From c83f7dd340eb9cf622183b208537fd43a9d95400 Mon Sep 17 00:00:00 2001 From: NkBe Date: Mon, 3 Aug 2026 14:24:46 +0200 Subject: [PATCH 01/13] Implement libxposed API 102: hot reload, detach and atomic hook replacement Adds the whole API 102 surface: hot reloading a module generation in place, XposedInterfaceWrapper#detach, hook ids with atomic replacement, and the rule that modules targeting 102 or higher cannot reach the legacy de.robv API. Hot reload targets are derived from the daemon's existing process registry rather than registered by the injected process, so system_server - whose modules load long before that cache exists - is a target by construction. getRunningTargets() reports every hooked process, including modules that cannot be reloaded, which answer UNSUPPORTED rather than being hidden. hotReloadModule() validates and enqueues, then reports through the callback. A frozen target is thawed for the transaction instead of surfacing as FAILED with a null message, which the API reserves for a module refusal. Squashed from the branch this PR carried (head f02468fe9, seventeen commits) so that the pull request is a linear series against master with no merge commits and none of master's own history in it. The corrections that follow rewrite parts of this, and reviewing them against a moving base was the problem this solves. --- daemon/proguard-rules.pro | 3 + .../matrix/vector/daemon/data/ConfigCache.kt | 15 +- .../matrix/vector/daemon/data/FileSystem.kt | 14 +- .../vector/daemon/ipc/ApplicationService.kt | 134 ++++++++- .../matrix/vector/daemon/ipc/CliHandler.kt | 2 + .../matrix/vector/daemon/ipc/ModuleService.kt | 130 +++++++++ .../vector/daemon/system/ProcessFreezer.kt | 57 ++++ gradle/libs.versions.toml | 4 + services/daemon-service/build.gradle.kts | 1 + .../lsposed/lspd/models/HotReloadOutcome.aidl | 21 ++ .../aidl/org/lsposed/lspd/models/Module.aidl | 1 + .../org/lsposed/lspd/models/PreLoadedApk.aidl | 3 + .../lspd/service/IHotReloadTarget.aidl | 20 ++ .../lspd/service/ILSPApplicationService.aidl | 11 + services/libxposed | 2 +- xposed/build.gradle.kts | 1 + xposed/consumer-rules.pro | 3 + xposed/libxposed | 2 +- .../org/matrix/vector/impl/VectorContext.kt | 18 +- .../vector/impl/VectorLifecycleManager.kt | 10 + .../vector/impl/core/VectorHotReloadTarget.kt | 16 + .../vector/impl/core/VectorModuleManager.kt | 274 ++++++++++++++++-- .../vector/impl/core/VectorServiceClient.kt | 19 ++ .../matrix/vector/impl/hooks/VectorChain.kt | 85 ++++-- .../vector/impl/hooks/VectorNativeHooker.kt | 143 ++++++++- .../impl/utils/VectorModuleClassLoader.kt | 34 ++- 26 files changed, 962 insertions(+), 61 deletions(-) create mode 100644 daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt create mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl create mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl create mode 100644 xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt diff --git a/daemon/proguard-rules.pro b/daemon/proguard-rules.pro index ab73abb05..94ebe3a0e 100644 --- a/daemon/proguard-rules.pro +++ b/daemon/proguard-rules.pro @@ -42,5 +42,8 @@ public static *** v(...); public static *** d(...); } +# The libxposed annotations are compile-only metadata and are not packaged +-dontwarn io.github.libxposed.annotation.** + -repackageclasses -allowaccessmodification 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 b2700306f..cbcfaf114 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 @@ -17,7 +17,9 @@ import org.lsposed.lspd.models.Application import org.lsposed.lspd.models.Module import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.VectorDaemon +import org.matrix.vector.daemon.ipc.ApplicationService import org.matrix.vector.daemon.ipc.InjectedModuleService +import org.matrix.vector.daemon.ipc.ModuleService import org.matrix.vector.daemon.system.* import org.matrix.vector.daemon.utils.InstallerVerifier import org.matrix.vector.daemon.utils.applySqliteHelperWorkaround @@ -231,6 +233,7 @@ object ConfigCache { packageName = pkgName this.apkPath = apkPath appId = appInfo.uid + versionCode = pkgInfo.longVersionCode applicationInfo = appInfo service = oldModule?.service ?: InjectedModuleService(pkgName) file = loaded.apk @@ -354,6 +357,15 @@ object ConfigCache { } Log.d(TAG, "Cache Update Complete. Map Swap successful.") + + // Targets are removed only after the module set has been published. + (oldState.modules.keys - newModules.keys).forEach { + ApplicationService.forgetHotReloadTargets(it) + } + ApplicationService.backfillLoadedVersions() + + // Ask stale opt-in targets to load the generation that was just installed. + newModules.values.forEach { ModuleService.autoHotReload(it) } // Log.d(TAG, "cached modules:") // newModules.forEach { (pkg, mod) -> Log.d(TAG, "$pkg ${mod.apkPath}") } @@ -410,9 +422,10 @@ object ConfigCache { service = InjectedModuleService(pkgName) } - runCatching { + runCatching { @Suppress("DEPRECATION") val pkg = PackageParser().parsePackage(File(apkPath), 0, false) + // A raw parse carries no version; backfillLoadedVersions supplies it later. module.applicationInfo = pkg.applicationInfo } .onFailure { diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt index d61a95912..c3d4c4893 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -240,6 +240,9 @@ object FileSystem { val moduleLibraryNames = mutableListOf() var isLegacy = false var exceptionPassthrough = false + var targetApiVersion = 0 + var minApiVersion = 0 + var autoHotReload = false runCatching { ZipFile(file).use { zip -> @@ -258,7 +261,10 @@ object FileSystem { } } - val targetApi = props.getProperty("targetApiVersion")?.trim()?.toIntOrNull() ?: 0 + val targetApi = leadingInt(props.getProperty("targetApiVersion")) + targetApiVersion = targetApi + minApiVersion = leadingInt(props.getProperty("minApiVersion")) + autoHotReload = props.getProperty("autoHotReload")?.trim().toBoolean() // The module-wide mode ExceptionMode.DEFAULT resolves to. Anything that is not // "passthrough" - absent, misspelled, or an explicit "protective" - keeps the // protective default the API specifies. @@ -342,6 +348,9 @@ object FileSystem { this.moduleLibraryNames = moduleLibraryNames this.legacy = isLegacy this.exceptionPassthrough = exceptionPassthrough + this.targetApiVersion = targetApiVersion + this.minApiVersion = minApiVersion + this.autoHotReload = autoHotReload } return ModuleLoad.Loaded(preLoadedApk) @@ -670,3 +679,6 @@ object FileSystem { return logDirPath.resolve(getNewLogFileName("modules")).toFile() } } + // Matches the manager's leading-integer parsing, including values such as "101.0". + private fun leadingInt(value: String?): Int = + value?.trim()?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0 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..c32fc5d6d 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,8 +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.AtomicInteger +import java.util.concurrent.atomic.AtomicLong import org.lsposed.lspd.models.Module +import org.lsposed.lspd.service.IHotReloadTarget import org.lsposed.lspd.service.ILSPApplicationService import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem @@ -30,8 +34,30 @@ object ApplicationService : ILSPApplicationService.Stub() { private val processes = ConcurrentHashMap() + /** One module generation loaded into one process: what a hot reload request addresses. */ + class HotReloadTarget( + val id: Long, + val modulePackageName: String, + val processName: String, + val uid: Int, + val pid: Int, + @Volatile var loadedVersionCode: Long, + val hotReloadable: Boolean, + ) { + val state = AtomicInteger(HookedProcess.TARGET_STATE_UP_TO_DATE) + } + + private val hotReloadTargets = ConcurrentHashMap() + + // Ids are framework-assigned and never reused, as HookedProcess.targetId requires. + private val nextHotReloadTargetId = AtomicLong(1) + private class ProcessInfo(val key: ProcessKey, val processName: String, val heartBeat: IBinder) : IBinder.DeathRecipient { + val targetIds = ConcurrentHashMap() + + @Volatile var hotReloadBinder: IHotReloadTarget? = null + init { heartBeat.linkToDeath(this, 0) processes[key] = this @@ -40,9 +66,114 @@ object ApplicationService : ILSPApplicationService.Stub() { override fun binderDied() { heartBeat.unlinkToDeath(this, 0) processes.remove(key) + targetIds.values.forEach { hotReloadTargets.remove(it) } + } + } + + private fun recordHotReloadTargets(info: ProcessInfo, modules: List) { + for (module in modules) { + info.targetIds.computeIfAbsent(module.packageName) { + val id = nextHotReloadTargetId.getAndIncrement() + hotReloadTargets[id] = + HotReloadTarget( + id = id, + modulePackageName = module.packageName, + processName = info.processName, + uid = info.key.uid, + pid = info.key.pid, + loadedVersionCode = module.versionCode, + // Hot reload is specified only for modules with exactly one Java entry class. + hotReloadable = module.file.moduleClassNames.size == 1, + ) + id + } + } + } + + // Not filtered to hot-reloadable targets: the AIDL documents this as hooked processes, and one + // that cannot be reloaded answers UNSUPPORTED rather than disappearing. + fun getHotReloadTargets(modulePackageName: String): List { + val installedVersion = ConfigCache.state.modules[modulePackageName]?.versionCode + return hotReloadTargets.values + .filter { it.modulePackageName == modulePackageName } + .map { target -> + HookedProcess().apply { + targetId = target.id + uid = target.uid + pid = target.pid + processName = target.processName + state = reportedState(target, installedVersion) + loadedVersionCode = target.loadedVersionCode + } + } + } + + // RELOADING and FAILED describe the last attempt and outrank a version comparison. + private fun reportedState(target: HotReloadTarget, installedVersion: Long?): Int { + val state = target.state.get() + if (state != HookedProcess.TARGET_STATE_UP_TO_DATE) return state + // Zero means unknown, not old; claiming STALE would never be satisfiable by a reload. + if (target.loadedVersionCode == 0L) return state + return if (installedVersion != null && installedVersion != target.loadedVersionCode) { + HookedProcess.TARGET_STATE_STALE + } else { + state + } + } + + // system_server records its targets before PMS exists, so they start without a version. + fun backfillLoadedVersions() { + hotReloadTargets.values + .filter { it.loadedVersionCode == 0L } + .forEach { target -> + ConfigCache.state.modules[target.modulePackageName] + ?.versionCode + ?.takeIf { it != 0L } + ?.let { target.loadedVersionCode = it } + } + } + + fun forgetHotReloadTargets(modulePackageName: String) { + hotReloadTargets.values.removeIf { it.modulePackageName == modulePackageName } + processes.values.forEach { it.targetIds.remove(modulePackageName) } + } + + fun staleHotReloadTargets(modulePackageName: String): List { + val installedVersion = ConfigCache.state.modules[modulePackageName]?.versionCode ?: return emptyList() + return hotReloadTargets.values.filter { + it.modulePackageName == modulePackageName && + it.hotReloadable && + it.loadedVersionCode != 0L && + it.loadedVersionCode != installedVersion } } + fun getHotReloadTarget(targetId: Long, modulePackageName: String): HotReloadTarget? = + hotReloadTargets[targetId]?.takeIf { it.modulePackageName == modulePackageName } + + // Reloads are serialized per target, so check and transition must be one atomic step. + fun beginHotReload(target: HotReloadTarget): Boolean { + while (true) { + val current = target.state.get() + if (current == HookedProcess.TARGET_STATE_RELOADING) return false + if (target.state.compareAndSet(current, HookedProcess.TARGET_STATE_RELOADING)) return true + } + } + + fun endHotReload(target: HotReloadTarget, state: Int, loadedVersionCode: Long? = null) { + loadedVersionCode?.let { target.loadedVersionCode = it } + target.state.set(state) + } + + fun getHotReloadBinder(target: HotReloadTarget): IHotReloadTarget? = + processes[ProcessKey(target.uid, target.pid)]?.hotReloadBinder + + override fun registerHotReloadTarget(target: IHotReloadTarget) { + val info = ensureRegistered() + info.hotReloadBinder = target + Log.d(TAG, "Hot reload target registered for ${info.processName} (pid=${info.key.pid})") + } + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { when (code) { DEX_TRANSACTION_CODE -> { @@ -98,7 +229,8 @@ object ApplicationService : ILSPApplicationService.Stub() { return ConfigCache.getModulesForProcess(info.processName, info.key.uid) } - override fun getModulesList() = getAllModules().filter { !it.file.legacy } + override fun getModulesList() = + getAllModules().filter { !it.file.legacy }.also { recordHotReloadTargets(ensureRegistered(), it) } override fun getLegacyModulesList() = getAllModules().filter { it.file.legacy } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt index 2013fabce..85d2a89a1 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt @@ -3,6 +3,7 @@ package org.matrix.vector.daemon.ipc import java.io.File import java.io.FileNotFoundException import java.io.IOException +import io.github.libxposed.service.IXposedService import org.lsposed.lspd.models.Application import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.CliRequest @@ -39,6 +40,7 @@ object CliHandler { return mapOf( "Framework Version" to BuildConfig.VERSION_NAME, "Version Code" to BuildConfig.VERSION_CODE, + "API Version" to IXposedService.LIB_API, "Enabled Modules" to ModuleDatabase.enabledModules().size, "Status Notification" to PreferenceStore.isStatusNotificationEnabled()) } 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 5bbeb3798..02af3b0d2 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt @@ -4,15 +4,19 @@ import android.content.AttributionSource import android.os.Binder import android.os.Build import android.os.Bundle +import android.os.DeadObjectException import android.os.ParcelFileDescriptor import android.os.RemoteException import android.util.Log +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 import java.util.Collections import java.util.WeakHashMap import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors import org.lsposed.lspd.models.Module import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.data.ConfigCache @@ -20,6 +24,7 @@ import org.matrix.vector.daemon.data.FileSystem import org.matrix.vector.daemon.data.ModuleDatabase import org.matrix.vector.daemon.data.PreferenceStore import org.matrix.vector.daemon.system.NotificationManager +import org.matrix.vector.daemon.system.ProcessFreezer import org.matrix.vector.daemon.system.PER_USER_RANGE import org.matrix.vector.daemon.system.activityManager @@ -28,6 +33,11 @@ private const val TAG = "VectorModuleService" class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { companion object { + // Per-target serialization lives on the target itself; this only keeps one slow target from + // delaying another. + private val hotReloadExecutor = + Executors.newCachedThreadPool { r -> Thread(r, "vector-hot-reload") } + private val uidSet = ConcurrentHashMap.newKeySet() private val serviceMap = Collections.synchronizedMap(WeakHashMap()) @@ -48,6 +58,18 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { fun uidGone(uid: Int) { uidSet.remove(uid) } + + // Drives the same cycle as a service request, so onHotReloading can still refuse it. + fun autoHotReload(module: Module) { + if (!module.file.autoHotReload) return + val service = serviceMap.getOrPut(module) { ModuleService(module) } + ApplicationService.staleHotReloadTargets(module.packageName).forEach { target -> + if (target.hotReloadable && ApplicationService.beginHotReload(target)) { + Log.d(TAG, "Auto hot reloading ${module.packageName} in ${target.processName}") + hotReloadExecutor.execute { service.runHotReload(target, null, null) } + } + } + } } /** @@ -179,6 +201,114 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { } } + override fun getRunningTargets(): List { + ensureModule() + return ApplicationService.getHotReloadTargets(loadedModule.packageName) + } + + override fun hotReloadModule(targetId: Long, data: Bundle?, callback: IHotReloadCallback?) { + ensureModule() + // SecurityException is reserved by the AIDL for exactly these two conditions, so it must not be + // raised for anything else on this path - a module-thrown SecurityException in particular has + // to reach the caller as a FAILED result, not as "invalid target id". + val target = + ApplicationService.getHotReloadTarget(targetId, loadedModule.packageName) + ?: throw SecurityException("Target $targetId is not a target of ${loadedModule.packageName}") + + if (!target.hotReloadable) { + // Hot reload is specified only for modules declaring exactly one Java entry class. + report(callback, IXposedService.HOT_RELOAD_UNSUPPORTED, "Module has no single Java entry class") + return + } + + if (!ApplicationService.beginHotReload(target)) { + report(callback, IXposedService.HOT_RELOAD_IN_PROGRESS, "A reload is already running") + return + } + + // The AIDL asks implementations to validate and enqueue promptly and report through the + // callback. Running the cycle inline would pin this binder thread for its whole duration and + // ANR a module app that called from its main thread. + hotReloadExecutor.execute { runHotReload(target, data, callback) } + } + + private fun runHotReload( + target: ApplicationService.HotReloadTarget, + data: Bundle?, + callback: IHotReloadCallback?, + ) { + var status = IXposedService.HOT_RELOAD_FAILED + var message: String? = "Hot reload did not run" + var refreeze: (() -> Unit)? = null + var loadedVersion: Long? = null + + try { + val binder = ApplicationService.getHotReloadBinder(target) + if (binder == null) { + status = IXposedService.HOT_RELOAD_UNSUPPORTED + message = "Process ${target.processName} has no hot reload entry point" + return + } + if (!binder.asBinder().isBinderAlive) { + status = IXposedService.HOT_RELOAD_PROCESS_DIED + message = "Process ${target.processName} is gone" + return + } + val newModule = ConfigCache.state.modules[loadedModule.packageName] + if (newModule == null) { + status = IXposedService.HOT_RELOAD_UNSUPPORTED + message = "No installed generation of ${loadedModule.packageName} to load" + return + } + + // A cached target is usually frozen, and a transaction to a frozen process never reaches the + // module. Thawing first is what keeps that case from being reported as a refusal. + refreeze = ProcessFreezer.thaw(target.uid, target.pid) + if (refreeze == null && ProcessFreezer.isFrozen(target.uid, target.pid)) { + status = IXposedService.HOT_RELOAD_FAILED + message = "Target process is frozen and could not be thawed" + return + } + + val outcome = binder.hotReload(loadedModule.packageName, data, newModule) + status = outcome.status + if (status == IXposedService.HOT_RELOAD_SUCCEEDED) loadedVersion = newModule.versionCode + // A null message is reserved for a refusal, so anything else gets one supplied. + message = + outcome.message + ?: if (status == IXposedService.HOT_RELOAD_FAILED && !outcome.refused) { + "Hot reload failed without a diagnostic message" + } else { + null + } + } catch (e: DeadObjectException) { + status = IXposedService.HOT_RELOAD_PROCESS_DIED + message = "Process ${target.processName} died during hot reload" + } catch (t: Throwable) { + status = IXposedService.HOT_RELOAD_FAILED + message = "${t.javaClass.name}: ${t.message ?: "no message"}" + Log.e(TAG, "Hot reload of ${loadedModule.packageName} failed", t) + } finally { + refreeze?.invoke() + ApplicationService.endHotReload(target, stateFor(status), loadedVersion) + report(callback, status, message) + } + } + + private fun stateFor(status: Int): Int = + when (status) { + IXposedService.HOT_RELOAD_SUCCEEDED -> HookedProcess.TARGET_STATE_UP_TO_DATE + IXposedService.HOT_RELOAD_FAILED -> HookedProcess.TARGET_STATE_FAILED + // Unsupported and process-died say nothing about the generation the target is running, so + // the reported state falls back to comparing versions. + else -> HookedProcess.TARGET_STATE_UP_TO_DATE + } + + private fun report(callback: IHotReloadCallback?, status: Int, message: String?) { + runCatching { callback?.onHotReloadResult(status, message) } + .onFailure { Log.w(TAG, "Cannot deliver hot reload result to ${loadedModule.packageName}", it) } + } + override fun requestRemotePreferences(group: String): Bundle { val userId = ensureModule() return Bundle().apply { diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt new file mode 100644 index 000000000..8a08c6b6e --- /dev/null +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt @@ -0,0 +1,57 @@ +package org.matrix.vector.daemon.system + +import android.util.Log +import java.io.File + +private const val TAG = "VectorFreezer" + +/** + * Thaws a frozen process for the duration of a daemon-initiated transaction. + * + * Android freezes cached processes, and a module's hooked targets are usually cached ones. A binder + * transaction does not reach a frozen process, so without this a hot reload of a backgrounded target + * fails without ever running module code - indistinguishable from the module returning false from + * onHotReloading, which is the one case the API reserves a null message for. + */ +object ProcessFreezer { + + /** + * The freezer is a cgroup v2 file. Newer releases give each process its own group; older ones and + * some vendor trees freeze at the uid level, so both layouts are probed. + */ + private fun freezeFile(uid: Int, pid: Int): File? = + sequenceOf( + "/sys/fs/cgroup/apps/uid_$uid/pid_$pid/cgroup.freeze", + "/sys/fs/cgroup/system/uid_$uid/pid_$pid/cgroup.freeze", + "/sys/fs/cgroup/apps/uid_$uid/cgroup.freeze", + "/sys/fs/cgroup/system/uid_$uid/cgroup.freeze", + ) + .map(::File) + .firstOrNull { it.exists() } + + fun isFrozen(uid: Int, pid: Int): Boolean = + runCatching { freezeFile(uid, pid)?.readText()?.trim() == "1" }.getOrDefault(false) + + /** + * Thaws the process if it is frozen and returns an action that restores the previous state, or + * null if nothing was changed. The caller must run the returned action once the transaction is + * done, otherwise the process is left permanently runnable. + */ + fun thaw(uid: Int, pid: Int): (() -> Unit)? { + val file = freezeFile(uid, pid) ?: return null + val wasFrozen = runCatching { file.readText().trim() == "1" }.getOrDefault(false) + if (!wasFrozen) return null + + val thawed = runCatching { file.writeText("0") }.isSuccess + if (!thawed) { + Log.w(TAG, "Cannot thaw uid=$uid pid=$pid through ${file.path}") + return null + } + + Log.d(TAG, "Thawed uid=$uid pid=$pid for a daemon transaction") + return { + runCatching { file.writeText("1") } + .onFailure { Log.w(TAG, "Cannot re-freeze uid=$uid pid=$pid", it) } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59e4a1167..e1d09d2b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,6 +68,10 @@ okhttp-dnsoverhttps = { group = "com.squareup.okhttp3", name = "okhttp-dnsoverht agp-apksig = { group = "com.android.tools.build", name = "apksig", version.ref = "agp" } gson = { module = "com.google.code.gson:gson", version = "2.14.0" } +# The libxposed API sources vendored under xposed/libxposed and services/libxposed carry +# @SinceApi and @InternalApi from API 102 onwards. Both are CLASS-retained metadata with no +# runtime behaviour, so this is compileOnly everywhere and never reaches a device. +libxposed-annotation = { module = "io.github.libxposed:annotation", version = "1.0.0" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } picocli = { module = "info.picocli:picocli", version = "4.7.7" } diff --git a/services/daemon-service/build.gradle.kts b/services/daemon-service/build.gradle.kts index 41a5df9bf..58c1fe1ed 100644 --- a/services/daemon-service/build.gradle.kts +++ b/services/daemon-service/build.gradle.kts @@ -18,5 +18,6 @@ android { dependencies { compileOnly(libs.androidx.annotation) + compileOnly(libs.libxposed.annotation) compileOnly(projects.hiddenapi.stubs) } diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl new file mode 100644 index 000000000..7ad9d01de --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl @@ -0,0 +1,21 @@ +package org.lsposed.lspd.models; + +/** + * Result of a hot reload performed inside a hooked process. + */ +parcelable HotReloadOutcome { + /** One of IXposedService.HOT_RELOAD_*. */ + int status; + + /** + * Diagnostic message. Null is reserved for a module refusal, so every other failure has to + * carry a message even when the module's own exception had none. + */ + String message; + + /** + * True only when onHotReloading returned false. This is what lets the daemon keep a null + * message for a genuine refusal while supplying one for every other failure. + */ + boolean refused; +} diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/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/models/PreLoadedApk.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl index 96c25a019..8234b22db 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl @@ -5,6 +5,9 @@ parcelable PreLoadedApk { List moduleClassNames; List moduleLibraryNames; boolean legacy; + int targetApiVersion; + int minApiVersion; + boolean autoHotReload; // module.prop 'exceptionMode', normalised by the daemon. false, the value an absent key // parses to, is PROTECTIVE - what ExceptionMode.DEFAULT is specified to fall back to. boolean exceptionPassthrough; diff --git a/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 497446194..7ee9559c5 100644 --- a/xposed/build.gradle.kts +++ b/xposed/build.gradle.kts @@ -34,5 +34,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..39cac0845 160000 --- a/xposed/libxposed +++ b/xposed/libxposed @@ -1 +1 @@ -Subproject commit edeb8379c067b16b91af3cb526f5f04db25c06b6 +Subproject commit 39cac0845771547c9c67a3e3ce255af110a54a0e 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 1064913b9..f150752bd 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt @@ -65,6 +65,15 @@ class VectorContext( ) : XposedInterface { private val remotePrefs = ConcurrentHashMap() + @Volatile private var frozen = false + + fun freeze() { + frozen = true + } + + fun unfreeze() { + frozen = false + } override fun getFrameworkName(): String = BuildConfig.FRAMEWORK_NAME @@ -77,14 +86,19 @@ class VectorContext( } override fun hook(origin: Executable): XposedInterface.HookBuilder { - return VectorHookBuilder(origin, defaultExceptionMode) + return VectorHookBuilder(origin, packageName, { frozen }, defaultExceptionMode) } override fun hookClassInitializer(origin: Class<*>): XposedInterface.HookBuilder { val clinit = findStaticInitializer(origin) ?: throw IllegalArgumentException("Class ${origin.name} has no static initializer") - return VectorHookBuilder(asSyntheticMethod(clinit), defaultExceptionMode) + return VectorHookBuilder( + asSyntheticMethod(clinit), + packageName, + { frozen }, + defaultExceptionMode, + ) } /** 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 02f1fc488..c72515192 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) + /** * The API declares `onPackageLoaded` as API 29 and up, so this carries the same requirement * rather than leaving the callers to remember it. `LoadedApkCreateAppFactoryHooker` is the only 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 c9983bf0f..e9e6007d7 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,15 +1,25 @@ 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.XposedInterface.ExceptionMode 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 @@ -21,10 +31,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}") @@ -55,6 +119,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 @@ -63,7 +128,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 @@ -86,6 +151,7 @@ object VectorModuleManager { } // Instantiate the module entry classes + val entries = mutableListOf() for (className in module.file.moduleClassNames) { runCatching { val moduleClass = moduleClassLoader.loadClass(className) @@ -100,29 +166,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) } } - 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 8867c52b8..e795c4569 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,32 +1,66 @@ 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.Collections +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 @@ -39,9 +73,6 @@ class VectorChain( override fun getThisObject(): Any? = thisObj - // Immutable, and a snapshot rather than a view: the chain rewrites this array in place when a - // hooker calls proceed(args) and when a legacy hook edits its arguments, which would otherwise - // change a list a hooker is still holding. override fun getArgs(): List = Collections.unmodifiableList(args.toMutableList()) override fun getArg(index: Int): Any? = args[index] @@ -63,18 +94,31 @@ 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) { - // Recording the recovery keeps this node's cached state consistent: once the hooker's - // exception has been suppressed, parent nodes must observe the recovered outcome and - // not the exception we just swallowed. executeDownstream { - handleInterceptorException(t, record, nextChain, thisObject, currentArgs) + handleInterceptorException( + t, + hooker, + exceptionMode, + nextChain, + thisObject, + currentArgs, + ) } } } @@ -82,10 +126,6 @@ class VectorChain( /** * Executes the block and caches the downstream state so parent chains can recover it if the * current interceptor crashes during post-processing. - * - * Exactly one of [downstreamResult] and [downstreamThrowable] is meaningful after this returns, - * so both are always written; leaving a stale value behind would let a parent node resurrect an - * exception this node already handled. */ private inline fun executeDownstream(block: () -> Any?): Any? { return try { @@ -103,7 +143,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, @@ -114,11 +155,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 920018bcd..366686fc4 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 @@ -11,20 +11,25 @@ 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. */ +/** + * 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, - // Framework-internal hooks have no module.prop, and must stay protective: letting one of - // them propagate would take the boot path down with it. + private val moduleId: Any? = null, + private val frozen: (() -> Boolean)? = null, private val defaultExceptionMode: ExceptionMode = ExceptionMode.PROTECTIVE, ) : 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 } @@ -32,7 +37,14 @@ class VectorHookBuilder( 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( "$origin is abstract: it has no body to hook. Hook the concrete override instead." @@ -71,12 +83,43 @@ class VectorHookBuilder( ) } - // Resolve DEFAULT here rather than at throw time: the record is stored natively and - // reaches VectorChain with no way back to the module, and module.prop cannot change - // for the life of the process. val resolvedMode = if (exceptionMode == ExceptionMode.DEFAULT) defaultExceptionMode else exceptionMode - val record = VectorHookRecord(hooker, priority, resolvedMode) + 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, resolvedMode, 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, resolvedMode, null) // Register natively. HookBridge now stores VectorHookRecord instead of HookerCallback. if ( @@ -85,16 +128,91 @@ class VectorHookBuilder( 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) + + 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 unhook() { - HookBridge.unhookMethod(true, 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. @@ -109,8 +227,7 @@ class VectorNativeHooker(private val method: T) { val thisObject = if (isStatic) null else args[0] val actualArgs = if (isStatic) args else args.sliceArray(1 until args.size) - // Retrieve the hook snapshots. Null means every hook was removed after this trampoline was - // entered, which is indistinguishable from having none. + // Null means every hook was removed after this trampoline was entered. val snapshots = HookBridge.callbackSnapshot(VectorHookRecord::class.java, method) ?: return invokeOriginalSafely(thisObject, actualArgs) 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) } From e2ea07596465ca7de21659f5422bb7cf4bbb56f6 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 14:27:35 +0200 Subject: [PATCH 02/13] Commit the new generation before onHotReloaded, and never roll back The interface releases the old generation "after this callback returns or throws", so a throw is not a signal to undo the reload. Rolling back could not have honoured that anyway: by the time onHotReloaded throws, the new code may already have called replaceHook, and unhooking only what was added since leaves the restored old entries running new hookers - a half-swapped state with no name. So the swap is committed first and a throw is reported as FAILED with the exception's diagnostic, which is exactly what the API describes: the process runs new code that migrated some of its hooks and not others. That makes 'did the reload succeed' and 'did the generation change' two different questions, so HotReloadOutcome now answers both. Without the second one the daemon would keep reporting the old loadedVersionCode for a target that is demonstrably running the new code, and getRunningTargets() would be lying about which generation is loaded. --- .../matrix/vector/daemon/ipc/ModuleService.kt | 6 ++- .../lsposed/lspd/models/HotReloadOutcome.aidl | 10 ++++ .../vector/impl/core/VectorModuleManager.kt | 54 +++++++++++-------- 3 files changed, 47 insertions(+), 23 deletions(-) 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 02af3b0d2..ebfa77555 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 @@ -272,7 +272,11 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { val outcome = binder.hotReload(loadedModule.packageName, data, newModule) status = outcome.status - if (status == IXposedService.HOT_RELOAD_SUCCEEDED) loadedVersion = newModule.versionCode + // Whether the generation was swapped is not the same question as whether the reload + // succeeded: onHotReloaded runs after the swap is committed, so a throw from it leaves the + // process on the new code and still reports FAILED. Recording the version the target is + // actually running is what keeps getRunningTargets() honest about it. + if (outcome.generationChanged) loadedVersion = newModule.versionCode // A null message is reserved for a refusal, so anything else gets one supplied. message = outcome.message diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl index 7ad9d01de..f78dc1588 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl @@ -18,4 +18,14 @@ parcelable HotReloadOutcome { * message for a genuine refusal while supplying one for every other failure. */ boolean refused; + + /** + * True once the process has actually swapped generations, whatever the status says. + * + * onHotReloaded is called after the swap is committed, because the interface releases the old + * generation "after this callback returns or throws". So a throw from it reports FAILED while + * the process is running the new code, and the daemon has to record the new version anyway - + * otherwise getRunningTargets() would keep naming a generation that is no longer loaded. + */ + boolean generationChanged; } 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 e9e6007d7..f0c136b31 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 @@ -270,9 +270,6 @@ object VectorModuleManager { // 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. @@ -291,26 +288,31 @@ object VectorModuleManager { 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) + // Committed before the callback runs, because the interface releases the old generation + // "after this callback returns or throws" - there is no rollback. A throw here leaves the + // process running new code that has migrated some of its hooks and not others, and says so + // through the result; rolling back could not undo the replaceHook calls the new code had + // already made anyway, and would restore old entries whose hooks now run new hookers. + generations[packageName] = newGeneration + + var failure: Throwable? = null + // The default onHotReloaded already unhooks these; doing both would double-unhook. + newEntries + .filter { VectorLifecycleManager.isActive(it) } + .forEach { + if (failure != null) return@forEach + runCatching { it.onHotReloaded(reloadedParam) }.onFailure { t -> failure = t } } - } 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)) + + // The last framework-owned reference to the old generation goes with this frame: its map + // entry is gone, its entries are out of activeModules, and oldEntries dies on return. + failure?.let { + Log.e(TAG, "onHotReloaded of $packageName threw", it) + return failed(describe(it), generationChanged = true) } - // 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) + return outcome(IXposedService.HOT_RELOAD_SUCCEEDED, null, generationChanged = true) } /** @@ -351,16 +353,24 @@ object VectorModuleManager { return false } - private fun outcome(status: Int, message: String?, refused: Boolean = false) = + private fun outcome( + status: Int, + message: String?, + refused: Boolean = false, + generationChanged: Boolean = false, + ) = HotReloadOutcome().apply { this.status = status this.message = message this.refused = refused + this.generationChanged = generationChanged } - private fun unsupported(message: String) = outcome(IXposedService.HOT_RELOAD_UNSUPPORTED, message) + private fun unsupported(message: String) = + outcome(IXposedService.HOT_RELOAD_UNSUPPORTED, message) - private fun failed(message: String) = outcome(IXposedService.HOT_RELOAD_FAILED, message) + private fun failed(message: String, generationChanged: Boolean = false) = + outcome(IXposedService.HOT_RELOAD_FAILED, message, generationChanged = generationChanged) private fun refusal() = outcome(IXposedService.HOT_RELOAD_FAILED, null, refused = true) From 43657347d38a8ce390891f747e8bf09c426b181e Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 14:28:20 +0200 Subject: [PATCH 03/13] Replace a hook natively instead of mutating its record The interface promises that replacing a hook is atomic and that a call already in flight keeps the chain it started with. Mutating the hooker inside the installed record gets the first for free - the native map never sees two records - but pays for the second by having VectorChain freeze an Array(hooks.size) of hookers at the start of every hooked call. That is an allocation on the hottest path in the framework, for a property only hot reload needs. HookBridge.replaceCallback swaps the jobject inside the callback multimap under the same lock callbackSnapshot takes, so a snapshot sees exactly one of the two, and a snapshot taken earlier keeps working because it copied the reference into a Java array of its own. The record goes back to being immutable, the per-call array goes away, and in-flight isolation falls out of the existing design. It also fixes a defect by construction. A superseded handle could still unhook the hook that replaced it - unhook() checked only whether the record was installed, never whether this handle still owned it - although the interface says such a handle 'is no longer valid'. Now the record it holds is no longer in the map, so IsSameObject finds nothing and there is nothing to cancel. Hook identity moves into VectorHookHandle and VectorHookRegistry: one live handle per registration, ids scoped to (module, executable, id) under a per-module lock that the reload also takes to freeze old code, so a registration racing the freeze either lands before it and is handed to the successor, or fails. And setId now keeps the caller's priority and exception mode. Only the handle-based replaceHook is specified to inherit them; registering a new hook that happens to carry the same id is a new hook. --- native/src/jni/hook_bridge.cpp | 57 ++++++ .../vector/impl/core/VectorModuleManager.kt | 6 +- .../matrix/vector/impl/hooks/VectorChain.kt | 64 ++----- .../vector/impl/hooks/VectorHookHandle.kt | 144 +++++++++++++++ .../vector/impl/hooks/VectorNativeHooker.kt | 171 ++++++------------ .../matrix/vector/nativebridge/HookBridge.kt | 21 +++ 6 files changed, 296 insertions(+), 167 deletions(-) create mode 100644 xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorHookHandle.kt diff --git a/native/src/jni/hook_bridge.cpp b/native/src/jni/hook_bridge.cpp index 29ff2ec64..0176ea43d 100644 --- a/native/src/jni/hook_bridge.cpp +++ b/native/src/jni/hook_bridge.cpp @@ -203,6 +203,60 @@ VECTOR_DEF_NATIVE_METHOD(jboolean, HookBridge, unhookMethod, jboolean useModernA return JNI_FALSE; } +/** + * @brief Swaps one registered callback for another in a single locked step. + * + * API 102's HookHandle#replaceHook and HookBuilder#setId both promise that a replacement is atomic: + * no window in which both the old and the new hooker are on the chain, and none in which neither + * is. Doing it as unhook-then-hook from Java can promise neither. + * + * The lock taken here is the one callbackSnapshot takes, so a snapshot sees exactly one of the two. + * A snapshot taken before the swap keeps working afterwards because it copied the reference into a + * Java array, which is a strong reference of its own - that is what lets a call already in flight + * keep running the old hooker, as the interface requires, without the chain having to freeze a + * hooker list of its own on every single hooked call. + * + * The entry keeps its place among equal priorities when the priority does not change, which is what + * replaceHook means by "keeps the priority": re-inserting would move it behind its peers. + * + * @return JNI_TRUE when oldCallback was found and replaced. + */ +VECTOR_DEF_NATIVE_METHOD(jboolean, HookBridge, replaceCallback, jboolean useModernApi, + jobject hookMethod, jobject oldCallback, jobject newCallback, + jint newPriority) { + auto target = env->FromReflectedMethod(hookMethod); + HookItem *hook_item = nullptr; + hooked_methods.if_contains(target, + [&hook_item](const auto &it) { hook_item = it.second.get(); }); + if (!hook_item) return JNI_FALSE; + + jobject backup = hook_item->GetBackup(); + if (!backup) return JNI_FALSE; + + lsplant::JNIMonitor monitor(env, backup); + + auto &callbacks = useModernApi ? hook_item->modern_callbacks : hook_item->legacy_callbacks; + + for (auto i = callbacks.begin(); i != callbacks.end(); ++i) { + if (!env->IsSameObject(i->second, oldCallback)) continue; + + auto replacement = env->NewGlobalRef(newCallback); + // Nothing has been changed yet, so the caller's hook is still whatever it was. + if (!replacement) return JNI_FALSE; + + env->DeleteGlobalRef(i->second); + if (i->first == newPriority) { + i->second = replacement; + } else { + callbacks.erase(i); + callbacks.emplace(newPriority, replacement); + } + return JNI_TRUE; + } + + return JNI_FALSE; +} + /** * @brief JNI method to request de-optimization of a method. * This can be necessary for some types of hooks to work correctly on JIT-compiled methods. @@ -712,6 +766,9 @@ static JNINativeMethod gMethods[] = { "lang/Object;)Z"), VECTOR_NATIVE_METHOD(HookBridge, unhookMethod, "(ZLjava/lang/reflect/Executable;Ljava/lang/Object;)Z"), + VECTOR_NATIVE_METHOD(HookBridge, replaceCallback, + "(ZLjava/lang/reflect/Executable;Ljava/lang/Object;Ljava/" + "lang/Object;I)Z"), VECTOR_NATIVE_METHOD(HookBridge, deoptimizeMethod, "(Ljava/lang/reflect/Executable;)Z"), VECTOR_NATIVE_METHOD(HookBridge, invokeOriginalMethod, "(Ljava/lang/reflect/Executable;Ljava/lang/Object;[Ljava/" 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 f0c136b31..35b0422f3 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 @@ -240,7 +240,11 @@ object VectorModuleManager { val (newGeneration, newEntries) = built // Before the callback, so registrations from inside it fail while unhook and replace work. - old.context.freeze() + // Under the hook registry's lock for this module, because a registration on another thread + // that has already passed its own check must either finish before the freeze - and so be in + // the list the successor is handed - or see the freeze and fail. Held for the flag write + // only: module code never runs inside it. + synchronized(VectorHookBuilder.lockOf(packageName)) { old.context.freeze() } var savedState: Any? = null val reloadingParam = 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 e795c4569..9d0982bff 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 @@ -5,62 +5,37 @@ import io.github.libxposed.api.XposedInterface.ExceptionMode import io.github.libxposed.api.XposedInterface.Hooker import java.lang.reflect.Executable import java.util.Collections -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]. */ +/** + * A registered hook configuration, stored natively by [HookBridge]. + * + * Immutable, and that is what makes the chain snapshot based for free. Replacing a hook swaps this + * whole object inside the native callback map rather than editing it, so an array + * `callbackSnapshot` already copied for a call in flight keeps pointing at the record that call + * started with. Making the hooker mutable instead would force every hooked call to freeze a hooker + * array of its own, which is an allocation on the hottest path in the framework. + */ 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 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 -private constructor( +class VectorChain( 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 @@ -94,18 +69,11 @@ private constructor( return executeDownstream { terminal(thisObject, currentArgs) } } - val hooker = hookers[hookIndex] - val exceptionMode = hooks[hookIndex].exceptionMode + val record = hooks[hookIndex] + val hooker = record.hooker + val exceptionMode = record.exceptionMode val nextChain = - VectorChain( - executable, - thisObject, - currentArgs, - hooks, - hookers, - hookIndex + 1, - terminal, - ) + VectorChain(executable, thisObject, currentArgs, hooks, hookIndex + 1, terminal) return try { executeDownstream { hooker.intercept(nextChain) } diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorHookHandle.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorHookHandle.kt new file mode 100644 index 000000000..7bcbbcfd2 --- /dev/null +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorHookHandle.kt @@ -0,0 +1,144 @@ +package org.matrix.vector.impl.hooks + +import io.github.libxposed.api.XposedInterface.HookHandle +import io.github.libxposed.api.XposedInterface.Hooker +import io.github.libxposed.api.error.HookFailedError +import java.lang.reflect.Executable +import java.util.concurrent.ConcurrentHashMap +import org.matrix.vector.nativebridge.HookBridge + +/** + * What the process remembers about the hooks a module has installed. + * + * Keyed by module package name rather than by generation, because both things kept here have to + * outlive one: an id exists so that new code can name a hook old code installed, and the handle list + * handed to `onHotReloaded` is by definition the previous generation's. + */ +internal object VectorHookRegistry { + + private data class IdKey(val moduleId: String, val executable: Executable, val id: String) + + private val ids = ConcurrentHashMap() + private val byModule = ConcurrentHashMap>() + private val locks = ConcurrentHashMap() + + /** + * Serialises everything that decides which handle owns a registration, and is also what a hot + * reload takes to freeze old code without racing a registration already under way. + */ + fun lockOf(moduleId: String): Any = locks.computeIfAbsent(moduleId) { Any() } + + fun findId(moduleId: String, origin: Executable, id: String): VectorHookHandle? = + ids[IdKey(moduleId, origin, id)] + + fun claimId(moduleId: String, origin: Executable, id: String, handle: VectorHookHandle) { + ids[IdKey(moduleId, origin, id)] = handle + } + + fun releaseId(moduleId: String, origin: Executable, id: String, handle: VectorHookHandle) { + ids.remove(IdKey(moduleId, origin, id), handle) + } + + fun track(moduleId: String, handle: VectorHookHandle) { + byModule.computeIfAbsent(moduleId) { ConcurrentHashMap.newKeySet() }.add(handle) + } + + fun forget(moduleId: String, handle: VectorHookHandle) { + byModule[moduleId]?.remove(handle) + } + + /** The hooks of [moduleId] that are still installed, for `HotReloadedParam#getOldHookHandles`. */ + fun liveHandles(moduleId: String): List = + byModule[moduleId]?.filter { it.isLive } ?: emptyList() +} + +/** + * The handle a module holds onto a hook it installed. + * + * A handle owns exactly one native record at a time, and a replacement mints a new handle and kills + * this one - the interface is explicit that "after a successful replacement, this handle is no + * longer valid", so a superseded handle must not be able to act on the record that replaced it, + * `unhook()` included. + * + * [moduleId] is null for the framework's own hooks, which have no module to scope an id to and + * nothing that could replace them. + */ +class VectorHookHandle +internal constructor( + private val origin: Executable, + private val moduleId: String?, + initialRecord: VectorHookRecord, +) : HookHandle { + + @Volatile + internal var record: VectorHookRecord = initialRecord + private set + + @Volatile + internal var isLive: Boolean = true + private set + + override fun getExecutable(): Executable = origin + + override fun getId(): String? = record.id + + override fun unhook() { + if (moduleId == null) { + synchronized(this) { + if (!isLive) return + isLive = false + } + HookBridge.unhookMethod(true, origin, record) + return + } + synchronized(VectorHookRegistry.lockOf(moduleId)) { + // Idempotent, as the interface requires - and a handle that has already been superseded + // has to stay quiet here rather than tear down its own replacement. + if (!isLive) return + isLive = false + record.id?.let { VectorHookRegistry.releaseId(moduleId, origin, it, this) } + VectorHookRegistry.forget(moduleId, this) + HookBridge.unhookMethod(true, origin, record) + } + } + + override fun replaceHook(hooker: Hooker): HookHandle { + @Suppress("SENSELESS_COMPARISON") + if (hooker == null) throw IllegalArgumentException("hooker is null") + + val moduleId = + this.moduleId ?: throw IllegalStateException("This hook does not belong to a module") + + synchronized(VectorHookRegistry.lockOf(moduleId)) { + if (!isLive) throw IllegalStateException("This hook handle is no longer valid") + // Everything but the hooker is inherited, which is what distinguishes this from + // registering a new hook that happens to carry the same id. + return swapLocked( + VectorHookRecord(hooker, record.priority, record.exceptionMode, record.id) + ) + } + } + + /** + * Puts [replacement] where this handle's record is and hands the registration to a fresh handle. + * Callers hold this module's lock, and [replacement] must carry this record's id. + */ + internal fun swapLocked(replacement: VectorHookRecord): VectorHookHandle { + val moduleId = checkNotNull(moduleId) + if ( + !HookBridge.replaceCallback(true, origin, record, replacement, replacement.priority) + ) { + // The record was not where we left it, and nothing was changed, so whatever hook is + // installed now stays installed. + throw HookFailedError("Cannot replace the hook on $origin") + } + + isLive = false + VectorHookRegistry.forget(moduleId, this) + + val handle = VectorHookHandle(origin, moduleId, replacement) + VectorHookRegistry.track(moduleId, handle) + replacement.id?.let { VectorHookRegistry.claimId(moduleId, origin, it, handle) } + return handle + } +} 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 366686fc4..503fc49a8 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 @@ -11,7 +11,6 @@ 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 @@ -22,7 +21,7 @@ import org.matrix.vector.nativebridge.HookBridge */ class VectorHookBuilder( private val origin: Executable, - private val moduleId: Any? = null, + private val moduleId: String? = null, private val frozen: (() -> Boolean)? = null, private val defaultExceptionMode: ExceptionMode = ExceptionMode.PROTECTIVE, ) : HookBuilder { @@ -40,11 +39,7 @@ class VectorHookBuilder( 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" - ) - } + ensureNotFrozen() if (Modifier.isAbstract(origin.modifiers)) { throw IllegalArgumentException( "$origin is abstract: it has no body to hook. Hook the concrete override instead." @@ -86,133 +81,73 @@ class VectorHookBuilder( val resolvedMode = if (exceptionMode == ExceptionMode.DEFAULT) defaultExceptionMode else 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, resolvedMode, 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) + val record = VectorHookRecord(hooker, priority, resolvedMode, id) + + // A framework hook. No module, so no id to scope and nothing to serialise against. + val moduleId = this.moduleId ?: return register(record, null) + + synchronized(VectorHookRegistry.lockOf(moduleId)) { + // Checked again under the lock a reload takes to freeze old code, so a registration + // cannot slip in between the check above and the snapshot the successor is handed. + ensureNotFrozen() + + // Same module, same executable, same id: the interface says this replaces the old hook + // atomically and invalidates its handle, rather than installing a second one. The + // replacement carries this builder's priority and exception mode, because it is a new + // hook - only the handle-based replaceHook is specified to inherit them. + if (id != null) { + VectorHookRegistry.findId(moduleId, origin, id)?.takeIf { it.isLive }?.let { + return it.swapLocked(record) } - // 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()) + return register(record, moduleId) } + } - val record = VectorHookRecord(hooker, priority, resolvedMode, null) + private fun ensureNotFrozen() { + if (frozen?.invoke() == true) { + throw IllegalStateException( + "This module generation has been retired by a hot reload and cannot register hooks" + ) + } + } - // Register natively. HookBridge now stores VectorHookRecord instead of HookerCallback. + /** Installs [record] natively and records the handle against its owner. */ + private fun register(record: VectorHookRecord, moduleId: String?): HookHandle { if ( - !HookBridge.hookMethod(true, origin, VectorNativeHooker::class.java, priority, record) + !HookBridge.hookMethod( + true, + origin, + VectorNativeHooker::class.java, + record.priority, + record, + ) ) { throw HookFailedError("Cannot hook $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)) + val handle = VectorHookHandle(origin, moduleId, record) + if (moduleId != null) { + VectorHookRegistry.track(moduleId, handle) + record.id?.let { VectorHookRegistry.claimId(moduleId, origin, it, handle) } + } + return handle } - private fun handleFor(record: VectorHookRecord, epoch: Int): HookHandle = - handleFor(origin, moduleId, record, epoch) - 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) - } - } - } + /** + * The hooks [moduleId] currently has installed, which is what a reload hands to the new + * generation. Taken after old code has been frozen, so nothing can be added to it behind + * the successor's back. + */ + fun snapshotHandles(moduleId: String): List = + VectorHookRegistry.liveHandles(moduleId) + + /** The lock a reload holds while it freezes old code. */ + fun lockOf(moduleId: String): Any = VectorHookRegistry.lockOf(moduleId) } } -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/nativebridge/HookBridge.kt b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt index 9478806fb..1d2b8bd13 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt @@ -23,6 +23,26 @@ object HookBridge { callback: Any?, ): Boolean + /** + * Swaps [oldCallback] for [newCallback] on [hookMethod] under the lock [callbackSnapshot] + * takes, so no snapshot can observe both or neither. + * + * A snapshot taken before this returns keeps running [oldCallback]: it copied the reference + * into an array of its own. That is what makes a replacement invisible to a call already in + * flight, which is what `HookHandle#replaceHook` promises. + * + * Returns false when [oldCallback] is no longer registered, which is the caller's cue that the + * handle it holds has already been replaced or unhooked. + */ + @JvmStatic + external fun replaceCallback( + useModernApi: Boolean, + hookMethod: Executable, + oldCallback: Any?, + newCallback: Any?, + newPriority: Int, + ): Boolean + @JvmStatic external fun deoptimizeMethod(method: Executable): Boolean @JvmStatic @@ -94,4 +114,5 @@ object HookBridge { artMethods: LongArray, artMethodSize: Long, ): Executable? + } From e8ea60b6a86689cfc480a8d808d477c1f8044a4a Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 14:28:57 +0200 Subject: [PATCH 04/13] Record a module's native entry points once, not once per generation RegisterNativeLib appends to a list the dlopen hook walks without stopping at the first match, so a name recorded twice means native_init runs twice for one library. Two paths were producing duplicates: loadModule called recordNativeEntrypoint after buildGeneration had already done it, and every hot reload records the same module's names again for the new generation. Deduplicating natively fixes both, and is the only place that can - the spec is explicit that the framework does not dlclose or call JNI_OnUnload across a hot reload, so there is no removal to pair a re-registration with. Also drops minApiVersion from PreLoadedApk. It was parsed and stored and never read: the API puts that check on the module through getApiVersion(), and the manager reads module.prop itself for what it displays. --- .../org/matrix/vector/daemon/data/FileSystem.kt | 6 ++---- native/src/core/native_api.cpp | 9 +++++++++ .../aidl/org/lsposed/lspd/models/PreLoadedApk.aidl | 12 +++++++++++- .../matrix/vector/impl/core/VectorModuleManager.kt | 8 ++++---- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt index c3d4c4893..5d4c90441 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -241,7 +241,6 @@ object FileSystem { var isLegacy = false var exceptionPassthrough = false var targetApiVersion = 0 - var minApiVersion = 0 var autoHotReload = false runCatching { @@ -263,7 +262,6 @@ object FileSystem { val targetApi = leadingInt(props.getProperty("targetApiVersion")) targetApiVersion = targetApi - minApiVersion = leadingInt(props.getProperty("minApiVersion")) autoHotReload = props.getProperty("autoHotReload")?.trim().toBoolean() // The module-wide mode ExceptionMode.DEFAULT resolves to. Anything that is not // "passthrough" - absent, misspelled, or an explicit "protective" - keeps the @@ -349,7 +347,6 @@ object FileSystem { this.legacy = isLegacy this.exceptionPassthrough = exceptionPassthrough this.targetApiVersion = targetApiVersion - this.minApiVersion = minApiVersion this.autoHotReload = autoHotReload } @@ -678,7 +675,8 @@ object FileSystem { createLogDirPath() return logDirPath.resolve(getNewLogFileName("modules")).toFile() } -} + // Matches the manager's leading-integer parsing, including values such as "101.0". private fun leadingInt(value: String?): Int = value?.trim()?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0 +} diff --git a/native/src/core/native_api.cpp b/native/src/core/native_api.cpp index 14920e2e8..e82361041 100644 --- a/native/src/core/native_api.cpp +++ b/native/src/core/native_api.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -142,6 +143,14 @@ void RegisterNativeLib(const std::string &library_name) { } std::lock_guard lock(g_module_registry_mutex); + // The dlopen hook walks this list without stopping at the first match, so a name recorded twice + // means native_init runs twice for one library. Hot reload registers a module's names again for + // every new generation, which is exactly how that happens. + if (std::find(g_module_native_libs.begin(), g_module_native_libs.end(), library_name) != + g_module_native_libs.end()) { + LOGD("Native module library '{}' is already registered.", library_name.c_str()); + return; + } g_module_native_libs.push_back(library_name); LOGD("Native module library '{}' has been registered.", library_name.c_str()); } diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl index 8234b22db..4be5bc17c 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl @@ -5,8 +5,18 @@ parcelable PreLoadedApk { List moduleClassNames; List moduleLibraryNames; boolean legacy; + // module.prop 'targetApiVersion'. Carried into the process because two of API 102's rules are + // only enforceable there: a module targeting 102 must not be able to resolve the legacy API, + // and `legacy` is a boolean that cannot tell 101 from 102. 0 for a legacy module, which + // declares no target at all. + // + // minApiVersion is deliberately not here. The API puts that check on the module, through + // getApiVersion(), and the manager reads module.prop itself for what it displays - a copy in + // here would have no reader. int targetApiVersion; - int minApiVersion; + // module.prop 'autoHotReload'. Whether reinstalling the module app should offer a hot reload to + // the processes already running it, rather than leaving them on the old code until they + // restart. The module still has the last word, through onHotReloading. boolean autoHotReload; // module.prop 'exceptionMode', normalised by the daemon. false, the value an absent key // parses to, is PROTECTIVE - what ExceptionMode.DEFAULT is specified to fall back to. 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 35b0422f3..4c3e8595e 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,10 +74,10 @@ object VectorModuleManager { } } - // Register any native JNI entrypoints declared by the module - module.file.moduleLibraryNames.forEach { libraryName -> - NativeAPI.recordNativeEntrypoint(libraryName) - } + // Native entry points are recorded by buildGeneration, which has to do it before the entry + // classes run. Doing it again here would put every library name in the dlopen hook's list + // twice, and that list is walked without a break - a matching library would have its + // native_init called once per duplicate. Log.d(TAG, "Loaded module ${module.packageName} successfully.") return true From 94b457cd2d8aa6a1045cd4f12b917b673522ec93 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 14:29:28 +0200 Subject: [PATCH 05/13] Resolve the legacy-API prefixes through the obfuscation map API 102 forbids a module targeting 102 or higher from reaching de.robv.android.xposed, and the module class loader is the only place that can be enforced - it is the one chokepoint direct linkage and Class.forName both funnel through. But it is handed a name, and the name is not the one in the source. daemon/src/main/jni/obfuscation.cpp rewrites Lde/robv/android/xposed/ - in the framework dex and in every module dex - to a fresh random string on every boot. So on a build with dex obfuscation on, which is every release build, a module's type reference resolves to a name the literal test never matched, and the rule was simply not enforced there. The prefixes now come from the same map the rest of the framework reads, and cover AndroidAppHelper and the XResources family as well as the package: those are part of the same legacy surface and the same signature table, and guarding only the package left the legacy resource API reachable. --- native/src/jni/hook_bridge.cpp | 48 +++++++++++++++++++ .../impl/utils/VectorModuleClassLoader.kt | 29 +++++++++-- .../matrix/vector/nativebridge/HookBridge.kt | 9 ++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/native/src/jni/hook_bridge.cpp b/native/src/jni/hook_bridge.cpp index 0176ea43d..c6ad2745d 100644 --- a/native/src/jni/hook_bridge.cpp +++ b/native/src/jni/hook_bridge.cpp @@ -10,6 +10,7 @@ #include #include +#include "core/config_bridge.h" #include "jni/jni_bridge.h" #include "jni/jni_hooks.h" @@ -648,6 +649,52 @@ VECTOR_DEF_NATIVE_METHOD(jobjectArray, HookBridge, callbackSnapshot, jclass call return res; } +/** + * @brief The class name prefixes of the legacy Xposed API as this process will be asked for them. + * + * API 102 forbids a module that targets it from calling the legacy API, and the only place that can + * be enforced is the module class loader - which is handed a name. A literal "de.robv.android.xposed" + * is not that name: the daemon rewrites those prefixes in the framework dex and in every module dex + * when dex obfuscation is on, so the name a module asks for is a different random string on every + * boot. Resolving them through the same map the rest of the framework uses is what makes the guard + * hold in both configurations. + * + * The four entries are the whole legacy surface the obfuscation table covers: the package itself, + * AndroidAppHelper, and the XResources / XModuleResources family. Guarding only the package would + * leave the legacy resource API reachable. + */ +VECTOR_DEF_NATIVE_METHOD(jobjectArray, HookBridge, legacyApiPrefixes) { + // In the dotted form the obfuscation map is served in - the same form loadClass receives. + static constexpr const char *kLegacyKeys[] = { + "de.robv.android.xposed.", + "android.app.AndroidApp", + "android.content.res.XRes", + "android.content.res.XModule", + }; + + const auto count = static_cast(ArraySize(kLegacyKeys)); + auto string_class = env->FindClass("java/lang/String"); + if (!string_class) return nullptr; + auto result = env->NewObjectArray(count, string_class, nullptr); + env->DeleteLocalRef(string_class); + if (!result) return nullptr; + + auto *bridge = ConfigBridge::GetInstance(); + for (jsize i = 0; i < count; ++i) { + std::string name = kLegacyKeys[i]; + if (bridge) { + const auto &map = bridge->obfuscation_map(); + // Absent means the map never arrived; the unobfuscated name is then the right answer, + // because a build with no map is a build with no obfuscation. + if (auto it = map.find(name); it != map.end()) name = it->second; + } + auto value = env->NewStringUTF(name.c_str()); + env->SetObjectArrayElement(result, i, value); + env->DeleteLocalRef(value); + } + return result; +} + /** * @brief Reports whether the pages spanning [addr, addr + len) are mapped. * @@ -785,6 +832,7 @@ static JNINativeMethod gMethods[] = { "Executable;)[[Ljava/lang/Object;"), VECTOR_NATIVE_METHOD(HookBridge, findStaticInitializer, "(Ljava/lang/Class;[JJ)Ljava/lang/reflect/Executable;"), + VECTOR_NATIVE_METHOD(HookBridge, legacyApiPrefixes, "()[Ljava/lang/String;"), }; /** 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 c6012e045..be64c7e4a 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 @@ -17,6 +17,7 @@ import java.util.Enumeration import java.util.jar.JarFile import java.util.stream.Collectors import java.util.zip.ZipEntry +import org.matrix.vector.nativebridge.HookBridge /** * Custom ClassLoader for module execution. Utilizes in-memory DEX loading to prevent the need to @@ -64,9 +65,9 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { 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)) { + // resolve those names here is what actually enforces it - reflective lookups against this + // loader included, since Class.forName and loadClass both funnel through this override. + if (blockLegacyApi && LEGACY_API_PREFIXES.any { name.startsWith(it) }) { throw ClassNotFoundException( "$name is unavailable to modules targeting Xposed API 102 or higher" ) @@ -145,7 +146,27 @@ 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." + + /** + * What the legacy API is called *here*, which is not what it is called in source: the + * daemon rewrites `de.robv.android.xposed`, `AndroidAppHelper` and the `XResources` family + * in the framework dex and in every module dex when dex obfuscation is on, so the names a + * module asks this loader for are a different random string on every boot. Matching the + * literal package would leave the 102 rule unenforced on exactly the builds that have + * obfuscation turned on. + */ + private val LEGACY_API_PREFIXES: Array by lazy { + runCatching { HookBridge.legacyApiPrefixes() } + .onFailure { Log.w(TAG, "Cannot resolve the legacy API prefixes", it) } + .getOrElse { + arrayOf( + "de.robv.android.xposed.", + "android.app.AndroidApp", + "android.content.res.XRes", + "android.content.res.XModule", + ) + } + } private val SYSTEM_NATIVE_LIBRARY_DIRS = splitPaths(System.getProperty("java.library.path")) private fun splitPaths(searchPath: String?): List { diff --git a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt index 1d2b8bd13..1bcbdc1a4 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/nativebridge/HookBridge.kt @@ -115,4 +115,13 @@ object HookBridge { artMethodSize: Long, ): Executable? + /** + * The class name prefixes of the legacy `de.robv` API as this process will actually be asked + * for them, which is not the same as what they are called in source: dex obfuscation rewrites + * them, in the framework and in every module, to a different random string on every boot. + * + * A module targeting API 102 is not allowed to reach any of them, and the module class loader + * is the only place that can be enforced. + */ + @JvmStatic external fun legacyApiPrefixes(): Array } From abb73a585e4c471f7585394a40bbf17905d575f1 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 14:30:15 +0200 Subject: [PATCH 06/13] Answer a hot reload out of band, with a timeout the daemon owns The reload transaction ran the old code's onHotReloading and the new code's onHotReloaded synchronously, so a daemon thread was pinned for as long as the module cared to take - and binder has no timeout of its own. A module that never returned left its target in RELOADING for the life of the process, and every later request answered IN_PROGRESS forever. Only process death recovered it. So the request becomes oneway, the outcome comes back through IHotReloadOutcomeCallback, and the daemon waits on it for thirty seconds. The process side hops off the incoming binder thread too, since holding one of the app's own binder threads for the length of a module's onHotReloading is the same mistake one process along. PROCESS_DIED now comes from the heartbeat registry rather than from the exception type. DeadObjectException does not mean the process died: a frozen but perfectly alive target fails a transaction exactly the same way, and reporting that as 'died' is a claim the module app has no way to check. The registry knows, because it is driven by a DeathRecipient. A target that is still frozen after the thaw attempt is reported at once rather than after the timeout, and with a message saying so - the timeout would have been indistinguishable from a module that hung. --- .../vector/daemon/ipc/ApplicationService.kt | 11 +++ .../matrix/vector/daemon/ipc/ModuleService.kt | 85 ++++++++++++++----- .../service/IHotReloadOutcomeCallback.aidl | 14 +++ .../lspd/service/IHotReloadTarget.aidl | 15 ++-- .../vector/impl/core/VectorHotReloadTarget.kt | 34 +++++++- 5 files changed, 132 insertions(+), 27 deletions(-) create mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadOutcomeCallback.aidl 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 c32fc5d6d..9e83aab98 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 @@ -151,6 +151,17 @@ object ApplicationService : ILSPApplicationService.Stub() { fun getHotReloadTarget(targetId: Long, modulePackageName: String): HotReloadTarget? = hotReloadTargets[targetId]?.takeIf { it.modulePackageName == modulePackageName } + /** + * Whether the process behind [target] is still the registered one. + * + * The heartbeat's DeathRecipient is what actually knows a process died. The exception a + * transaction throws does not: a frozen but perfectly alive target fails a transaction the same + * way a dead one does, and reporting that as PROCESS_DIED would be a lie the module app has no + * way to check. + */ + fun isProcessRegistered(target: HotReloadTarget): Boolean = + processes.containsKey(ProcessKey(target.uid, target.pid)) + // Reloads are serialized per target, so check and transition must be one atomic step. fun beginHotReload(target: HotReloadTarget): Boolean { while (true) { 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 ebfa77555..7a687f1ad 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt @@ -4,7 +4,6 @@ import android.content.AttributionSource import android.os.Binder import android.os.Build import android.os.Bundle -import android.os.DeadObjectException import android.os.ParcelFileDescriptor import android.os.RemoteException import android.util.Log @@ -16,8 +15,12 @@ import java.io.Serializable import java.util.Collections import java.util.WeakHashMap import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.lsposed.lspd.models.HotReloadOutcome import org.lsposed.lspd.models.Module +import org.lsposed.lspd.service.IHotReloadOutcomeCallback import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem @@ -38,6 +41,11 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { private val hotReloadExecutor = Executors.newCachedThreadPool { r -> Thread(r, "vector-hot-reload") } + // How long a target gets to answer. Generous, because the whole point is that the callee runs + // module code - but finite, because binder is not, and a target left in RELOADING answers every + // later request with IN_PROGRESS for as long as the process lives. + private const val RELOAD_TIMEOUT_SECONDS = 30L + private val uidSet = ConcurrentHashMap.newKeySet() private val serviceMap = Collections.synchronizedMap(WeakHashMap()) @@ -241,6 +249,8 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { var message: String? = "Hot reload did not run" var refreeze: (() -> Unit)? = null var loadedVersion: Long? = null + val answered = CountDownLatch(1) + var outcome: HotReloadOutcome? = null try { val binder = ApplicationService.getHotReloadBinder(target) @@ -249,11 +259,6 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { message = "Process ${target.processName} has no hot reload entry point" return } - if (!binder.asBinder().isBinderAlive) { - status = IXposedService.HOT_RELOAD_PROCESS_DIED - message = "Process ${target.processName} is gone" - return - } val newModule = ConfigCache.state.modules[loadedModule.packageName] if (newModule == null) { status = IXposedService.HOT_RELOAD_UNSUPPORTED @@ -261,36 +266,76 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { return } - // A cached target is usually frozen, and a transaction to a frozen process never reaches the - // module. Thawing first is what keeps that case from being reported as a refusal. + // A cached target is usually frozen, and a transaction to a frozen process is not delivered. + // Thawing first is what keeps that case from being reported as a refusal. A device with no + // app freezer at all - anything before the cgroup v2 freezer - is the ordinary path, not a + // failure, so a null here only means "nothing to do". refreeze = ProcessFreezer.thaw(target.uid, target.pid) - if (refreeze == null && ProcessFreezer.isFrozen(target.uid, target.pid)) { + if (ProcessFreezer.isFrozen(target.uid, target.pid)) { + // Say so now rather than spending the timeout on a transaction that will not be delivered. + // Not a refusal either: the message is what tells the two apart. status = IXposedService.HOT_RELOAD_FAILED - message = "Target process is frozen and could not be thawed" + message = "Process ${target.processName} is frozen and could not be thawed" + return + } + + val callbackStub = + object : IHotReloadOutcomeCallback.Stub() { + override fun onHotReloadOutcome(result: HotReloadOutcome?) { + outcome = result + answered.countDown() + } + } + binder.hotReload(loadedModule.packageName, data, newModule, callbackStub) + + // Bounded, because the callee runs arbitrary module code and binder has no timeout of its + // own: without this a module that never returns from onHotReloading would leave the target + // RELOADING for the life of the process, and every later request would answer IN_PROGRESS. + if (!answered.await(RELOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + status = + if (ApplicationService.isProcessRegistered(target)) IXposedService.HOT_RELOAD_FAILED + else IXposedService.HOT_RELOAD_PROCESS_DIED + message = + if (status == IXposedService.HOT_RELOAD_PROCESS_DIED) { + "Process ${target.processName} died during hot reload" + } else { + "Process ${target.processName} did not answer within ${RELOAD_TIMEOUT_SECONDS}s" + } return } - val outcome = binder.hotReload(loadedModule.packageName, data, newModule) - status = outcome.status + val answer = + outcome + ?: run { + status = IXposedService.HOT_RELOAD_FAILED + message = "Process ${target.processName} answered with nothing" + return + } + + status = answer.status // Whether the generation was swapped is not the same question as whether the reload // succeeded: onHotReloaded runs after the swap is committed, so a throw from it leaves the // process on the new code and still reports FAILED. Recording the version the target is // actually running is what keeps getRunningTargets() honest about it. - if (outcome.generationChanged) loadedVersion = newModule.versionCode + if (answer.generationChanged) loadedVersion = newModule.versionCode // A null message is reserved for a refusal, so anything else gets one supplied. message = - outcome.message - ?: if (status == IXposedService.HOT_RELOAD_FAILED && !outcome.refused) { + answer.message + ?: if (status == IXposedService.HOT_RELOAD_FAILED && !answer.refused) { "Hot reload failed without a diagnostic message" } else { null } - } catch (e: DeadObjectException) { - status = IXposedService.HOT_RELOAD_PROCESS_DIED - message = "Process ${target.processName} died during hot reload" } catch (t: Throwable) { - status = IXposedService.HOT_RELOAD_FAILED - message = "${t.javaClass.name}: ${t.message ?: "no message"}" + // Deliberately not keyed on DeadObjectException: a frozen-but-alive target answers a + // transaction with exactly that, so the exception type says nothing about whether the process + // is gone. The heartbeat registry does - it is driven by a DeathRecipient. + val gone = !ApplicationService.isProcessRegistered(target) + status = + if (gone) IXposedService.HOT_RELOAD_PROCESS_DIED else IXposedService.HOT_RELOAD_FAILED + message = + if (gone) "Process ${target.processName} died during hot reload" + else "${t.javaClass.name}: ${t.message ?: "no message"}" Log.e(TAG, "Hot reload of ${loadedModule.packageName} failed", t) } finally { refreeze?.invoke() diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadOutcomeCallback.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadOutcomeCallback.aidl new file mode 100644 index 000000000..7d288b345 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadOutcomeCallback.aidl @@ -0,0 +1,14 @@ +package org.lsposed.lspd.service; + +import org.lsposed.lspd.models.HotReloadOutcome; + +/** + * How an injected process answers a hot reload request. + * + * Separate from the request so the request itself can be oneway: the work behind it runs arbitrary + * module code - onHotReloading is allowed to take as long as it likes - and nothing in the daemon + * should be holding a thread, or a target's RELOADING state, for the duration. + */ +interface IHotReloadOutcomeCallback { + oneway void onHotReloadOutcome(in HotReloadOutcome outcome) = 1; +} 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 index 35d34e767..5fba325b3 100644 --- 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 @@ -1,7 +1,7 @@ package org.lsposed.lspd.service; -import org.lsposed.lspd.models.HotReloadOutcome; import org.lsposed.lspd.models.Module; +import org.lsposed.lspd.service.IHotReloadOutcomeCallback; /** * Daemon-to-process entry point for hot reloading a module generation in place. @@ -11,10 +11,15 @@ import org.lsposed.lspd.models.Module; */ interface IHotReloadTarget { /** - * Replaces the loaded generation of modulePackageName with newModule. + * Replaces the loaded generation of modulePackageName with newModule, and answers through + * callback. * - *

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.

+ *

oneway, and answered out of band, because the work runs the old code's onHotReloading and + * the new code's onHotReloaded - arbitrary module code with no bound on how long it takes. A + * synchronous form would pin a daemon thread for that whole time and, worse, leave the target + * stuck in RELOADING for good if the module never returned, since binder itself has no + * timeout. The daemon supplies one instead.

*/ - HotReloadOutcome hotReload(String modulePackageName, in Bundle extras, in Module newModule) = 1; + oneway void hotReload(String modulePackageName, in Bundle extras, in Module newModule, + IHotReloadOutcomeCallback callback) = 1; } 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 index 567b16d36..54d56a2d4 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt @@ -1,16 +1,46 @@ package org.matrix.vector.impl.core +import android.os.Binder import android.os.Bundle -import org.lsposed.lspd.models.HotReloadOutcome +import android.os.Process +import java.util.concurrent.Executors import org.lsposed.lspd.models.Module +import org.lsposed.lspd.service.IHotReloadOutcomeCallback import org.lsposed.lspd.service.IHotReloadTarget +import org.lsposed.lspd.util.Utils.Log + +private const val TAG = "VectorHotReloadTarget" /** Registered once while the framework bootstraps, before any module is loaded. */ object VectorHotReloadTarget : IHotReloadTarget.Stub() { + /** + * One thread, so reloads in this process are serialised even across modules, and so the + * incoming oneway transaction returns at once. Running the cycle on the binder thread that + * delivered it would hold one of this app's binder threads for as long as the module's + * onHotReloading cares to take. + */ + private val worker = Executors.newSingleThreadExecutor { Thread(it, "vector-hot-reload") } + override fun hotReload( modulePackageName: String?, extras: Bundle?, newModule: Module?, - ): HotReloadOutcome = VectorModuleManager.hotReload(modulePackageName, extras, newModule) + callback: IHotReloadOutcomeCallback?, + ) { + // The daemon is the only caller this binder was ever handed to, but it runs as the system + // uid rather than as root, and this object lives in an app process - so the check is worth + // stating rather than assuming. Nothing else may drive a module's lifecycle. + val caller = Binder.getCallingUid() + if (caller != Process.SYSTEM_UID && caller != 0) { + Log.w(TAG, "Refusing a hot reload request from uid $caller") + return + } + + worker.execute { + val outcome = VectorModuleManager.hotReload(modulePackageName, extras, newModule) + runCatching { callback?.onHotReloadOutcome(outcome) } + .onFailure { Log.w(TAG, "Cannot report the hot reload outcome", it) } + } + } } From 3e5773c70c937913cffc9bc1616457631a4d8162 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 14:31:07 +0200 Subject: [PATCH 07/13] Scope hot reload targets to the caller's user ensureModule() proves only that the caller shares the module's app id. The same module installed for two users is two module apps with two sets of preferences, so without a user check the copy in user 10 could enumerate and reload user 0's processes - which the AIDL reserves SecurityException for. System uids stay addressable from every user rather than being scoped to user 0. system_server runs once for the whole device and carries a module enabled in any user, so the strict reading would make it unreachable from every secondary user's copy of the module. --- .../vector/daemon/ipc/ApplicationService.kt | 22 +++++++++++++++---- .../matrix/vector/daemon/ipc/ModuleService.kt | 11 ++++++---- 2 files changed, 25 insertions(+), 8 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 9e83aab98..acb2050a5 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 @@ -15,6 +15,7 @@ import org.lsposed.lspd.service.IHotReloadTarget import org.lsposed.lspd.service.ILSPApplicationService import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem +import org.matrix.vector.daemon.system.PER_USER_RANGE import org.matrix.vector.daemon.utils.InstallerVerifier import org.matrix.vector.daemon.utils.ObfuscationManager @@ -90,12 +91,23 @@ object ApplicationService : ILSPApplicationService.Stub() { } } + /** + * Whether [userId]'s copy of the module may address [target]. + * + * The same module installed for two users is two module apps with two sets of preferences, and + * neither has any business reloading the other's processes. System uids are the exception rather + * than a hole: system_server runs once for the whole device and carries a module enabled in any + * user, so scoping it to user 0 would make it unreachable from every other user. + */ + private fun addressableBy(target: HotReloadTarget, userId: Int): Boolean = + target.uid < PER_USER_RANGE || target.uid / PER_USER_RANGE == userId + // Not filtered to hot-reloadable targets: the AIDL documents this as hooked processes, and one // that cannot be reloaded answers UNSUPPORTED rather than disappearing. - fun getHotReloadTargets(modulePackageName: String): List { + fun getHotReloadTargets(modulePackageName: String, userId: Int): List { val installedVersion = ConfigCache.state.modules[modulePackageName]?.versionCode return hotReloadTargets.values - .filter { it.modulePackageName == modulePackageName } + .filter { it.modulePackageName == modulePackageName && addressableBy(it, userId) } .map { target -> HookedProcess().apply { targetId = target.id @@ -148,8 +160,10 @@ object ApplicationService : ILSPApplicationService.Stub() { } } - fun getHotReloadTarget(targetId: Long, modulePackageName: String): HotReloadTarget? = - hotReloadTargets[targetId]?.takeIf { it.modulePackageName == modulePackageName } + fun getHotReloadTarget(targetId: Long, modulePackageName: String, userId: Int): HotReloadTarget? = + hotReloadTargets[targetId]?.takeIf { + it.modulePackageName == modulePackageName && addressableBy(it, userId) + } /** * Whether the process behind [target] is still the registered one. 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 7a687f1ad..a0ee49e28 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 @@ -210,17 +210,20 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { } override fun getRunningTargets(): List { - ensureModule() - return ApplicationService.getHotReloadTargets(loadedModule.packageName) + val userId = ensureModule() + return ApplicationService.getHotReloadTargets(loadedModule.packageName, userId) } override fun hotReloadModule(targetId: Long, data: Bundle?, callback: IHotReloadCallback?) { - ensureModule() + // The user id matters as much as the app id here: ensureModule only proves the caller shares + // the module's appId, and the same module installed for two users is two separate module apps. + // Without this, the copy in user 10 could reload user 0's processes. + val userId = ensureModule() // SecurityException is reserved by the AIDL for exactly these two conditions, so it must not be // raised for anything else on this path - a module-thrown SecurityException in particular has // to reach the caller as a FAILED result, not as "invalid target id". val target = - ApplicationService.getHotReloadTarget(targetId, loadedModule.packageName) + ApplicationService.getHotReloadTarget(targetId, loadedModule.packageName, userId) ?: throw SecurityException("Target $targetId is not a target of ${loadedModule.packageName}") if (!target.hotReloadable) { From cda6720a010fca6f672e2432d75509aabd2c6caa Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 14:31:22 +0200 Subject: [PATCH 08/13] Ask the kernel where the app freezer is, instead of guessing The four paths this probed - under /sys/fs/cgroup/apps/ and /sys/fs/cgroup/system/ - do not exist on SM-A145R running Android 15, which uses /uid_/pid_ with nothing above it. freezeFile() returned null there, so thaw() did nothing, isFrozen() was always false, and a cached target could not be hot reloaded at all. Nothing said so: the reload just failed later, for a different-looking reason. The 0:: line of /proc//cgroup is the process's own cgroup v2 path relative to the mount point, which is the kernel's own answer and holds whatever the layout. Only the process's own group is used; the uid-level group holds every process of the app and thawing there moves processes this has no business touching. The restore re-reads before writing. The framework's app compaction owns this state too, and if it has thawed the process meanwhile - because the user brought the app to the foreground - freezing it again from here would stop a process the system believes is running. A device with no cgroup v2 freezer at all is an ordinary answer rather than a failure; minSdk is 27 and it does not exist across that range. --- .../matrix/vector/daemon/ipc/ModuleService.kt | 4 +- .../vector/daemon/system/ProcessFreezer.kt | 80 +++++++++++++------ 2 files changed, 56 insertions(+), 28 deletions(-) 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 a0ee49e28..1a6dc4dea 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 @@ -273,8 +273,8 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { // Thawing first is what keeps that case from being reported as a refusal. A device with no // app freezer at all - anything before the cgroup v2 freezer - is the ordinary path, not a // failure, so a null here only means "nothing to do". - refreeze = ProcessFreezer.thaw(target.uid, target.pid) - if (ProcessFreezer.isFrozen(target.uid, target.pid)) { + refreeze = ProcessFreezer.thaw(target.pid) + if (ProcessFreezer.isFrozen(target.pid)) { // Say so now rather than spending the timeout on a transaction that will not be delivered. // Not a refusal either: the message is what tells the two apart. status = IXposedService.HOT_RELOAD_FAILED diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt index 8a08c6b6e..09770e613 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt @@ -9,49 +9,77 @@ private const val TAG = "VectorFreezer" * Thaws a frozen process for the duration of a daemon-initiated transaction. * * Android freezes cached processes, and a module's hooked targets are usually cached ones. A binder - * transaction does not reach a frozen process, so without this a hot reload of a backgrounded target - * fails without ever running module code - indistinguishable from the module returning false from - * onHotReloading, which is the one case the API reserves a null message for. + * transaction is not delivered to a frozen process, so without this a hot reload of a backgrounded + * target fails without ever running module code - indistinguishable from the module returning false + * from onHotReloading, which is the one case the API reserves a null message for. */ object ProcessFreezer { /** - * The freezer is a cgroup v2 file. Newer releases give each process its own group; older ones and - * some vendor trees freeze at the uid level, so both layouts are probed. + * The freezer file for one process, or null when this device has none for it. + * + * Where it lives is the kernel's answer rather than ours: the `0::` line of `/proc//cgroup` + * is that process's own cgroup v2 path relative to the mount point, so reading it is the one form + * that holds whatever the layout is. A guessed list does not - SM-A145R on Android 15 uses + * `/uid_/pid_`, with no `apps/` or `system/` above it, and the paths this was first + * written with matched nothing at all there. + * + * Only the process's own group is ever returned. The uid-level group holds every process of the + * app, and thawing there would move processes this reload has no business touching. minSdk is 27, + * and the cgroup v2 freezer does not exist across that whole range, so null is an ordinary answer + * rather than a failure. */ - private fun freezeFile(uid: Int, pid: Int): File? = - sequenceOf( - "/sys/fs/cgroup/apps/uid_$uid/pid_$pid/cgroup.freeze", - "/sys/fs/cgroup/system/uid_$uid/pid_$pid/cgroup.freeze", - "/sys/fs/cgroup/apps/uid_$uid/cgroup.freeze", - "/sys/fs/cgroup/system/uid_$uid/cgroup.freeze", - ) - .map(::File) - .firstOrNull { it.exists() } - - fun isFrozen(uid: Int, pid: Int): Boolean = - runCatching { freezeFile(uid, pid)?.readText()?.trim() == "1" }.getOrDefault(false) + private fun freezeFile(pid: Int): File? { + val path = + runCatching { + File("/proc/$pid/cgroup") + .readLines() + .firstOrNull { it.startsWith("0::") } + ?.removePrefix("0::") + ?.trim() + ?.takeIf { it.isNotEmpty() && it != "/" } + } + .getOrNull() ?: return null + + // A group shared with the whole uid is not ours to thaw. + if (!path.contains("/pid_")) return null + + return File("/sys/fs/cgroup$path/cgroup.freeze").takeIf { it.exists() } + } + + fun isFrozen(pid: Int): Boolean = + runCatching { freezeFile(pid)?.readText()?.trim() == "1" }.getOrDefault(false) /** - * Thaws the process if it is frozen and returns an action that restores the previous state, or - * null if nothing was changed. The caller must run the returned action once the transaction is - * done, otherwise the process is left permanently runnable. + * Thaws the process if it is frozen, and returns the action that puts it back. Null when nothing + * was changed - either there is no freezer here or the process was already running. + * + * The restore re-reads the file rather than writing "1" blindly: the framework's own app + * compaction owns this state too, and if it has thawed the process meanwhile - because the user + * brought the app to the foreground - freezing it again from here would stop a process the system + * believes is running. */ - fun thaw(uid: Int, pid: Int): (() -> Unit)? { - val file = freezeFile(uid, pid) ?: return null + fun thaw(pid: Int): (() -> Unit)? { + val file = freezeFile(pid) ?: return null val wasFrozen = runCatching { file.readText().trim() == "1" }.getOrDefault(false) if (!wasFrozen) return null val thawed = runCatching { file.writeText("0") }.isSuccess if (!thawed) { - Log.w(TAG, "Cannot thaw uid=$uid pid=$pid through ${file.path}") + Log.w(TAG, "Cannot thaw pid=$pid through ${file.path}") return null } - Log.d(TAG, "Thawed uid=$uid pid=$pid for a daemon transaction") + Log.d(TAG, "Thawed pid=$pid for a daemon transaction") return { - runCatching { file.writeText("1") } - .onFailure { Log.w(TAG, "Cannot re-freeze uid=$uid pid=$pid", it) } + runCatching { + if (file.readText().trim() == "0") { + file.writeText("1") + } else { + Log.d(TAG, "Left pid=$pid alone: something else changed its freezer state") + } + } + .onFailure { Log.w(TAG, "Cannot re-freeze pid=$pid", it) } } } } From 34144a8b50332478ecec08f9acda4719ab6df4a5 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 14:32:58 +0200 Subject: [PATCH 09/13] Move the API 102 IPC surface out of org.lsposed.lspd and say what it does The AIDL is the framework's real API between three security domains, and it was the least documented thing in the tree: no comments, LSPosed-inherited names that mean nothing here, and at least one name that was actively wrong. That last one is not a style complaint. registerHotReloadTarget reads as 'register a target', so the first attempt at hot reload had the injected process pass a module name, which made the daemon look the module up - and system_server loads its modules before that cache exists, so system_server could never become a target. The method hands over a channel; it now says so. ILSPApplicationService -> IFrameworkService IDaemonService -> IVectorDaemon ILSPSystemServerService -> ISystemServerBootstrap IHotReloadTarget -> IProcessChannel (a channel, not a target) IHotReloadOutcomeCallback -> IHotReloadResultReceiver ILSPInjectedModuleService -> IModuleService Module -> LoadedModule PreLoadedApk -> ModuleCode (dexes, entries and policy; not 'an apk' in any sense) registerHotReloadTarget() -> attachProcessChannel() requestApplicationService() -> attachProcess() heartBeat -> processLifeToken Every file now carries prose on what each method is for, who may call it, which process it runs in, and the constraints that were otherwise oral tradition: that IFrameworkService numbers its transactions implicitly so methods may only be appended; that a null message on a hot reload outcome is reserved for a module refusal and nothing else may claim it; that the life token exists only for linkToDeath and that letting it be collected looks exactly like dying; and that attachProcessChannel must not be made oneway, because the binder driver records the sending thread only for synchronous transactions and an async call therefore arrives with getCallingPid() == 0, which fails the (uid, pid) authentication with no symptom beyond every later reload answering UNSUPPORTED. IRemotePreferenceCallback and IModuleService came along even though remote preferences are not part of 102, because LoadedModule carries the latter and leaving them behind would have left the new package importing back into the old one for no reason. Deliberately not org.matrix.vector.service: that package is already the zygisk bridge's, and it is one of the three prefixes daemon/src/main/jni/obfuscation.cpp rewrites. Landing the AIDL there would have started obfuscating it - harmless for the daemon-to-process interfaces, fatal for anything the manager APK also compiles, since the manager is a separate APK the daemon's obfuscator never sees and the binder descriptors would stop matching. org.matrix.vector.ipc is in no prefix, so what is obfuscated does not change. manager-service keeps org.lsposed.lspd for now, as does the Utils logger: neither has anything to do with API 102. --- .../org/matrix/vector/daemon/VectorService.kt | 12 +-- .../matrix/vector/daemon/data/ConfigCache.kt | 22 +++--- .../matrix/vector/daemon/data/DaemonState.kt | 6 +- .../matrix/vector/daemon/data/FileSystem.kt | 8 +- .../vector/daemon/ipc/ApplicationService.kt | 24 +++--- .../daemon/ipc/InjectedModuleService.kt | 6 +- .../matrix/vector/daemon/ipc/ModuleService.kt | 18 ++--- .../vector/daemon/ipc/SystemServerService.kt | 12 +-- .../de/robv/android/xposed/XposedInit.java | 4 +- .../main/java/org/matrix/vector/Startup.java | 4 +- services/daemon-service/build.gradle.kts | 2 +- .../lsposed/lspd/models/HotReloadOutcome.aidl | 31 -------- .../aidl/org/lsposed/lspd/models/Module.aidl | 13 ---- .../org/lsposed/lspd/models/PreLoadedApk.aidl | 28 ------- .../lsposed/lspd/service/IDaemonService.aidl | 11 --- .../service/IHotReloadOutcomeCallback.aidl | 14 ---- .../lspd/service/IHotReloadTarget.aidl | 25 ------- .../lspd/service/ILSPApplicationService.aidl | 26 ------- .../service/ILSPInjectedModuleService.aidl | 13 ---- .../lspd/service/ILSPSystemServerService.aidl | 7 -- .../service/IRemotePreferenceCallback.aidl | 5 -- .../matrix/vector/ipc/HotReloadOutcome.aidl | 42 +++++++++++ .../matrix/vector/ipc/IFrameworkService.aidl | 64 ++++++++++++++++ .../vector/ipc/IHotReloadResultReceiver.aidl | 14 ++++ .../org/matrix/vector/ipc/IModuleService.aidl | 33 +++++++++ .../matrix/vector/ipc/IProcessChannel.aidl | 37 ++++++++++ .../vector/ipc/IRemotePreferenceCallback.aidl | 13 ++++ .../vector/ipc/ISystemServerBootstrap.aidl | 18 +++++ .../org/matrix/vector/ipc/IVectorDaemon.aidl | 35 +++++++++ .../org/matrix/vector/ipc/LoadedModule.aidl | 41 +++++++++++ .../org/matrix/vector/ipc/ModuleCode.aidl | 73 +++++++++++++++++++ .../org/matrix/vector/impl/VectorContext.kt | 4 +- .../vector/impl/VectorRemotePreferences.kt | 6 +- .../vector/impl/core/VectorModuleManager.kt | 12 +-- ...eloadTarget.kt => VectorProcessChannel.kt} | 26 ++++--- .../vector/impl/core/VectorServiceClient.kt | 31 ++++---- .../matrix/vector/impl/core/VectorStartup.kt | 4 +- .../kotlin/org/matrix/vector/core/Main.kt | 4 +- .../matrix/vector/service/BridgeService.kt | 12 +-- 39 files changed, 483 insertions(+), 277 deletions(-) delete mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl delete mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/models/Module.aidl delete mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl delete mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IDaemonService.aidl delete mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadOutcomeCallback.aidl delete mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl delete mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl delete mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPInjectedModuleService.aidl delete mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPSystemServerService.aidl delete mode 100644 services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IRemotePreferenceCallback.aidl create mode 100644 services/daemon-service/src/main/aidl/org/matrix/vector/ipc/HotReloadOutcome.aidl create mode 100644 services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl create mode 100644 services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadResultReceiver.aidl create mode 100644 services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IModuleService.aidl create mode 100644 services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl create mode 100644 services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IRemotePreferenceCallback.aidl create mode 100644 services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ISystemServerBootstrap.aidl create mode 100644 services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IVectorDaemon.aidl create mode 100644 services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl create mode 100644 services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ModuleCode.aidl rename xposed/src/main/kotlin/org/matrix/vector/impl/core/{VectorHotReloadTarget.kt => VectorProcessChannel.kt} (65%) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt index 86e4ee744..162402646 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt @@ -16,8 +16,8 @@ import hidden.HiddenApiBridge import io.github.libxposed.service.IXposedScopeCallback import kotlinx.coroutines.launch import org.lsposed.lspd.models.Application -import org.lsposed.lspd.service.IDaemonService -import org.lsposed.lspd.service.ILSPApplicationService +import org.matrix.vector.ipc.IVectorDaemon +import org.matrix.vector.ipc.IFrameworkService import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.ModuleDatabase import org.matrix.vector.daemon.data.PreferenceStore @@ -29,7 +29,7 @@ import org.matrix.vector.daemon.system.* private const val TAG = "VectorService" -object VectorService : IDaemonService.Stub() { +object VectorService : IVectorDaemon.Stub() { private var bootCompleted = false @@ -65,14 +65,14 @@ object VectorService : IDaemonService.Stub() { } } - override fun requestApplicationService( + override fun attachProcess( uid: Int, pid: Int, processName: String, heartBeat: IBinder - ): ILSPApplicationService? { + ): IFrameworkService? { if (Binder.getCallingUid() != 1000) { - Log.w(TAG, "Unauthorized requestApplicationService call") + Log.w(TAG, "Unauthorized attachProcess call") return null } if (ApplicationService.hasRegister(uid, pid)) return null 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 cbcfaf114..3ff22268e 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 @@ -14,7 +14,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import org.lsposed.lspd.ILSPManagerService import org.lsposed.lspd.models.Application -import org.lsposed.lspd.models.Module +import org.matrix.vector.ipc.LoadedModule import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.VectorDaemon import org.matrix.vector.daemon.ipc.ApplicationService @@ -151,7 +151,7 @@ object ConfigCache { Log.d(TAG, "Executing Cache Update...") val oldState = state - val newModules = mutableMapOf() + val newModules = mutableMapOf() val newStaticScopes = mutableMapOf>() // Deleted from the configuration: the package is not installed for any user, so what it was // configured to do cannot mean anything. @@ -229,7 +229,7 @@ object ConfigCache { when (val loaded = FileSystem.loadModule(apkPath, state.isDexObfuscateEnabled)) { is ModuleLoad.Loaded -> { val module = - Module().apply { + LoadedModule().apply { packageName = pkgName this.apkPath = apkPath appId = appInfo.uid @@ -272,12 +272,12 @@ object ConfigCache { } } - val newScopes = mutableMapOf>() + val newScopes = mutableMapOf>() // A module can reach the same process by more than one route: self rows in two users each // propagate into the other's, and the scope derived below can name a process a row named as // well. Twice in the list is twice loaded, so every insertion goes through here. - fun addToScope(processName: String, uid: Int, module: Module) { + fun addToScope(processName: String, uid: Int, module: LoadedModule) { val modules = newScopes.getOrPut(ProcessScope(processName, uid)) { mutableListOf() } if (modules.none { it === module }) modules.add(module) } @@ -377,7 +377,7 @@ object ConfigCache { } } - fun getModulesForProcess(processName: String, uid: Int): List { + fun getModulesForProcess(processName: String, uid: Int): List { ensureCacheReady() if (processName == "system_server") { Log.w(TAG, "Skip unexpected module queries for $processName") @@ -386,11 +386,11 @@ object ConfigCache { return state.scopes[ProcessScope(processName, uid)] ?: emptyList() } - fun getModuleByUid(uid: Int): Module? = + fun getModuleByUid(uid: Int): LoadedModule? = state.modules.values.firstOrNull { it.appId == uid % PER_USER_RANGE } - fun getModulesForSystemServer(): List { - val modules = mutableListOf() + fun getModulesForSystemServer(): List { + val modules = mutableListOf() if (!android.os.SELinux.checkSELinuxAccess( "u:r:system_server:s0", "u:r:system_server:s0", "process", "execmem")) { Log.e(TAG, "Skipping system_server injection: sepolicy execmem denied") @@ -415,7 +415,7 @@ object ConfigCache { val statPath = FileSystem.toGlobalNamespace("/data/user_de/0/$pkgName").absolutePath val module = - Module().apply { + LoadedModule().apply { packageName = pkgName this.apkPath = apkPath appId = runCatching { Os.stat(statPath).st_uid }.getOrDefault(-1) @@ -464,7 +464,7 @@ object ConfigCache { * disk. A module that ships no library, or whose staging failed, keeps a null here and loads * exactly as it did before. */ - private fun stageNativeLibrariesFor(module: Module) { + private fun stageNativeLibrariesFor(module: LoadedModule) { val file = module.file ?: return // system_server asks for its modules early enough that the cache may not have been built yet, // and this is the same reason getPrefsPath does not trust the field either. diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt index 68107dbe0..fe4a4e5e5 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt @@ -1,7 +1,7 @@ package org.matrix.vector.daemon.data import java.nio.file.Path -import org.lsposed.lspd.models.Module +import org.matrix.vector.ipc.LoadedModule import org.matrix.vector.daemon.BuildConfig data class ProcessScope(val processName: String, val uid: Int) @@ -17,8 +17,8 @@ data class DaemonState( val isCacheReady: Boolean = false, val managerUid: Int = -1, val miscPath: Path? = null, - val modules: Map = emptyMap(), - val scopes: Map> = emptyMap(), + val modules: Map = emptyMap(), + val scopes: Map> = emptyMap(), /** * Modules the user enabled that the framework could not load, and why. * diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt index 5d4c90441..6fa533a2f 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -34,7 +34,7 @@ import java.util.zip.ZipFile import java.util.zip.ZipOutputStream import kotlin.io.path.exists import kotlin.io.path.isDirectory -import org.lsposed.lspd.models.PreLoadedApk +import org.matrix.vector.ipc.ModuleCode import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.utils.ObfuscationManager @@ -51,7 +51,7 @@ private const val TAG = "VectorFileSystem" */ sealed interface ModuleLoad { /** Parsed, and ready to hand to a forking process. */ - data class Loaded(val apk: PreLoadedApk) : ModuleLoad + data class Loaded(val apk: ModuleCode) : ModuleLoad /** Declares libxposed API 100, and carries nothing else this framework can load. */ data object UnsupportedApi : ModuleLoad @@ -61,7 +61,7 @@ sealed interface ModuleLoad { } /** The APK when it loaded and null when it did not, for callers with nothing to say about why. */ -val ModuleLoad.apkOrNull: PreLoadedApk? +val ModuleLoad.apkOrNull: ModuleCode? get() = (this as? ModuleLoad.Loaded)?.apk object FileSystem { @@ -234,7 +234,7 @@ object FileSystem { val file = File(apkPath) if (!file.exists()) return ModuleLoad.Unusable - val preLoadedApk = PreLoadedApk() + val preLoadedApk = ModuleCode() val preLoadedDexes = mutableListOf() val moduleClassNames = mutableListOf() val moduleLibraryNames = mutableListOf() 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 acb2050a5..b918d549b 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 @@ -10,9 +10,9 @@ import io.github.libxposed.service.HookedProcess import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong -import org.lsposed.lspd.models.Module -import org.lsposed.lspd.service.IHotReloadTarget -import org.lsposed.lspd.service.ILSPApplicationService +import org.matrix.vector.ipc.LoadedModule +import org.matrix.vector.ipc.IProcessChannel +import org.matrix.vector.ipc.IFrameworkService import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem import org.matrix.vector.daemon.system.PER_USER_RANGE @@ -29,7 +29,7 @@ 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 -object ApplicationService : ILSPApplicationService.Stub() { +object ApplicationService : IFrameworkService.Stub() { data class ProcessKey(val uid: Int, val pid: Int) @@ -57,7 +57,7 @@ object ApplicationService : ILSPApplicationService.Stub() { IBinder.DeathRecipient { val targetIds = ConcurrentHashMap() - @Volatile var hotReloadBinder: IHotReloadTarget? = null + @Volatile var hotReloadBinder: IProcessChannel? = null init { heartBeat.linkToDeath(this, 0) @@ -71,7 +71,7 @@ object ApplicationService : ILSPApplicationService.Stub() { } } - private fun recordHotReloadTargets(info: ProcessInfo, modules: List) { + private fun recordHotReloadTargets(info: ProcessInfo, modules: List) { for (module in modules) { info.targetIds.computeIfAbsent(module.packageName) { val id = nextHotReloadTargetId.getAndIncrement() @@ -190,13 +190,15 @@ object ApplicationService : ILSPApplicationService.Stub() { target.state.set(state) } - fun getHotReloadBinder(target: HotReloadTarget): IHotReloadTarget? = + fun getHotReloadBinder(target: HotReloadTarget): IProcessChannel? = processes[ProcessKey(target.uid, target.pid)]?.hotReloadBinder - override fun registerHotReloadTarget(target: IHotReloadTarget) { + override fun attachProcessChannel(channel: IProcessChannel) { + // Synchronous on purpose: a oneway transaction arrives with getCallingPid() == 0, and this + // registry is keyed on (uid, pid). See the note on the AIDL. val info = ensureRegistered() - info.hotReloadBinder = target - Log.d(TAG, "Hot reload target registered for ${info.processName} (pid=${info.key.pid})") + info.hotReloadBinder = channel + Log.d(TAG, "Process channel attached for ${info.processName} (pid=${info.key.pid})") } override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { @@ -243,7 +245,7 @@ object ApplicationService : ILSPApplicationService.Stub() { return info } - private fun getAllModules(): List { + private fun getAllModules(): List { val info = ensureRegistered() if (info.key.uid == Process.SYSTEM_UID && info.processName == "system") { return ConfigCache.getModulesForSystemServer() diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt index 3765b7940..a1f1d5059 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt @@ -8,8 +8,8 @@ import android.util.Log import io.github.libxposed.service.IXposedService import java.io.Serializable import java.util.concurrent.ConcurrentHashMap -import org.lsposed.lspd.service.ILSPInjectedModuleService -import org.lsposed.lspd.service.IRemotePreferenceCallback +import org.matrix.vector.ipc.IModuleService +import org.matrix.vector.ipc.IRemotePreferenceCallback import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem import org.matrix.vector.daemon.data.PreferenceStore @@ -17,7 +17,7 @@ import org.matrix.vector.daemon.system.PER_USER_RANGE private const val TAG = "VectorInjectedModuleService" -class InjectedModuleService(private val packageName: String) : ILSPInjectedModuleService.Stub() { +class InjectedModuleService(private val packageName: String) : IModuleService.Stub() { // Tracks active RemotePreferenceCallbacks linked by config group. Preferences are stored per // Android user, so a registration is only interested in updates made by its own user. 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 1a6dc4dea..d42f645ec 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 @@ -18,9 +18,9 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -import org.lsposed.lspd.models.HotReloadOutcome -import org.lsposed.lspd.models.Module -import org.lsposed.lspd.service.IHotReloadOutcomeCallback +import org.matrix.vector.ipc.HotReloadOutcome +import org.matrix.vector.ipc.LoadedModule +import org.matrix.vector.ipc.IHotReloadResultReceiver import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem @@ -33,7 +33,7 @@ import org.matrix.vector.daemon.system.activityManager private const val TAG = "VectorModuleService" -class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { +class ModuleService(private val loadedModule: LoadedModule) : IXposedService.Stub() { companion object { // Per-target serialization lives on the target itself; this only keeps one slow target from @@ -47,7 +47,7 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { private const val RELOAD_TIMEOUT_SECONDS = 30L private val uidSet = ConcurrentHashMap.newKeySet() - private val serviceMap = Collections.synchronizedMap(WeakHashMap()) + private val serviceMap = Collections.synchronizedMap(WeakHashMap()) fun uidClear() { uidSet.clear() @@ -68,7 +68,7 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { } // Drives the same cycle as a service request, so onHotReloading can still refuse it. - fun autoHotReload(module: Module) { + fun autoHotReload(module: LoadedModule) { if (!module.file.autoHotReload) return val service = serviceMap.getOrPut(module) { ModuleService(module) } ApplicationService.staleHotReloadTargets(module.packageName).forEach { target -> @@ -124,7 +124,7 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { val appId = Binder.getCallingUid() % PER_USER_RANGE if (loadedModule.appId != appId) { throw RemoteException( - "Module ${loadedModule.packageName} is not for uid ${Binder.getCallingUid()}") + "LoadedModule ${loadedModule.packageName} is not for uid ${Binder.getCallingUid()}") } return Binder.getCallingUid() / PER_USER_RANGE } @@ -228,7 +228,7 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { if (!target.hotReloadable) { // Hot reload is specified only for modules declaring exactly one Java entry class. - report(callback, IXposedService.HOT_RELOAD_UNSUPPORTED, "Module has no single Java entry class") + report(callback, IXposedService.HOT_RELOAD_UNSUPPORTED, "LoadedModule has no single Java entry class") return } @@ -283,7 +283,7 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { } val callbackStub = - object : IHotReloadOutcomeCallback.Stub() { + object : IHotReloadResultReceiver.Stub() { override fun onHotReloadOutcome(result: HotReloadOutcome?) { outcome = result answered.countDown() diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt index e53a5d4b4..f0564b331 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt @@ -6,14 +6,14 @@ import android.os.IServiceCallback import android.os.Parcel import android.os.ServiceManager import android.util.Log -import org.lsposed.lspd.service.ILSPApplicationService -import org.lsposed.lspd.service.ILSPSystemServerService +import org.matrix.vector.ipc.IFrameworkService +import org.matrix.vector.ipc.ISystemServerBootstrap import org.matrix.vector.daemon.* import org.matrix.vector.daemon.system.getSystemServiceManager private const val TAG = "VectorSystemServer" -object SystemServerService : ILSPSystemServerService.Stub(), IBinder.DeathRecipient { +object SystemServerService : ISystemServerBootstrap.Stub(), IBinder.DeathRecipient { private var proxyServiceName: String? = null private var originService: IBinder? = null @@ -54,12 +54,12 @@ object SystemServerService : ILSPSystemServerService.Stub(), IBinder.DeathRecipi .onFailure { Log.e(TAG, "Failed to register proxy service `$serviceName`", it) } } - override fun requestApplicationService( + override fun attachProcess( uid: Int, pid: Int, processName: String, heartBeat: IBinder? - ): ILSPApplicationService? { + ): IFrameworkService? { if (uid != 1000 || heartBeat == null || processName != "system") return null systemServerRequested = true @@ -84,7 +84,7 @@ object SystemServerService : ILSPSystemServerService.Stub(), IBinder.DeathRecipi val processName = data.readString() ?: "" val heartBeat = data.readStrongBinder() - val service = requestApplicationService(uid, pid, processName, heartBeat) + val service = attachProcess(uid, pid, processName, heartBeat) if (service != null) { reply?.writeNoException() reply?.writeStrongBinder(service.asBinder()) diff --git a/legacy/src/main/java/de/robv/android/xposed/XposedInit.java b/legacy/src/main/java/de/robv/android/xposed/XposedInit.java index 595c5c5c2..52773a658 100644 --- a/legacy/src/main/java/de/robv/android/xposed/XposedInit.java +++ b/legacy/src/main/java/de/robv/android/xposed/XposedInit.java @@ -24,7 +24,7 @@ import org.matrix.vector.impl.utils.VectorModuleClassLoader; import org.matrix.vector.nativebridge.NativeAPI; import org.matrix.vector.nativebridge.ResourcesHook; -import org.lsposed.lspd.models.PreLoadedApk; +import org.matrix.vector.ipc.ModuleCode; import org.lsposed.lspd.util.Utils.Log; import java.io.File; @@ -287,7 +287,7 @@ private static boolean initModule(ClassLoader mcl, String apk, List modu * Load a module from an APK by calling the init(String) method for all classes defined * in assets/xposed_init. */ - private static boolean loadModule(String name, String apk, PreLoadedApk file) { + private static boolean loadModule(String name, String apk, ModuleCode file) { Log.v(TAG, "Loading legacy module " + name + " from " + apk); var sb = new StringBuilder(); diff --git a/legacy/src/main/java/org/matrix/vector/Startup.java b/legacy/src/main/java/org/matrix/vector/Startup.java index f47fff407..596af1ce8 100644 --- a/legacy/src/main/java/org/matrix/vector/Startup.java +++ b/legacy/src/main/java/org/matrix/vector/Startup.java @@ -1,6 +1,6 @@ package org.matrix.vector; -import org.lsposed.lspd.service.ILSPApplicationService; +import org.matrix.vector.ipc.IFrameworkService; import org.lsposed.lspd.util.Utils; import org.matrix.vector.impl.core.VectorStartup; import org.matrix.vector.impl.di.VectorBootstrap; @@ -20,7 +20,7 @@ public static void bootstrapXposed(boolean systemServerStarted) { } } - public static void initXposed(boolean isSystem, String processName, String appDir, ILSPApplicationService service) { + public static void initXposed(boolean isSystem, String processName, String appDir, IFrameworkService service) { // Establish the Dependency Injection contract VectorBootstrap.INSTANCE.init(new LegacyDelegateImpl()); diff --git a/services/daemon-service/build.gradle.kts b/services/daemon-service/build.gradle.kts index 58c1fe1ed..64e28c91c 100644 --- a/services/daemon-service/build.gradle.kts +++ b/services/daemon-service/build.gradle.kts @@ -12,7 +12,7 @@ android { } } - aidlPackagedList += "org/lsposed/lspd/models/Module.aidl" + aidlPackagedList += "org/matrix/vector/ipc/LoadedModule.aidl" namespace = "org.lsposed.lspd.daemonservice" } diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl deleted file mode 100644 index f78dc1588..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/HotReloadOutcome.aidl +++ /dev/null @@ -1,31 +0,0 @@ -package org.lsposed.lspd.models; - -/** - * Result of a hot reload performed inside a hooked process. - */ -parcelable HotReloadOutcome { - /** One of IXposedService.HOT_RELOAD_*. */ - int status; - - /** - * Diagnostic message. Null is reserved for a module refusal, so every other failure has to - * carry a message even when the module's own exception had none. - */ - String message; - - /** - * True only when onHotReloading returned false. This is what lets the daemon keep a null - * message for a genuine refusal while supplying one for every other failure. - */ - boolean refused; - - /** - * True once the process has actually swapped generations, whatever the status says. - * - * onHotReloaded is called after the swap is committed, because the interface releases the old - * generation "after this callback returns or throws". So a throw from it reports FAILED while - * the process is running the new code, and the daemon has to record the new version anyway - - * otherwise getRunningTargets() would keep naming a generation that is no longer loaded. - */ - boolean generationChanged; -} 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 deleted file mode 100644 index fbc7a5132..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/Module.aidl +++ /dev/null @@ -1,13 +0,0 @@ -package org.lsposed.lspd.models; -import org.lsposed.lspd.models.PreLoadedApk; -import org.lsposed.lspd.service.ILSPInjectedModuleService; - -parcelable Module { - String packageName; - int appId; - long versionCode; - String apkPath; - PreLoadedApk file; - ApplicationInfo applicationInfo; - ILSPInjectedModuleService service; -} diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl deleted file mode 100644 index 4be5bc17c..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl +++ /dev/null @@ -1,28 +0,0 @@ -package org.lsposed.lspd.models; - -parcelable PreLoadedApk { - List preLoadedDexes; - List moduleClassNames; - List moduleLibraryNames; - boolean legacy; - // module.prop 'targetApiVersion'. Carried into the process because two of API 102's rules are - // only enforceable there: a module targeting 102 must not be able to resolve the legacy API, - // and `legacy` is a boolean that cannot tell 101 from 102. 0 for a legacy module, which - // declares no target at all. - // - // minApiVersion is deliberately not here. The API puts that check on the module, through - // getApiVersion(), and the manager reads module.prop itself for what it displays - a copy in - // here would have no reader. - int targetApiVersion; - // module.prop 'autoHotReload'. Whether reinstalling the module app should offer a hot reload to - // the processes already running it, rather than leaving them on the old code until they - // restart. The module still has the last word, through onHotReloading. - boolean autoHotReload; - // module.prop 'exceptionMode', normalised by the daemon. false, the value an absent key - // parses to, is PROTECTIVE - what ExceptionMode.DEFAULT is specified to fall back to. - boolean exceptionPassthrough; - // Where the daemon staged this module's native libraries, for the one process that cannot map - // them out of the APK. Null when the module ships none for this ABI, when staging failed, or - // when the module was never destined for system_server in the first place. - @nullable String nativeLibraryDir; -} diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IDaemonService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IDaemonService.aidl deleted file mode 100644 index 8ac84bd34..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IDaemonService.aidl +++ /dev/null @@ -1,11 +0,0 @@ -package org.lsposed.lspd.service; - -import org.lsposed.lspd.service.ILSPApplicationService; - -interface IDaemonService { - ILSPApplicationService requestApplicationService(int uid, int pid, String processName, IBinder heartBeat); - - oneway void dispatchSystemServerContext(in IBinder activityThread, in IBinder activityToken); - - boolean preStartManager(); -} diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadOutcomeCallback.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadOutcomeCallback.aidl deleted file mode 100644 index 7d288b345..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadOutcomeCallback.aidl +++ /dev/null @@ -1,14 +0,0 @@ -package org.lsposed.lspd.service; - -import org.lsposed.lspd.models.HotReloadOutcome; - -/** - * How an injected process answers a hot reload request. - * - * Separate from the request so the request itself can be oneway: the work behind it runs arbitrary - * module code - onHotReloading is allowed to take as long as it likes - and nothing in the daemon - * should be holding a thread, or a target's RELOADING state, for the duration. - */ -interface IHotReloadOutcomeCallback { - oneway void onHotReloadOutcome(in HotReloadOutcome outcome) = 1; -} 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 deleted file mode 100644 index 5fba325b3..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl +++ /dev/null @@ -1,25 +0,0 @@ -package org.lsposed.lspd.service; - -import org.lsposed.lspd.models.Module; -import org.lsposed.lspd.service.IHotReloadOutcomeCallback; - -/** - * 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, and answers through - * callback. - * - *

oneway, and answered out of band, because the work runs the old code's onHotReloading and - * the new code's onHotReloaded - arbitrary module code with no bound on how long it takes. A - * synchronous form would pin a daemon thread for that whole time and, worse, leave the target - * stuck in RELOADING for good if the module never returned, since binder itself has no - * timeout. The daemon supplies one instead.

- */ - oneway void hotReload(String modulePackageName, in Bundle extras, in Module newModule, - IHotReloadOutcomeCallback callback) = 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 deleted file mode 100644 index c8fa3c8cb..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl +++ /dev/null @@ -1,26 +0,0 @@ -package org.lsposed.lspd.service; - -import org.lsposed.lspd.models.Module; -import org.lsposed.lspd.service.IHotReloadTarget; - -interface ILSPApplicationService { - boolean isLogMuted(); - - List getLegacyModulesList(); - - List getModulesList(); - - 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/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPInjectedModuleService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPInjectedModuleService.aidl deleted file mode 100644 index 71751c3a9..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPInjectedModuleService.aidl +++ /dev/null @@ -1,13 +0,0 @@ -package org.lsposed.lspd.service; - -import org.lsposed.lspd.service.IRemotePreferenceCallback; - -interface ILSPInjectedModuleService { - long getFrameworkProperties(); - - Bundle requestRemotePreferences(String group, IRemotePreferenceCallback callback); - - @nullable ParcelFileDescriptor openRemoteFile(String path); - - String[] getRemoteFileList(); -} diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPSystemServerService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPSystemServerService.aidl deleted file mode 100644 index a6963007d..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPSystemServerService.aidl +++ /dev/null @@ -1,7 +0,0 @@ -package org.lsposed.lspd.service; - -import org.lsposed.lspd.service.ILSPApplicationService; - -interface ILSPSystemServerService { - ILSPApplicationService requestApplicationService(int uid, int pid, String processName, IBinder heartBeat); -} diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IRemotePreferenceCallback.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IRemotePreferenceCallback.aidl deleted file mode 100644 index 259c5def5..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IRemotePreferenceCallback.aidl +++ /dev/null @@ -1,5 +0,0 @@ -package org.lsposed.lspd.service; - -interface IRemotePreferenceCallback { - oneway void onUpdate(in Bundle map); -} diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/HotReloadOutcome.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/HotReloadOutcome.aidl new file mode 100644 index 000000000..22ccba901 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/HotReloadOutcome.aidl @@ -0,0 +1,42 @@ +package org.matrix.vector.ipc; + +/** + * What an injected process reports back after attempting a hot reload. + * + *

Three fields rather than one status, because the API's encoding is lossy in two places and + * both losses are ones a module app cannot recover from.

+ */ +parcelable HotReloadOutcome { + /** + * One of {@code IXposedService.HOT_RELOAD_*}, passed through unchanged so that nothing between + * the module and the module app has to re-encode it. + */ + int status; + + /** + * The framework's diagnostic. Null only for a module refusal, because that is the + * encoding {@code HotReloadResult} reserves for one: FAILED with a null message means + * {@code onHotReloading} returned false, and nothing else may claim it. + */ + String message; + + /** + * True only when {@code onHotReloading} returned false. + * + *

This is what lets the daemon keep the null message for a genuine refusal while supplying + * one for every other failure - including a module exception that happened to carry no message + * of its own, which would otherwise be indistinguishable from a refusal.

+ */ + boolean refused; + + /** + * True once the process has actually swapped generations, whatever {@link #status} says. + * + *

Not the same question as whether the reload succeeded. {@code onHotReloaded} is called + * after the swap is committed, because the API releases the old generation "after this callback + * returns or throws" - so a throw from it reports FAILED while the process is running + * the new code. The daemon has to record the new version anyway, or {@code getRunningTargets()} + * would keep naming a generation that is no longer loaded.

+ */ + boolean generationChanged; +} diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl new file mode 100644 index 000000000..e34eefcd2 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl @@ -0,0 +1,64 @@ +package org.matrix.vector.ipc; + +import org.matrix.vector.ipc.LoadedModule; +import org.matrix.vector.ipc.IProcessChannel; + +/** + * What an injected process asks the framework for, once the zygisk handshake has given it one. + * + *

The daemon authenticates every call here by {@code Binder.getCallingUid()/getCallingPid()} + * against the process registry the handshake built, so a caller can only ever act as itself.

+ * + *

Transaction ids are implicit in this file. A new method must be appended; inserting one + * anywhere above renumbers every method after it, and the daemon and the injected processes are + * only guaranteed to agree because they ship in the same zip.

+ */ +interface IFrameworkService { + /** Whether the user has asked the framework to keep quiet in the log. */ + boolean isLogMuted(); + + /** The legacy (de.robv) modules in scope for this process. */ + List getLegacyModulesList(); + + /** + * The libxposed modules in scope for this process. + * + *

Answering this is also what makes the calling process a hot reload target for each module + * returned: the daemon knows it served module M to process P, which is the whole of what + * {@code getRunningTargets()} needs, so nothing has to be reported back afterwards.

+ * + *

That is deliberate rather than incidental. Deriving targets from a registration call made + * by the injected process is what made system_server unreachable in the first attempt at hot + * reload: system_server loads its modules before the daemon's module cache exists, so any + * registration that had to look a module up there failed and was swallowed.

+ */ + List getModulesList(); + + /** Where this process should look for a module's XSharedPreferences files. */ + String getPrefsPath(String packageName); + + /** + * Asks for the manager APK, and for the manager binder if this process is the manager. + * + *

Both directions in one call: {@code binder} is an out-parameter the daemon appends to.

+ */ + ParcelFileDescriptor requestInjectedManagerBinder(out List binder); + + /** + * Hands the daemon the channel it needs to call back into this process. + * + *

Called once while the framework bootstraps, before any module is loaded, and carrying no + * module identity at all - so it cannot depend on the daemon's module cache being populated. + * That dependency is exactly what stopped system_server becoming a hot reload target before. + * The daemon files the channel against the (uid, pid) it authenticated, so a process can only + * ever attach its own.

+ * + *

Not oneway, and must not become oneway. The binder driver only records the sending + * thread for synchronous transactions - {@code binder_transaction()} sets {@code t->from} only + * when {@code TF_ONE_WAY} is clear - so an async call arrives with the caller's euid but a + * {@code getCallingPid()} of 0. The daemon keys its process registry on (uid, pid), so + * making this oneway makes every attach fail authentication, and the only symptom is that + * every later hot reload answers UNSUPPORTED with "no hot reload entry point".

+ */ + void attachProcessChannel(IProcessChannel channel); +} diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadResultReceiver.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadResultReceiver.aidl new file mode 100644 index 000000000..71e3abba3 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadResultReceiver.aidl @@ -0,0 +1,14 @@ +package org.matrix.vector.ipc; + +import org.matrix.vector.ipc.HotReloadOutcome; + +/** + * How an injected process answers a hot reload request. + * + *

Separate from the request so the request itself can be oneway. The work behind it runs + * arbitrary module code - {@code onHotReloading} is allowed to take as long as it likes - and + * neither a daemon thread nor a target's RELOADING state should be held for that long.

+ */ +interface IHotReloadResultReceiver { + oneway void onHotReloadOutcome(in HotReloadOutcome outcome) = 1; +} diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IModuleService.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IModuleService.aidl new file mode 100644 index 000000000..8b799b8f8 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IModuleService.aidl @@ -0,0 +1,33 @@ +package org.matrix.vector.ipc; + +import org.matrix.vector.ipc.IRemotePreferenceCallback; + +/** + * A module's own service, as seen from inside a process the module was injected into. + * + *

Not to be confused with {@code io.github.libxposed.service.IXposedService}, which is the same + * module's service as seen from its app. The two deliberately differ: an app may write its + * remote files, a hooked process may only read them, because a hooked process runs as the app it + * was injected into rather than as the module.

+ */ +interface IModuleService { + /** The framework capability bits, as {@code XposedInterface#getFrameworkProperties}. */ + long getFrameworkProperties(); + + /** + * Reads a preference group, and optionally subscribes to changes made by the module app. + * + * @param callback null to read once without subscribing + */ + Bundle requestRemotePreferences(String group, IRemotePreferenceCallback callback); + + /** + * Opens one of the module's remote files read-only, or null when it does not exist or + * the path is refused - which is what lets the caller raise the FileNotFoundException the API + * documents for both cases. + */ + @nullable ParcelFileDescriptor openRemoteFile(String path); + + /** Names of the module's remote files. */ + String[] getRemoteFileList(); +} diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl new file mode 100644 index 000000000..29252f581 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl @@ -0,0 +1,37 @@ +package org.matrix.vector.ipc; + +import org.matrix.vector.ipc.LoadedModule; +import org.matrix.vector.ipc.IHotReloadResultReceiver; + +/** + * The one thing the daemon calls into an injected process for. + * + *

Every other interface here points the other way; this is the only reverse channel, and it is + * deliberately not a general control surface. A hooked process runs as the app, so anything broader + * would be reachable by the app itself. The process side additionally checks that the caller is the + * daemon.

+ * + *

Handed to the daemon by {@code IFrameworkService#attachProcessChannel} while the framework + * bootstraps, before any module has loaded and carrying no module identity at all - which is what + * makes it work for system_server, whose modules load before the daemon's module cache exists.

+ */ +interface IProcessChannel { + /** + * Loads a new generation of {@code module} over the one already running, and answers through + * {@code receiver}. + * + *

oneway, and answered out of band, because this runs the old code's + * {@code onHotReloading} and the new code's {@code onHotReloaded} - module code, with no bound + * on how long it takes. A synchronous form would pin a daemon thread for that whole time and, + * worse, leave the target stuck in RELOADING for good if the module never returned, since + * binder has no timeout of its own. The daemon supplies one instead.

+ * + * @param modulePackageName which loaded module to replace + * @param extras what the module app passed to {@code hotReloadModule}, reaching the + * old code as {@code HotReloadingParam#getExtras}. Null for a reload + * the daemon started itself, on autoHotReload + * @param module the generation to load + */ + oneway void hotReload(String modulePackageName, in Bundle extras, in LoadedModule module, + IHotReloadResultReceiver receiver) = 1; +} diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IRemotePreferenceCallback.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IRemotePreferenceCallback.aidl new file mode 100644 index 000000000..ed8b86499 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IRemotePreferenceCallback.aidl @@ -0,0 +1,13 @@ +package org.matrix.vector.ipc; + +/** + * How the daemon tells an injected process that a module's remote preferences changed. + * + *

Registered per (module, group, user) through {@link IModuleService#requestRemotePreferences}. + * Without it a hooked process would keep serving values the module app has already replaced, until + * the process restarts.

+ */ +interface IRemotePreferenceCallback { + /** @param map the diff, in the shape RemotePreferences.Editor writes: put / delete / clear */ + oneway void onUpdate(in Bundle map); +} diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ISystemServerBootstrap.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ISystemServerBootstrap.aidl new file mode 100644 index 000000000..660f2d54d --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ISystemServerBootstrap.aidl @@ -0,0 +1,18 @@ +package org.matrix.vector.ipc; + +import org.matrix.vector.ipc.IFrameworkService; + +/** + * The one handshake system_server gets, and it gets it early. + * + *

system_server cannot use {@link IVectorDaemon}: it is specialized before the daemon has any + * way to push a binder into it. Instead the daemon claims a system service name in servicemanager + * before the real service registers, and system_server finds that proxy during specialization. Once + * the real service turns up the proxy forwards everything to it, so this is a single opportunity + * rather than a standing channel - anything system_server needs to hand the daemon has to be handed + * over here.

+ */ +interface ISystemServerBootstrap { + /** As {@link IVectorDaemon#attachProcess}; only uid 1000 / "system" is accepted. */ + IFrameworkService attachProcess(int uid, int pid, String processName, IBinder processLifeToken); +} diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IVectorDaemon.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IVectorDaemon.aidl new file mode 100644 index 000000000..b969c1e31 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IVectorDaemon.aidl @@ -0,0 +1,35 @@ +package org.matrix.vector.ipc; + +import org.matrix.vector.ipc.IFrameworkService; + +/** + * The daemon's front door, reached from a newly specialized process through the zygisk bridge. + * + *

This binder is pushed into a process by the daemon rather than looked up: the zygisk native + * module intercepts {@code Binder.execTransact}, and the daemon transacts a magic code on any + * binder to hand it over. Nothing here is registered in servicemanager.

+ */ +interface IVectorDaemon { + /** + * Announces a process to the daemon and asks for its framework service. + * + *

Returns null when no module is in scope for this process, which is the ordinary answer for + * most of them.

+ * + * @param uid the process uid, as the daemon will re-derive it from the binder call + * @param pid the process id + * @param processName the Android process name, which is what module scope is keyed on + * @param processLifeToken a bare Binder owned by the calling process, used for nothing but + * {@code linkToDeath} - it is how the daemon learns the process is gone, + * and how a hot reload tells a dead target from an unreachable one. The + * caller must keep a strong reference to it (the native side takes a JNI + * global ref); letting it be collected looks exactly like dying. + */ + IFrameworkService attachProcess(int uid, int pid, String processName, IBinder processLifeToken); + + /** Gives the daemon system_server's ActivityThread and activity token, once they exist. */ + oneway void dispatchSystemServerContext(in IBinder activityThread, in IBinder activityToken); + + /** Asks the daemon to bring the manager up before it is needed. */ + boolean preStartManager(); +} diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl new file mode 100644 index 000000000..c8b092e52 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl @@ -0,0 +1,41 @@ +package org.matrix.vector.ipc; + +import org.matrix.vector.ipc.ModuleCode; +import org.matrix.vector.ipc.IModuleService; + +/** + * A module as the daemon hands it to one injected process: who it is, the code to run, and the way + * back to the daemon that the module's own API calls travel over. + * + *

Also the payload of a hot reload. {@code IProcessChannel#hotReload} carries a second one of + * these for a module the process already has, and swapping generations is what the process does + * with it.

+ */ +parcelable LoadedModule { + /** The module app's package name, which is the module's identity everywhere. */ + String packageName; + + /** The module app's app id (uid without the user component). */ + int appId; + + /** + * The module app's version code, as PackageManager reports it. + * + *

This is what tells a running target apart from what is installed: a process still running + * the code of an older version code is {@code STALE}, and is what a hot reload exists to bring + * forward. Reaches the module app as {@code HookedProcess.loadedVersionCode}, where the API + * documents it as diagnostic only.

+ */ + long versionCode; + + /** Path to the module APK, used to build the native library search path. */ + String apkPath; + + /** The generation of code to load. */ + ModuleCode file; + + ApplicationInfo applicationInfo; + + /** What {@code XposedInterface}'s remote preferences and remote files calls go through. */ + IModuleService service; +} diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ModuleCode.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ModuleCode.aidl new file mode 100644 index 000000000..faa7faa42 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ModuleCode.aidl @@ -0,0 +1,73 @@ +package org.matrix.vector.ipc; + +/** + * One generation of a module's executable code, as the daemon read it out of the module APK. + * + *

Named for what it is rather than where it came from: it is no longer "an APK" in any useful + * sense - the dex lives in shared memory, the entry lists come from {@code META-INF/xposed/}, and + * two of the fields are policy read out of {@code module.prop}. A hot reload consists of handing an + * injected process a second one of these for a module it has already loaded.

+ */ +parcelable ModuleCode { + /** + * The module's dex files, mapped read-only. Consumed by the receiving process: it maps them + * into a class loader and closes them, so a second generation needs a second set. + */ + List preLoadedDexes; + + /** + * Fully qualified names of the module's Java entry classes, from + * {@code META-INF/xposed/java_init.list} (or {@code assets/xposed_init} for a legacy module). + * + *

The API requires at least one, and specifies hot reload only for modules that declare + * exactly one - a module with several has no single entry to hand the reload to, and + * must be answered {@code UNSUPPORTED}.

+ */ + List moduleClassNames; + + /** Native libraries the module wants {@code native_init} called on, from native_init.list. */ + List moduleLibraryNames; + + /** True for a module selected by {@code assets/xposed_init} rather than by targetApiVersion. */ + boolean legacy; + + /** + * module.prop {@code targetApiVersion}, or 0 for a legacy module, which declares none. + * + *

Carried into the process because one of API 102's rules is only enforceable there: a + * module targeting 102 or higher must not be able to resolve the legacy + * {@code de.robv.android.xposed} API, and {@link #legacy} is a boolean that cannot tell 101 + * from 102.

+ * + *

module.prop's {@code minApiVersion} is deliberately absent. The API puts that check on the + * module, through {@code XposedInterface#getApiVersion()}, and the manager reads module.prop + * itself for what it displays - a copy here would have no reader.

+ */ + int targetApiVersion; + + /** + * module.prop {@code autoHotReload}: whether reinstalling the module app should offer a hot + * reload to the processes already running it, rather than leaving them on the old code until + * they restart. + * + *

An offer, not a command - the running module still has the last word through + * {@code onHotReloading}.

+ */ + boolean autoHotReload; + + /** + * module.prop {@code exceptionMode}, normalised by the daemon. false, the value an absent key + * parses to, is PROTECTIVE - what {@code ExceptionMode.DEFAULT} is specified to fall back to. + */ + boolean exceptionPassthrough; + + /** + * Where the daemon staged this module's native libraries, for the one process that cannot map + * them out of the APK: /data/app is apk_data_file, which system_server may read and map but + * never execute. + * + *

Null when the module ships none for this ABI, when staging failed, or when the module was + * never destined for system_server in the first place.

+ */ + @nullable String nativeLibraryDir; +} 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 f150752bd..fc2aac7f1 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt @@ -13,7 +13,7 @@ import java.lang.reflect.Field import java.lang.reflect.Method import java.lang.reflect.Modifier import java.util.concurrent.ConcurrentHashMap -import org.lsposed.lspd.service.ILSPInjectedModuleService +import org.matrix.vector.ipc.IModuleService import org.lsposed.lspd.util.Utils.Log import org.matrix.vector.impl.hooks.VectorCtorInvoker import org.matrix.vector.impl.hooks.VectorHookBuilder @@ -59,7 +59,7 @@ private val artMethodField: Field? by lazy { class VectorContext( private val packageName: String, private val applicationInfo: ApplicationInfo, - private val service: ILSPInjectedModuleService, + private val service: IModuleService, // What ExceptionMode.DEFAULT resolves to for this module, from module.prop. private val defaultExceptionMode: ExceptionMode = ExceptionMode.PROTECTIVE, ) : XposedInterface { diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt index 2c66241c9..5899d89b8 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt @@ -7,8 +7,8 @@ import android.util.ArraySet import io.github.libxposed.api.error.XposedFrameworkError import java.util.TreeMap import java.util.concurrent.ConcurrentHashMap -import org.lsposed.lspd.service.ILSPInjectedModuleService -import org.lsposed.lspd.service.IRemotePreferenceCallback +import org.matrix.vector.ipc.IModuleService +import org.matrix.vector.ipc.IRemotePreferenceCallback import org.lsposed.lspd.util.Utils.Log @Suppress("DEPRECATION", "UNCHECKED_CAST") @@ -21,7 +21,7 @@ private inline fun Bundle.getSerializableCompat(key: String): T? { } @Suppress("UNCHECKED_CAST") -internal class VectorRemotePreferences(service: ILSPInjectedModuleService, group: String) : +internal class VectorRemotePreferences(service: IModuleService, group: String) : SharedPreferences { private val map = ConcurrentHashMap() 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 4c3e8595e..4cc91cc27 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 @@ -14,8 +14,8 @@ 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.matrix.vector.ipc.HotReloadOutcome +import org.matrix.vector.ipc.LoadedModule import org.lsposed.lspd.util.Utils.Log import org.matrix.vector.impl.VectorContext import org.matrix.vector.impl.VectorLifecycleManager @@ -54,7 +54,7 @@ object VectorModuleManager { /** * Loads a module APK, instantiates its entry classes, and binds them to the Vector framework. */ - fun loadModule(module: Module, isSystemServer: Boolean, processName: String): Boolean { + fun loadModule(module: LoadedModule, isSystemServer: Boolean, processName: String): Boolean { val (generation, entries) = buildGeneration(module, isSystemServer, processName) ?: return false @@ -85,7 +85,7 @@ object VectorModuleManager { // Publishes nothing, so a reload can fail before the old generation is touched. private fun buildGeneration( - module: Module, + module: LoadedModule, isSystemServer: Boolean, processName: String, ): Pair>? { @@ -188,7 +188,7 @@ object VectorModuleManager { fun hotReload( modulePackageName: String?, extras: Bundle?, - newModule: Module?, + newModule: LoadedModule?, ): HotReloadOutcome { val packageName = modulePackageName ?: return unsupported("Hot reload was requested without a module") @@ -212,7 +212,7 @@ object VectorModuleManager { private fun runHotReload( packageName: String, extras: Bundle?, - newModule: Module?, + newModule: LoadedModule?, ): HotReloadOutcome { if (newModule == null) { return unsupported("No new generation of $packageName was supplied") diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt similarity index 65% rename from xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt rename to xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt index 54d56a2d4..95c75bfe1 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorHotReloadTarget.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt @@ -4,15 +4,21 @@ import android.os.Binder import android.os.Bundle import android.os.Process import java.util.concurrent.Executors -import org.lsposed.lspd.models.Module -import org.lsposed.lspd.service.IHotReloadOutcomeCallback -import org.lsposed.lspd.service.IHotReloadTarget +import org.matrix.vector.ipc.LoadedModule +import org.matrix.vector.ipc.IHotReloadResultReceiver +import org.matrix.vector.ipc.IProcessChannel import org.lsposed.lspd.util.Utils.Log -private const val TAG = "VectorHotReloadTarget" +private const val TAG = "VectorProcessChannel" -/** Registered once while the framework bootstraps, before any module is loaded. */ -object VectorHotReloadTarget : IHotReloadTarget.Stub() { +/** + * This process's end of the only channel the daemon has for calling in. + * + * Handed over once while the framework bootstraps, before any module is loaded and carrying no + * module identity - which is what lets system_server have one too, since its modules load before + * the daemon's module cache exists. + */ +object VectorProcessChannel : IProcessChannel.Stub() { /** * One thread, so reloads in this process are serialised even across modules, and so the @@ -25,8 +31,8 @@ object VectorHotReloadTarget : IHotReloadTarget.Stub() { override fun hotReload( modulePackageName: String?, extras: Bundle?, - newModule: Module?, - callback: IHotReloadOutcomeCallback?, + module: LoadedModule?, + receiver: IHotReloadResultReceiver?, ) { // The daemon is the only caller this binder was ever handed to, but it runs as the system // uid rather than as root, and this object lives in an app process - so the check is worth @@ -38,8 +44,8 @@ object VectorHotReloadTarget : IHotReloadTarget.Stub() { } worker.execute { - val outcome = VectorModuleManager.hotReload(modulePackageName, extras, newModule) - runCatching { callback?.onHotReloadOutcome(outcome) } + val outcome = VectorModuleManager.hotReload(modulePackageName, extras, module) + runCatching { receiver?.onHotReloadOutcome(outcome) } .onFailure { Log.w(TAG, "Cannot report the hot reload outcome", it) } } } 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 163d4f31c..bd2e639b4 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 @@ -2,25 +2,25 @@ 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.matrix.vector.ipc.LoadedModule +import org.matrix.vector.ipc.IProcessChannel +import org.matrix.vector.ipc.IFrameworkService import org.lsposed.lspd.util.Utils.Log /** * Singleton client for managing IPC communication with the injected manager service. Handles Binder * death gracefully and ensures safe remote execution. */ -object VectorServiceClient : ILSPApplicationService, IBinder.DeathRecipient { +object VectorServiceClient : IFrameworkService, IBinder.DeathRecipient { private const val TAG = "VectorServiceClient" - private var service: ILSPApplicationService? = null + private var service: IFrameworkService? = null var processName: String = "" private set @Synchronized - fun init(appService: ILSPApplicationService?, niceName: String) { + fun init(appService: IFrameworkService?, niceName: String) { val binder = appService?.asBinder() if (service == null && binder != null) { runCatching { @@ -33,23 +33,24 @@ object VectorServiceClient : ILSPApplicationService, IBinder.DeathRecipient { 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. + // Handed over here rather than after module loading, and carrying no module identity: + // system_server loads its modules before the daemon's module cache exists, so anything + // that had to name a module here could not work for it. service?.let { try { - it.registerHotReloadTarget(VectorHotReloadTarget) + it.attachProcessChannel(VectorProcessChannel) } catch (t: Throwable) { - Log.e(TAG, "Failed to register the hot reload target in process: $niceName", t) + Log.e(TAG, "Failed to attach the process channel in process: $niceName", t) } } } } - override fun registerHotReloadTarget(target: IHotReloadTarget?) { + override fun attachProcessChannel(channel: IProcessChannel?) { try { - service?.registerHotReloadTarget(target) + service?.attachProcessChannel(channel) } catch (t: Throwable) { - Log.e(TAG, "Failed to register a hot reload target", t) + Log.e(TAG, "Failed to attach the process channel", t) } } @@ -57,11 +58,11 @@ object VectorServiceClient : ILSPApplicationService, IBinder.DeathRecipient { return runCatching { service?.isLogMuted == true }.getOrDefault(false) } - override fun getLegacyModulesList(): List { + override fun getLegacyModulesList(): List { return runCatching { service?.legacyModulesList }.getOrNull() ?: emptyList() } - override fun getModulesList(): List { + override fun getModulesList(): List { return runCatching { service?.modulesList }.getOrNull() ?: emptyList() } diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorStartup.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorStartup.kt index 7614a4866..01ef8d92c 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorStartup.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorStartup.kt @@ -5,7 +5,7 @@ import android.os.Build import android.os.IBinder import dalvik.system.DexFile import org.lsposed.lspd.util.Utils -import org.lsposed.lspd.service.ILSPApplicationService +import org.matrix.vector.ipc.IFrameworkService import org.matrix.vector.impl.di.VectorBootstrap import org.matrix.vector.impl.hookers.* import org.matrix.vector.impl.hooks.VectorHookBuilder @@ -21,7 +21,7 @@ object VectorStartup { isSystem: Boolean, processName: String?, appDir: String?, - service: ILSPApplicationService?, + service: IFrameworkService?, ) { VectorServiceClient.init(service, processName ?: "android") VectorDeopter.deoptBootMethods() diff --git a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt index 275b12ff9..1b245ee26 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt @@ -2,7 +2,7 @@ package org.matrix.vector.core import android.os.IBinder import android.os.Process -import org.lsposed.lspd.service.ILSPApplicationService +import org.matrix.vector.ipc.IFrameworkService import org.lsposed.lspd.util.Utils import org.matrix.vector.BuildConfig import org.matrix.vector.GrapheneDclHooker @@ -39,7 +39,7 @@ object Main { } // Initialize Xposed bridge components - val appService = ILSPApplicationService.Stub.asInterface(binder) + val appService = IFrameworkService.Stub.asInterface(binder) Startup.initXposed(isSystem, niceName, appDir, appService) // Configure logging levels from the service client diff --git a/zygisk/src/main/kotlin/org/matrix/vector/service/BridgeService.kt b/zygisk/src/main/kotlin/org/matrix/vector/service/BridgeService.kt index e9d13724f..45dfcad1c 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/service/BridgeService.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/service/BridgeService.kt @@ -7,7 +7,7 @@ import android.os.IBinder.DeathRecipient import android.os.Parcel import hidden.HiddenApiBridge.Binder_allowBlocking import hidden.HiddenApiBridge.Context_getActivityToken -import org.lsposed.lspd.service.IDaemonService +import org.matrix.vector.ipc.IVectorDaemon import org.lsposed.lspd.util.Utils.Log /** @@ -30,7 +30,7 @@ object BridgeService { @Volatile private var serviceBinder: IBinder? = null - @Volatile private var service: IDaemonService? = null + @Volatile private var service: IVectorDaemon? = null /** Cleans up service references if the remote Vector daemon crashes. */ private val serviceRecipient: DeathRecipient = DeathRecipient { @@ -41,12 +41,12 @@ object BridgeService { } /** Returns the active Vector daemin service interface. */ - @JvmStatic fun getService(): IDaemonService? = service + @JvmStatic fun getService(): IVectorDaemon? = service /** * Initializes the client-side connection to the Vector daemin service. * - * @param binder The raw binder for [IDaemonService]. + * @param binder The raw binder for [IVectorDaemon]. */ private fun receiveFromBridge(binder: IBinder?) { if (binder == null) { @@ -65,7 +65,7 @@ object BridgeService { // Allow blocking calls since we are often in a synchronous fork path val blockingBinder = Binder_allowBlocking(binder) serviceBinder = blockingBinder - service = IDaemonService.Stub.asInterface(blockingBinder) + service = IVectorDaemon.Stub.asInterface(blockingBinder) runCatching { blockingBinder.linkToDeath(serviceRecipient, 0) } .onFailure { Log.e(TAG, "Failed to link to service death", it) } @@ -106,7 +106,7 @@ object BridgeService { val processName = data.readString() val heartBeat = data.readStrongBinder() val appService = - service?.requestApplicationService( + service?.attachProcess( Binder.getCallingUid(), Binder.getCallingPid(), processName, From c7c5a29f9be1e092ad1fe4fcb46b2113c451b415 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 14:33:24 +0200 Subject: [PATCH 10/13] Make the API 102 AIDL consistent, and delete the interface nothing was using Names, now that the package move put the inconsistencies next to each other: getModulesList / getLegacyModulesList -> getModules / getLegacyModules getRemoteFileList -> getRemoteFileNames (it returns names) IHotReloadResultReceiver.onHotReloadOutcome -> IHotReloadOutcomeReceiver.onOutcome (Result and Outcome for one concept) IRemotePreferenceCallback.onUpdate(map) -> onRemotePreferencesChanged(diff) (it is a diff, and the old name said nothing about what had updated) LoadedModule.file -> LoadedModule.code (its type is ModuleCode) Two structural changes rather than renames. ISystemServerBootstrap is deleted. Nothing ever held it as an interface: the only implementer is SystemServerService, nothing calls Stub.asInterface, and the zygisk side reaches it by transacting BRIDGE_TRANSACTION_CODE on the hijacked service name, which onTransact answers before super ever sees it. The generated dispatch table was unreachable and the descriptor was checked by nobody. SystemServerService is a plain Binder now and says why, so the next person does not add the interface back. requestInjectedManagerBinder(out List) becomes openManagerApk() and requestManagerService(). It was two unrelated results in one call, one of them through an out-parameter, and the caller read binderList[0] without checking - so a process that was not granted the manager service got an IndexOutOfBoundsException swallowed by an outer catch, which is a confusing way to spell 'no'. Splitting it lets the caller stop before opening an APK it has no use for, and puts the side effect where it can be documented: asking for the service is what makes this process the manager's host, which is a claim rather than a query. --- .../matrix/vector/daemon/data/ConfigCache.kt | 8 ++--- .../matrix/vector/daemon/data/FileSystem.kt | 4 +-- .../vector/daemon/ipc/ApplicationService.kt | 32 ++++++++++--------- .../daemon/ipc/InjectedModuleService.kt | 4 +-- .../matrix/vector/daemon/ipc/ModuleService.kt | 10 +++--- .../vector/daemon/ipc/SystemServerService.kt | 29 ++++++++++++----- .../de/robv/android/xposed/XposedInit.java | 6 ++-- .../matrix/vector/ipc/IFrameworkService.aidl | 19 ++++++++--- ...er.aidl => IHotReloadOutcomeReceiver.aidl} | 4 +-- .../org/matrix/vector/ipc/IModuleService.aidl | 2 +- .../matrix/vector/ipc/IProcessChannel.aidl | 6 ++-- .../vector/ipc/IRemotePreferenceCallback.aidl | 7 ++-- .../vector/ipc/ISystemServerBootstrap.aidl | 18 ----------- .../org/matrix/vector/ipc/LoadedModule.aidl | 2 +- .../org/matrix/vector/impl/VectorContext.kt | 2 +- .../vector/impl/VectorRemotePreferences.kt | 2 +- .../vector/impl/core/VectorModuleManager.kt | 14 ++++---- .../vector/impl/core/VectorProcessChannel.kt | 6 ++-- .../vector/impl/core/VectorServiceClient.kt | 16 ++++++---- .../matrix/vector/ParasiticManagerHooker.kt | 8 +++-- 20 files changed, 107 insertions(+), 92 deletions(-) rename services/daemon-service/src/main/aidl/org/matrix/vector/ipc/{IHotReloadResultReceiver.aidl => IHotReloadOutcomeReceiver.aidl} (80%) delete mode 100644 services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ISystemServerBootstrap.aidl 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 3ff22268e..04a6b9e93 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 @@ -236,7 +236,7 @@ object ConfigCache { versionCode = pkgInfo.longVersionCode applicationInfo = appInfo service = oldModule?.service ?: InjectedModuleService(pkgName) - file = loaded.apk + code = loaded.apk } newModules[pkgName] = module } @@ -324,7 +324,7 @@ object ConfigCache { // repair and nothing that replaces the scope table can drop it again. Legacy is the // loader's own verdict, so a module built against API 101 keeps its own process to itself. newModules.values - .filter { it.file?.legacy == true } + .filter { it.code?.legacy == true } .forEach { module -> userManager?.getRealUsers()?.forEach { user -> val pkgInfo = @@ -444,7 +444,7 @@ object ConfigCache { } FileSystem.loadModule(apkPath, state.isDexObfuscateEnabled).apkOrNull?.let { - module.file = it + module.code = it stageNativeLibrariesFor(module) modules.add(module) // We intentionally don't mutate state.modules here. Cache update will catch it. @@ -465,7 +465,7 @@ object ConfigCache { * exactly as it did before. */ private fun stageNativeLibrariesFor(module: LoadedModule) { - val file = module.file ?: return + val file = module.code ?: return // system_server asks for its modules early enough that the cache may not have been built yet, // and this is the same reason getPrefsPath does not trust the field either. setupMiscPath() diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt index 6fa533a2f..814bd7670 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -608,10 +608,10 @@ object FileSystem { os.write("${scope.processName}/${scope.uid}\n".toByteArray()) modules.forEach { mod -> os.write("\t${mod.packageName}\n".toByteArray()) - mod.file?.moduleClassNames?.forEach { cn -> + mod.code?.moduleClassNames?.forEach { cn -> os.write("\t\t$cn\n".toByteArray()) } - mod.file?.moduleLibraryNames?.forEach { ln -> + mod.code?.moduleLibraryNames?.forEach { ln -> os.write("\t\t$ln\n".toByteArray()) } } 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 b918d549b..b96f9299e 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 @@ -84,7 +84,7 @@ object ApplicationService : IFrameworkService.Stub() { pid = info.key.pid, loadedVersionCode = module.versionCode, // Hot reload is specified only for modules with exactly one Java entry class. - hotReloadable = module.file.moduleClassNames.size == 1, + hotReloadable = module.code.moduleClassNames.size == 1, ) id } @@ -256,10 +256,10 @@ object ApplicationService : IFrameworkService.Stub() { return ConfigCache.getModulesForProcess(info.processName, info.key.uid) } - override fun getModulesList() = - getAllModules().filter { !it.file.legacy }.also { recordHotReloadTargets(ensureRegistered(), it) } + override fun getModules() = + getAllModules().filter { !it.code.legacy }.also { recordHotReloadTargets(ensureRegistered(), it) } - override fun getLegacyModulesList() = getAllModules().filter { it.file.legacy } + override fun getLegacyModules() = getAllModules().filter { it.code.legacy } override fun isLogMuted(): Boolean = !ManagerService.isVerboseLog @@ -268,17 +268,8 @@ object ApplicationService : IFrameworkService.Stub() { return ConfigCache.getPrefsPath(packageName, info.key.uid) } - override fun requestInjectedManagerBinder( - binderList: MutableList - ): ParcelFileDescriptor? { - val info = ensureRegistered() - val pid = info.key.pid - val uid = info.key.uid - - if (ManagerService.postStartManager(pid) || ConfigCache.isManager(uid)) { - binderList.add(ManagerService.obtainManagerBinder(info.heartBeat, pid, uid)) - } - + override fun openManagerApk(): ParcelFileDescriptor? { + ensureRegistered() return runCatching { // Verify the APK signature before serving it InstallerVerifier.verifyInstallerSignature(FileSystem.managerApkPath.toString()) @@ -288,4 +279,15 @@ object ApplicationService : IFrameworkService.Stub() { .onFailure { Log.e(TAG, "Failed to open or verify manager APK", it) } .getOrNull() } + + override fun requestManagerService(): IBinder? { + val info = ensureRegistered() + val pid = info.key.pid + val uid = info.key.uid + // postStartManager decides here that this process hosts the manager, so this is a claim rather + // than a query - which is why it is its own call now instead of a hidden out-parameter on the + // one that opens the APK. + if (!ManagerService.postStartManager(pid) && !ConfigCache.isManager(uid)) return null + return ManagerService.obtainManagerBinder(info.heartBeat, pid, uid) + } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt index a1f1d5059..b331df99a 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt @@ -66,7 +66,7 @@ class InjectedModuleService(private val packageName: String) : IModuleService.St .getOrNull() } - override fun getRemoteFileList(): Array { + override fun getRemoteFileNames(): Array { val userId = Binder.getCallingUid() / PER_USER_RANGE return runCatching { val dir = FileSystem.resolveModuleDir(packageName, "files", userId, -1) @@ -80,7 +80,7 @@ class InjectedModuleService(private val packageName: String) : IModuleService.St val groupCallbacks = callbacks[group] ?: return for (subscriber in groupCallbacks) { if (subscriber.userId != userId) continue - runCatching { subscriber.callback.onUpdate(diff) } + runCatching { subscriber.callback.onRemotePreferencesChanged(diff) } .onFailure { groupCallbacks.remove(subscriber) } } } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt index d42f645ec..fecca4e55 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 @@ -20,7 +20,7 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import org.matrix.vector.ipc.HotReloadOutcome import org.matrix.vector.ipc.LoadedModule -import org.matrix.vector.ipc.IHotReloadResultReceiver +import org.matrix.vector.ipc.IHotReloadOutcomeReceiver import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem @@ -56,7 +56,7 @@ class ModuleService(private val loadedModule: LoadedModule) : IXposedService.Stu fun uidStarts(uid: Int) { if (uidSet.add(uid)) { val module = ConfigCache.getModuleByUid(uid) - if (module?.file?.legacy == false) { + if (module?.code?.legacy == false) { val service = serviceMap.getOrPut(module) { ModuleService(module) } service.sendBinder(uid) } @@ -69,7 +69,7 @@ class ModuleService(private val loadedModule: LoadedModule) : IXposedService.Stu // Drives the same cycle as a service request, so onHotReloading can still refuse it. fun autoHotReload(module: LoadedModule) { - if (!module.file.autoHotReload) return + if (!module.code.autoHotReload) return val service = serviceMap.getOrPut(module) { ModuleService(module) } ApplicationService.staleHotReloadTargets(module.packageName).forEach { target -> if (target.hotReloadable && ApplicationService.beginHotReload(target)) { @@ -283,8 +283,8 @@ class ModuleService(private val loadedModule: LoadedModule) : IXposedService.Stu } val callbackStub = - object : IHotReloadResultReceiver.Stub() { - override fun onHotReloadOutcome(result: HotReloadOutcome?) { + object : IHotReloadOutcomeReceiver.Stub() { + override fun onOutcome(result: HotReloadOutcome?) { outcome = result answered.countDown() } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt index f0564b331..e961f4743 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt @@ -1,5 +1,6 @@ package org.matrix.vector.daemon.ipc +import android.os.Binder import android.os.Build import android.os.IBinder import android.os.IServiceCallback @@ -7,13 +8,21 @@ import android.os.Parcel import android.os.ServiceManager import android.util.Log import org.matrix.vector.ipc.IFrameworkService -import org.matrix.vector.ipc.ISystemServerBootstrap import org.matrix.vector.daemon.* import org.matrix.vector.daemon.system.getSystemServiceManager private const val TAG = "VectorSystemServer" -object SystemServerService : ISystemServerBootstrap.Stub(), IBinder.DeathRecipient { +/** + * The daemon's end of the one handshake system_server gets. + * + * A plain [Binder] rather than an AIDL stub on purpose. system_server never holds an interface for + * this - it reaches the daemon by transacting [BRIDGE_TRANSACTION_CODE] on whatever binder the + * hijacked service name resolves to, which [onTransact] answers directly. An AIDL interface here + * would generate a dispatch table nothing ever entered, and would have to state a descriptor that + * nothing ever checks. + */ +object SystemServerService : Binder(), IBinder.DeathRecipient { private var proxyServiceName: String? = null private var originService: IBinder? = null @@ -54,17 +63,21 @@ object SystemServerService : ISystemServerBootstrap.Stub(), IBinder.DeathRecipie .onFailure { Log.e(TAG, "Failed to register proxy service `$serviceName`", it) } } - override fun attachProcess( + /** + * Registers system_server and answers with its framework service, or null if this is not + * system_server. Only ever called from [onTransact] below. + */ + private fun attachProcess( uid: Int, pid: Int, processName: String, - heartBeat: IBinder? + processLifeToken: IBinder? ): IFrameworkService? { - if (uid != 1000 || heartBeat == null || processName != "system") return null + if (uid != 1000 || processLifeToken == null || processName != "system") return null systemServerRequested = true // Return the ApplicationService singleton if successfully registered - return if (ApplicationService.registerHeartBeat(uid, pid, processName, heartBeat)) { + return if (ApplicationService.registerHeartBeat(uid, pid, processName, processLifeToken)) { ApplicationService } else null } @@ -82,9 +95,9 @@ object SystemServerService : ISystemServerBootstrap.Stub(), IBinder.DeathRecipie val uid = data.readInt() val pid = data.readInt() val processName = data.readString() ?: "" - val heartBeat = data.readStrongBinder() + val processLifeToken = data.readStrongBinder() - val service = attachProcess(uid, pid, processName, heartBeat) + val service = attachProcess(uid, pid, processName, processLifeToken) if (service != null) { reply?.writeNoException() reply?.writeStrongBinder(service.asBinder()) diff --git a/legacy/src/main/java/de/robv/android/xposed/XposedInit.java b/legacy/src/main/java/de/robv/android/xposed/XposedInit.java index 52773a658..e45c8b3a8 100644 --- a/legacy/src/main/java/de/robv/android/xposed/XposedInit.java +++ b/legacy/src/main/java/de/robv/android/xposed/XposedInit.java @@ -204,11 +204,11 @@ public static Map> getLoadedModules() { } public static void loadLegacyModules() { - var moduleList = VectorServiceClient.INSTANCE.getLegacyModulesList(); + var moduleList = VectorServiceClient.INSTANCE.getLegacyModules(); moduleList.forEach(module -> { var apk = module.apkPath; var name = module.packageName; - var file = module.file; + var file = module.code; loadedModules.put(name, Optional.of(apk)); // temporarily add it for XSharedPreference if (!loadModule(name, apk, file)) { loadedModules.remove(name); @@ -225,7 +225,7 @@ public static void loadModules(ActivityThread at) { return; } var packages = (ArrayMap) XposedHelpers.getObjectField(at, "mPackages"); - VectorServiceClient.INSTANCE.getModulesList().forEach(module -> { + VectorServiceClient.INSTANCE.getModules().forEach(module -> { loadedModules.put(module.packageName, Optional.empty()); if (!VectorModuleManager.INSTANCE.loadModule(module, startsSystemServer, VectorServiceClient.INSTANCE.getProcessName())) { loadedModules.remove(module.packageName); diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl index e34eefcd2..447f0a307 100644 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl @@ -18,7 +18,7 @@ interface IFrameworkService { boolean isLogMuted(); /** The legacy (de.robv) modules in scope for this process. */ - List getLegacyModulesList(); + List getLegacyModules(); /** * The libxposed modules in scope for this process. @@ -32,17 +32,26 @@ interface IFrameworkService { * reload: system_server loads its modules before the daemon's module cache exists, so any * registration that had to look a module up there failed and was swallowed.

*/ - List getModulesList(); + List getModules(); /** Where this process should look for a module's XSharedPreferences files. */ String getPrefsPath(String packageName); /** - * Asks for the manager APK, and for the manager binder if this process is the manager. + * The manager APK, opened read-only once its signature has been verified against the one this + * framework was built with. Null when it is missing or does not verify. + */ + @nullable ParcelFileDescriptor openManagerApk(); + + /** + * The manager's service binder, if this process is the one that should host the manager. * - *

Both directions in one call: {@code binder} is an out-parameter the daemon appends to.

+ *

Null for every other process, which is nearly all of them. Asking is how a process finds + * out, and asking is not free of consequence: the daemon decides here that this process + * is the host, so a process that asks and then does not go on to host the manager has taken + * the slot from whichever one would have.

*/ - ParcelFileDescriptor requestInjectedManagerBinder(out List binder); + @nullable IBinder requestManagerService(); /** * Hands the daemon the channel it needs to call back into this process. diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadResultReceiver.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadOutcomeReceiver.aidl similarity index 80% rename from services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadResultReceiver.aidl rename to services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadOutcomeReceiver.aidl index 71e3abba3..419403691 100644 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadResultReceiver.aidl +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadOutcomeReceiver.aidl @@ -9,6 +9,6 @@ import org.matrix.vector.ipc.HotReloadOutcome; * arbitrary module code - {@code onHotReloading} is allowed to take as long as it likes - and * neither a daemon thread nor a target's RELOADING state should be held for that long.

*/ -interface IHotReloadResultReceiver { - oneway void onHotReloadOutcome(in HotReloadOutcome outcome) = 1; +interface IHotReloadOutcomeReceiver { + oneway void onOutcome(in HotReloadOutcome outcome) = 1; } diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IModuleService.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IModuleService.aidl index 8b799b8f8..2b08f57e1 100644 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IModuleService.aidl +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IModuleService.aidl @@ -29,5 +29,5 @@ interface IModuleService { @nullable ParcelFileDescriptor openRemoteFile(String path); /** Names of the module's remote files. */ - String[] getRemoteFileList(); + String[] getRemoteFileNames(); } diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl index 29252f581..09bb34e00 100644 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl @@ -1,7 +1,7 @@ package org.matrix.vector.ipc; import org.matrix.vector.ipc.LoadedModule; -import org.matrix.vector.ipc.IHotReloadResultReceiver; +import org.matrix.vector.ipc.IHotReloadOutcomeReceiver; /** * The one thing the daemon calls into an injected process for. @@ -11,7 +11,7 @@ import org.matrix.vector.ipc.IHotReloadResultReceiver; * would be reachable by the app itself. The process side additionally checks that the caller is the * daemon.

* - *

Handed to the daemon by {@code IFrameworkService#attachProcessChannel} while the framework + *

Handed to the daemon by {@link IFrameworkService#attachProcessChannel} while the framework * bootstraps, before any module has loaded and carrying no module identity at all - which is what * makes it work for system_server, whose modules load before the daemon's module cache exists.

*/ @@ -33,5 +33,5 @@ interface IProcessChannel { * @param module the generation to load */ oneway void hotReload(String modulePackageName, in Bundle extras, in LoadedModule module, - IHotReloadResultReceiver receiver) = 1; + IHotReloadOutcomeReceiver receiver) = 1; } diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IRemotePreferenceCallback.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IRemotePreferenceCallback.aidl index ed8b86499..4e66755dd 100644 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IRemotePreferenceCallback.aidl +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IRemotePreferenceCallback.aidl @@ -8,6 +8,9 @@ package org.matrix.vector.ipc; * the process restarts.

*/ interface IRemotePreferenceCallback { - /** @param map the diff, in the shape RemotePreferences.Editor writes: put / delete / clear */ - oneway void onUpdate(in Bundle map); + /** + * @param diff what changed, in the shape RemotePreferences.Editor writes it: a "put" map, a + * "delete" set, and a "clear" flag - not the whole group + */ + oneway void onRemotePreferencesChanged(in Bundle diff); } diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ISystemServerBootstrap.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ISystemServerBootstrap.aidl deleted file mode 100644 index 660f2d54d..000000000 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ISystemServerBootstrap.aidl +++ /dev/null @@ -1,18 +0,0 @@ -package org.matrix.vector.ipc; - -import org.matrix.vector.ipc.IFrameworkService; - -/** - * The one handshake system_server gets, and it gets it early. - * - *

system_server cannot use {@link IVectorDaemon}: it is specialized before the daemon has any - * way to push a binder into it. Instead the daemon claims a system service name in servicemanager - * before the real service registers, and system_server finds that proxy during specialization. Once - * the real service turns up the proxy forwards everything to it, so this is a single opportunity - * rather than a standing channel - anything system_server needs to hand the daemon has to be handed - * over here.

- */ -interface ISystemServerBootstrap { - /** As {@link IVectorDaemon#attachProcess}; only uid 1000 / "system" is accepted. */ - IFrameworkService attachProcess(int uid, int pid, String processName, IBinder processLifeToken); -} diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl index c8b092e52..ea839d35b 100644 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl @@ -32,7 +32,7 @@ parcelable LoadedModule { String apkPath; /** The generation of code to load. */ - ModuleCode file; + ModuleCode code; ApplicationInfo applicationInfo; 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 fc2aac7f1..94240a965 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt @@ -167,7 +167,7 @@ class VectorContext( } override fun listRemoteFiles(): Array { - return service.remoteFileList + return service.remoteFileNames } override fun openRemoteFile(name: String): ParcelFileDescriptor { diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt index 5899d89b8..77ee6c173 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt @@ -30,7 +30,7 @@ internal class VectorRemotePreferences(service: IModuleService, group: String) : private val callback = object : IRemotePreferenceCallback.Stub() { @Synchronized - override fun onUpdate(bundle: Bundle) { + override fun onRemotePreferencesChanged(bundle: Bundle) { val changes = ArraySet() // Sent for edit().clear() and for deleteRemotePreferences. Without this the cache 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 4cc91cc27..e34d61add 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 @@ -99,7 +99,7 @@ object VectorModuleManager { // stages a copy under a label we own for exactly this reason, and it has to come // first, because findLibrary answers with the first candidate it can open. if (isSystemServer) { - module.file.nativeLibraryDir?.let { + module.code.nativeLibraryDir?.let { append(it).append(File.pathSeparator) } } @@ -116,10 +116,10 @@ object VectorModuleManager { val moduleClassLoader = VectorModuleClassLoader.loadApk( module.apkPath, - module.file.preLoadedDexes, + module.code.preLoadedDexes, librarySearchPath, initLoader, - blockLegacyApi = module.file.targetApiVersion >= 102, + blockLegacyApi = module.code.targetApiVersion >= 102, ) // Security/Integrity Check: Ensure the module isn't bundling its own API classes @@ -138,7 +138,7 @@ object VectorModuleManager { applicationInfo = module.applicationInfo, service = module.service, // Our IPC client defaultExceptionMode = - if (module.file.exceptionPassthrough) ExceptionMode.PASSTHROUGH + if (module.code.exceptionPassthrough) ExceptionMode.PASSTHROUGH else ExceptionMode.PROTECTIVE, ) @@ -146,13 +146,13 @@ object VectorModuleManager { // the entry classes run: a module is free to load its libraries from its constructor or // from onModuleLoaded, and an entrypoint recorded afterwards is one the dlopen hook has // already missed. The legacy loader has always done it in this order. - module.file.moduleLibraryNames.forEach { libraryName -> + module.code.moduleLibraryNames.forEach { libraryName -> NativeAPI.recordNativeEntrypoint(libraryName) } // Instantiate the module entry classes val entries = mutableListOf() - for (className in module.file.moduleClassNames) { + for (className in module.code.moduleClassNames) { runCatching { val moduleClass = moduleClassLoader.loadClass(className) Log.v(TAG, "Loading class $moduleClass") @@ -222,7 +222,7 @@ object VectorModuleManager { ?: return unsupported( "$packageName is not loaded in ${VectorServiceClient.processName}" ) - if (newModule.file.moduleClassNames.size != 1) { + if (newModule.code.moduleClassNames.size != 1) { return unsupported("$packageName does not declare exactly one Java entry class") } diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt index 95c75bfe1..31b9146d2 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt @@ -5,7 +5,7 @@ import android.os.Bundle import android.os.Process import java.util.concurrent.Executors import org.matrix.vector.ipc.LoadedModule -import org.matrix.vector.ipc.IHotReloadResultReceiver +import org.matrix.vector.ipc.IHotReloadOutcomeReceiver import org.matrix.vector.ipc.IProcessChannel import org.lsposed.lspd.util.Utils.Log @@ -32,7 +32,7 @@ object VectorProcessChannel : IProcessChannel.Stub() { modulePackageName: String?, extras: Bundle?, module: LoadedModule?, - receiver: IHotReloadResultReceiver?, + receiver: IHotReloadOutcomeReceiver?, ) { // The daemon is the only caller this binder was ever handed to, but it runs as the system // uid rather than as root, and this object lives in an app process - so the check is worth @@ -45,7 +45,7 @@ object VectorProcessChannel : IProcessChannel.Stub() { worker.execute { val outcome = VectorModuleManager.hotReload(modulePackageName, extras, module) - runCatching { receiver?.onHotReloadOutcome(outcome) } + runCatching { receiver?.onOutcome(outcome) } .onFailure { Log.w(TAG, "Cannot report the hot reload outcome", it) } } } 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 bd2e639b4..e59dd8d85 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 @@ -58,20 +58,24 @@ object VectorServiceClient : IFrameworkService, IBinder.DeathRecipient { return runCatching { service?.isLogMuted == true }.getOrDefault(false) } - override fun getLegacyModulesList(): List { - return runCatching { service?.legacyModulesList }.getOrNull() ?: emptyList() + override fun getLegacyModules(): List { + return runCatching { service?.legacyModules }.getOrNull() ?: emptyList() } - override fun getModulesList(): List { - return runCatching { service?.modulesList }.getOrNull() ?: emptyList() + override fun getModules(): List { + return runCatching { service?.modules }.getOrNull() ?: emptyList() } override fun getPrefsPath(packageName: String): String? { return runCatching { service?.getPrefsPath(packageName) }.getOrNull() } - override fun requestInjectedManagerBinder(binder: List): ParcelFileDescriptor? { - return runCatching { service?.requestInjectedManagerBinder(binder) }.getOrNull() + override fun openManagerApk(): ParcelFileDescriptor? { + return runCatching { service?.openManagerApk() }.getOrNull() + } + + override fun requestManagerService(): IBinder? { + return runCatching { service?.requestManagerService() }.getOrNull() } override fun asBinder(): IBinder? { diff --git a/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt b/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt index 79be41415..ffb975604 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt @@ -474,10 +474,12 @@ object ParasiticManagerHooker { /** Entry point. Checks if the current process should host the parasitic manager. */ @JvmStatic fun start(): Boolean { - val binderList = mutableListOf() return try { - VectorServiceClient.requestInjectedManagerBinder(binderList)!!.use { pfd -> - val managerService = ILSPManagerService.Stub.asInterface(binderList[0]) + // Claimed first, because asking for it is what makes this process the manager's host; + // there is no point opening the APK for a process that was not given the service. + val managerBinder = VectorServiceClient.requestManagerService() ?: return false + VectorServiceClient.openManagerApk()!!.use { pfd -> + val managerService = ILSPManagerService.Stub.asInterface(managerBinder) if (isParasitic) { managerFd = pfd.detachFd() From cec720641104c5b1edfe6934c5adcccee97647c6 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 15:50:19 +0200 Subject: [PATCH 11/13] Fix two defects a pre-merge review reproduced on a device Both were found by reading and then confirmed on hardware, and both had the same shape: the framework reported success for something that had not happened. addressableBy() tested `uid < PER_USER_RANGE`, meaning every uid in user 0 rather than the AID_* uids the carve-out was for. A module app in a secondary user could therefore enumerate and hot reload every user-0 process running that module - the exact cross-user access the commit that added the check says it prevents. On the test device a copy of the harness module installed only for user 10 listed uids 10135 and 10136 and reloaded a user-0 app that was not installed for user 10 at all. The boundary is FIRST_APPLICATION_UID; system_server keeps its carve-out, which is what the check existed for. buildGeneration() returned a Generation whose entry list was empty when every entry class failed to instantiate, and no caller treated that as failure. The initial load reported the module loaded. A hot reload was worse: it committed the empty generation, never called onHotReloaded, never unhooked the old hooks, answered HOT_RELOAD_SUCCEEDED, and left the target wedged, since the committed generation had no live entry for any later reload to hand over to. Reproduced with a module whose constructor throws only inside the target process: the daemon reported SUCCEEDED and loadedVersionCode 8 while the process went on running V7's hookers, and every later reload answered UNSUPPORTED. Also corrects documentation the same review found to be wrong, which matters because this branch's claim is that it documents things - a confidently wrong comment is worse than none: - The dedup comment said the dlopen hook walks the library list without a break, so a duplicate would call native_init twice. It does break at the first match. The list never shrinking is the real reason to dedup, and now what it says. - Two comments said postStartManager decides here that this process hosts the manager. It is `pid == managerPid`, a comparison; the decision was taken when the daemon launched the manager. - IFrameworkService claimed every call is authenticated. isLogMuted is not, and deliberately so. - IProcessChannel claimed to be the only interface the daemon calls into a process on. IRemotePreferenceCallback, in the same package, is another. - HotReloadOutcome.message said null means a refusal. Success is null too; what is reserved is FAILED with a null message. - LoadedModule.versionCode and ModuleCode.targetApiVersion described values the daemon does not always produce. - The AIDL rename had leaked "LoadedModule" into two English diagnostics, one of which reaches the module app as HotReloadResult.message(). - daemon, zygisk and legacy READMEs still named IDaemonService, ILSPApplicationService and getLegacyModulesList. --- daemon/README.md | 2 +- .../vector/daemon/ipc/ApplicationService.kt | 19 ++++++++++++------- .../vector/daemon/ipc/ManagerService.kt | 2 +- .../matrix/vector/daemon/ipc/ModuleService.kt | 4 ++-- .../vector/daemon/system/SystemExtensions.kt | 9 +++++++++ legacy/README.md | 2 +- native/src/core/native_api.cpp | 6 +++--- .../matrix/vector/ipc/HotReloadOutcome.aidl | 7 ++++--- .../matrix/vector/ipc/IFrameworkService.aidl | 17 ++++++++++------- .../matrix/vector/ipc/IProcessChannel.aidl | 9 +++++---- .../org/matrix/vector/ipc/LoadedModule.aidl | 5 ++++- .../org/matrix/vector/ipc/ModuleCode.aidl | 4 +++- .../vector/impl/core/VectorModuleManager.kt | 18 +++++++++++++++--- zygisk/README.md | 6 +++--- .../matrix/vector/ParasiticManagerHooker.kt | 4 ++-- 15 files changed, 75 insertions(+), 39 deletions(-) diff --git a/daemon/README.md b/daemon/README.md index 534df888c..c913307e2 100644 --- a/daemon/README.md +++ b/daemon/README.md @@ -19,7 +19,7 @@ src/main/ ├── utils/ # Context forgery, signature verification, and JNI bridges ├── Cli.kt # Command-line interface definitions ├── VectorDaemon.kt # Main entry point and looper initialization - └── VectorService.kt # Primary IDaemonService implementation + └── VectorService.kt # Primary IVectorDaemon implementation ``` ## Concurrency and State Management 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 b96f9299e..eebf12c8d 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 @@ -15,6 +15,7 @@ import org.matrix.vector.ipc.IProcessChannel import org.matrix.vector.ipc.IFrameworkService import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.FileSystem +import org.matrix.vector.daemon.system.FIRST_APPLICATION_UID import org.matrix.vector.daemon.system.PER_USER_RANGE import org.matrix.vector.daemon.utils.InstallerVerifier import org.matrix.vector.daemon.utils.ObfuscationManager @@ -95,12 +96,16 @@ object ApplicationService : IFrameworkService.Stub() { * Whether [userId]'s copy of the module may address [target]. * * The same module installed for two users is two module apps with two sets of preferences, and - * neither has any business reloading the other's processes. System uids are the exception rather - * than a hole: system_server runs once for the whole device and carries a module enabled in any - * user, so scoping it to user 0 would make it unreachable from every other user. + * neither has any business reloading the other's processes. + * + * The carve-out is for the AID_* uids below [FIRST_APPLICATION_UID], not for user 0: system_server + * runs once for the whole device and carries a module enabled in any user, so scoping it to user 0 + * would make it unreachable from every other one. Testing `uid < PER_USER_RANGE` instead would + * admit the whole of user 0 - every app process on a single-user device - which is the opposite of + * what this is for. */ private fun addressableBy(target: HotReloadTarget, userId: Int): Boolean = - target.uid < PER_USER_RANGE || target.uid / PER_USER_RANGE == userId + target.uid < FIRST_APPLICATION_UID || target.uid / PER_USER_RANGE == userId // Not filtered to hot-reloadable targets: the AIDL documents this as hooked processes, and one // that cannot be reloaded answers UNSUPPORTED rather than disappearing. @@ -284,9 +289,9 @@ object ApplicationService : IFrameworkService.Stub() { val info = ensureRegistered() val pid = info.key.pid val uid = info.key.uid - // postStartManager decides here that this process hosts the manager, so this is a claim rather - // than a query - which is why it is its own call now instead of a hidden out-parameter on the - // one that opens the APK. + // postStartManager compares the caller against the pid the daemon launched the manager into, + // so this reports a decision already taken rather than making one. It is its own call because it + // answers a different question from the one that opens the APK, not because it costs anything. if (!ManagerService.postStartManager(pid) && !ConfigCache.isManager(uid)) return null return ManagerService.obtainManagerBinder(info.heartBeat, pid, uid) } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt index 4839f00f7..41b0a3beb 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt @@ -297,7 +297,7 @@ object ManagerService : ILSPManagerService.Stub() { /** * The flashed manager APK, verified, for the manager to install as an ordinary app. * - * The same file and the same check as [ApplicationService.requestInjectedManagerBinder], which + * The same file and the same check as [ApplicationService.openManagerApk], which * serves it to the host process for injection — one APK, one signature gate, whichever way it * leaves the module directory. */ 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 fecca4e55..afcd5357d 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 @@ -124,7 +124,7 @@ class ModuleService(private val loadedModule: LoadedModule) : IXposedService.Stu val appId = Binder.getCallingUid() % PER_USER_RANGE if (loadedModule.appId != appId) { throw RemoteException( - "LoadedModule ${loadedModule.packageName} is not for uid ${Binder.getCallingUid()}") + "Module ${loadedModule.packageName} is not for uid ${Binder.getCallingUid()}") } return Binder.getCallingUid() / PER_USER_RANGE } @@ -228,7 +228,7 @@ class ModuleService(private val loadedModule: LoadedModule) : IXposedService.Stu if (!target.hotReloadable) { // Hot reload is specified only for modules declaring exactly one Java entry class. - report(callback, IXposedService.HOT_RELOAD_UNSUPPORTED, "LoadedModule has no single Java entry class") + report(callback, IXposedService.HOT_RELOAD_UNSUPPORTED, "Module has no single Java entry class") return } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt index 8f62fccf8..b4dd39e5a 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/SystemExtensions.kt @@ -20,6 +20,15 @@ import org.matrix.vector.daemon.utils.getRealUsers private const val TAG = "VectorSystem" const val PER_USER_RANGE = 100000 + +/** + * The first uid handed to an installed app, as `android.os.Process.FIRST_APPLICATION_UID`. + * + * Below it are the AID_* uids, which carry no user component and are the same process for the whole + * device however many users exist. Above it, a uid is `user * PER_USER_RANGE + appId`, so dividing + * by [PER_USER_RANGE] is only a user test for uids on this side of the line. + */ +const val FIRST_APPLICATION_UID = 10000 const val MATCH_ANY_USER = 0x00400000 // PackageManager.MATCH_ANY_USER const val MATCH_ALL_FLAGS = PackageManager.MATCH_DISABLED_COMPONENTS or diff --git a/legacy/README.md b/legacy/README.md index 78fbe7a30..d41b2aabd 100644 --- a/legacy/README.md +++ b/legacy/README.md @@ -24,7 +24,7 @@ The `LegacyDelegateImpl` satisfies the `LegacyFrameworkDelegate` interface, acti ## Module Initialization -Legacy modules are loaded during the initialization phase via `XposedInit.loadLegacyModules()`. The framework queries the daemon (`VectorServiceClient.INSTANCE.getLegacyModulesList()`) to retrieve the list of enabled APK paths. +Legacy modules are loaded during the initialization phase via `XposedInit.loadLegacyModules()`. The framework queries the daemon (`VectorServiceClient.INSTANCE.getLegacyModules()`) to retrieve the list of enabled APK paths. Modules are not loaded using standard Android mechanism. To prevent detection via `ClassLoader.getParent()` chain-walking and to eliminate residual file descriptors, `XposedInit.loadModule` utilizes `VectorModuleClassLoader`. This classloader loads the module APK directly into memory, isolating the module's execution environment from the host application's classpath. diff --git a/native/src/core/native_api.cpp b/native/src/core/native_api.cpp index e82361041..308b5a5e9 100644 --- a/native/src/core/native_api.cpp +++ b/native/src/core/native_api.cpp @@ -143,9 +143,9 @@ void RegisterNativeLib(const std::string &library_name) { } std::lock_guard lock(g_module_registry_mutex); - // The dlopen hook walks this list without stopping at the first match, so a name recorded twice - // means native_init runs twice for one library. Hot reload registers a module's names again for - // every new generation, which is exactly how that happens. + // The list is walked on every dlopen in the process and never shrinks - there is no + // unregistration, and hot reload records a module's names again for each new generation - so + // without this it grows without bound and every dlopen pays for the duplicates. if (std::find(g_module_native_libs.begin(), g_module_native_libs.end(), library_name) != g_module_native_libs.end()) { LOGD("Native module library '{}' is already registered.", library_name.c_str()); diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/HotReloadOutcome.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/HotReloadOutcome.aidl index 22ccba901..3a7d402ca 100644 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/HotReloadOutcome.aidl +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/HotReloadOutcome.aidl @@ -14,9 +14,10 @@ parcelable HotReloadOutcome { int status; /** - * The framework's diagnostic. Null only for a module refusal, because that is the - * encoding {@code HotReloadResult} reserves for one: FAILED with a null message means - * {@code onHotReloading} returned false, and nothing else may claim it. + * The framework's diagnostic, or null when there is nothing to say - which is success, and + * exactly one kind of failure. {@code HotReloadResult} reserves FAILED-with-a-null-message for a + * module refusal: it means {@code onHotReloading} returned false, and no other failure may + * present that way. */ String message; diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl index 447f0a307..2d74b199e 100644 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl @@ -6,8 +6,11 @@ import org.matrix.vector.ipc.IProcessChannel; /** * What an injected process asks the framework for, once the zygisk handshake has given it one. * - *

The daemon authenticates every call here by {@code Binder.getCallingUid()/getCallingPid()} - * against the process registry the handshake built, so a caller can only ever act as itself.

+ *

Every call that answers with something about the caller - its modules, its preference path, the + * manager - is authenticated by {@code Binder.getCallingUid()/getCallingPid()} against the process + * registry the handshake built, so a caller can only ever act as itself. {@link #isLogMuted} is the + * exception and is deliberately unauthenticated: it discloses one global boolean about log + * verbosity, and it is asked before a process has anything to be authenticated as.

* *

Transaction ids are implicit in this file. A new method must be appended; inserting one * anywhere above renumbers every method after it, and the daemon and the injected processes are @@ -44,12 +47,12 @@ interface IFrameworkService { @nullable ParcelFileDescriptor openManagerApk(); /** - * The manager's service binder, if this process is the one that should host the manager. + * The manager's service binder, if this process is the one hosting the manager. * - *

Null for every other process, which is nearly all of them. Asking is how a process finds - * out, and asking is not free of consequence: the daemon decides here that this process - * is the host, so a process that asks and then does not go on to host the manager has taken - * the slot from whichever one would have.

+ *

Null for every other process, which is nearly all of them. This only reports a decision + * already taken - the daemon recorded which pid it was launching the manager into when it + * started it, and this compares the caller against that - so asking is free and a process that + * asks and then does nothing has cost nothing.

*/ @nullable IBinder requestManagerService(); diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl index 09bb34e00..fb31b92a1 100644 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl @@ -6,10 +6,11 @@ import org.matrix.vector.ipc.IHotReloadOutcomeReceiver; /** * The one thing the daemon calls into an injected process for. * - *

Every other interface here points the other way; this is the only reverse channel, and it is - * deliberately not a general control surface. A hooked process runs as the app, so anything broader - * would be reachable by the app itself. The process side additionally checks that the caller is the - * daemon.

+ *

One of two interfaces that point this way - {@link IRemotePreferenceCallback} is the other, + * and it carries nothing but preference diffs. This one drives a module's lifecycle, so it is + * deliberately not a general control surface: a hooked process runs as the app, and anything + * broader here would be reachable by the app itself. The process side additionally checks that the + * caller is the daemon.

* *

Handed to the daemon by {@link IFrameworkService#attachProcessChannel} while the framework * bootstraps, before any module has loaded and carrying no module identity at all - which is what diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl index ea839d35b..9a7f0a1d5 100644 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl @@ -19,7 +19,10 @@ parcelable LoadedModule { int appId; /** - * The module app's version code, as PackageManager reports it. + * The module app's version code, as PackageManager reports it - or 0 when it was not available. + * system_server is served its modules before PackageManager is published, so its targets start + * at 0 and the daemon backfills them once the module cache is built; 0 therefore means unknown + * rather than old, and no target is reported STALE on the strength of it. * *

This is what tells a running target apart from what is installed: a process still running * the code of an older version code is {@code STALE}, and is what a hot reload exists to bring diff --git a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ModuleCode.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ModuleCode.aidl index faa7faa42..deb2767b8 100644 --- a/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ModuleCode.aidl +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ModuleCode.aidl @@ -32,7 +32,9 @@ parcelable ModuleCode { boolean legacy; /** - * module.prop {@code targetApiVersion}, or 0 for a legacy module, which declares none. + * module.prop {@code targetApiVersion}, verbatim, or 0 when the key is absent or unparseable. + * A legacy module usually has none, but one that declares a value below 101 keeps it - {@link + * #legacy} is what says how the module is loaded, not this. * *

Carried into the process because one of API 102's rules is only enforceable there: a * module targeting 102 or higher must not be able to resolve the legacy 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 e34d61add..258e59ae3 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 @@ -75,9 +75,8 @@ object VectorModuleManager { } // Native entry points are recorded by buildGeneration, which has to do it before the entry - // classes run. Doing it again here would put every library name in the dlopen hook's list - // twice, and that list is walked without a break - a matching library would have its - // native_init called once per duplicate. + // classes run. Recording them again here would put every library name in the list the dlopen + // hook walks twice over, and that list never shrinks. Log.d(TAG, "Loaded module ${module.packageName} successfully.") return true @@ -176,6 +175,19 @@ object VectorModuleManager { .onFailure { e -> Log.e(TAG, "Failed to instantiate class $className", e) } } + // A generation with nothing in it is not a generation. Every entry class can fail to + // instantiate - a constructor that throws, a class that does not extend XposedModule - + // and each of those is logged and skipped above, which used to leave an empty list that + // every caller then treated as success: the initial load reported the module loaded, + // and a hot reload committed the empty generation, never called onHotReloaded, never + // unhooked the old hooks, and answered SUCCEEDED while the process went on running the + // previous generation. The module was then wedged, because the committed generation had + // no live entry for any later reload to hand over to. + if (entries.isEmpty()) { + Log.e(TAG, "No entry class of ${module.packageName} could be instantiated") + return null + } + val generation = Generation(moduleClassLoader, vectorContext, entries, isSystemServer, processName) return generation to entries diff --git a/zygisk/README.md b/zygisk/README.md index b3ddf34b6..0da0126d6 100644 --- a/zygisk/README.md +++ b/zygisk/README.md @@ -22,15 +22,15 @@ The `system_server` acts as the primary proxy router for the framework. During t 1. The native module queries `ServiceManager` for the `serial` service (or `serial_vector` for late-inject scenarios). This service acts as a temporary rendezvous point. 2. The module sends a `_VEC` transaction to retrieve a temporary binder, which it uses to fetch the framework DEX file descriptor and the obfuscation map. 3. The module installs the JNI Binder Trap (`HookBridge`) and bootstraps the Kotlin layer via `Main.forkCommon`. -4. Concurrently, the root daemon initiates a Binder transaction directly to the `system_server`. The JNI trap intercepts this, and BridgeService processes the `SEND_BINDER` action, storing the daemon's primary `IDaemonService` binder, sending back `system_server` context and linking a `DeathRecipient`. +4. Concurrently, the root daemon initiates a Binder transaction directly to the `system_server`. The JNI trap intercepts this, and BridgeService processes the `SEND_BINDER` action, storing the daemon's primary `IVectorDaemon` binder, sending back `system_server` context and linking a `DeathRecipient`. ### Phase 2: User Application Rendezvous Standard applications initialize their IPC connection by routing requests through the `system_server`. 1. In `postAppSpecialize`, the application queries `ServiceManager` for the `activity` service (which resides in `system_server`). 2. The application sends a `_VEC` transaction containing the `GET_BINDER` action, its process name, and a newly allocated heartbeat `BBinder`. 3. The JNI trap inside `system_server` intercepts this transaction before the Activity Manager processes it. -4. The `system_server`'s BridgeService forwards the application's UID, PID, and heartbeat binder to the root daemon via the `IDaemonService` binder acquired in Phase 1. -5. The daemon evaluates the request against its internal scope state. If approved, it generates an `ILSPApplicationService` binder and returns it to the `system_server`, which writes it back to the waiting application's reply parcel. +4. The `system_server`'s BridgeService forwards the application's UID, PID, and heartbeat binder to the root daemon via the `IVectorDaemon` binder acquired in Phase 1. +5. The daemon evaluates the request against its internal scope state. If approved, it generates an `IFrameworkService` binder and returns it to the `system_server`, which writes it back to the waiting application's reply parcel. 6. The application uses this dedicated binder to fetch its specific framework DEX and obfuscation map. ### The Heartbeat Mechanism diff --git a/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt b/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt index ffb975604..79e4db7b2 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/ParasiticManagerHooker.kt @@ -475,8 +475,8 @@ object ParasiticManagerHooker { @JvmStatic fun start(): Boolean { return try { - // Claimed first, because asking for it is what makes this process the manager's host; - // there is no point opening the APK for a process that was not given the service. + // Asked first: a process the daemon did not launch the manager into gets null here, + // and there is no point opening the APK for it. val managerBinder = VectorServiceClient.requestManagerService() ?: return false VectorServiceClient.openManagerApk()!!.use { pfd -> val managerService = ILSPManagerService.Stub.asInterface(managerBinder) From 2b8870ac97a4f540767fd2a5225af4da84139c12 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 16:21:30 +0200 Subject: [PATCH 12/13] Let a module outside user 0 give back the system scope it took setModuleScope normalises a framework scope row to user 0 whoever asked for it, because system_server is one process for the whole device and a module in a work profile hooking it is hooking the same one as everyone else. removeModuleScope did not normalise - it refused outright for any user but 0 - so a module outside user 0 could take system scope and never give it back. The bad path is IXposedService.removeScope, which returns void: the module asked, nothing happened, and it was told nothing. Through the CLI it is at least visible as "removed 0 apps". On a device: cli scope rm org.matrix.hrmodule system/11 -> removed 0 apps cli scope rm org.matrix.hrmodule system/0 -> removed 1 apps Normalising on the way out, the same way the write normalises on the way in, is all it needed. The notification-approval path had the mirror image of the same confusion: it tested whether the scope was already granted by comparing the requesting user against the stored row, which for "system" is always 0, so the test never matched. Every approval appended a duplicate and rewrote the whole scope table. setModuleScope's normalisation and CONFLICT_IGNORE collapsed it again, so nothing was corrupted - the check was simply dead for the one package it matters most for. Also corrects why addressableBy carves out the AID_* uids. The reason is not that system_server is special; it is that a module in any user may hold this scope and the row records none of them, so nothing downstream can tell which user asked, and every user holding the module is equally entitled to the one generation loaded there. --- .../org/matrix/vector/daemon/VectorService.kt | 8 ++++++-- .../org/matrix/vector/daemon/data/ModuleDatabase.kt | 10 ++++++++-- .../matrix/vector/daemon/ipc/ApplicationService.kt | 13 ++++++++----- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt index 162402646..53fbef318 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt @@ -449,11 +449,15 @@ object VectorService : IVectorDaemon.Stub() { when (action) { "approve" -> { val scopes = ModuleDatabase.getModuleScope(packageName) ?: mutableListOf() - if (scopes.none { it.packageName == scopePackageName && it.userId == userId }) { + // Compared against where the row will land, not against the user who asked: the + // framework is stored under user 0 whoever requested it, so for "system" this test + // never matched and every approval appended a duplicate and rewrote the whole table. + val storedUserId = if (scopePackageName == "system") 0 else userId + if (scopes.none { it.packageName == scopePackageName && it.userId == storedUserId }) { scopes.add( Application().apply { this.packageName = scopePackageName - this.userId = userId + this.userId = storedUserId }) ModuleDatabase.setModuleScope(packageName, scopes) } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt index a71781d1a..4133c827b 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt @@ -321,7 +321,13 @@ object ModuleDatabase { } fun removeModuleScope(packageName: String, scopePackageName: String, userId: Int): Boolean { - if (packageName == "lspd" || (scopePackageName == "system" && userId != 0)) return false + if (packageName == "lspd") return false + // Normalised the same way [setModuleScope] normalises it, rather than refused. The framework is + // stored against user 0 whoever asked for it, so a removal keyed on the caller's own user + // matches no row at all - which meant a module outside user 0 could take system scope and then + // never give it back. It reached that state through the very same call: removeScope returns + // nothing, so the module was told its request had been honoured. + val storedUserId = if (scopePackageName == "system") 0 else userId val db = dbHelper.writableDatabase val mid = db.compileStatement("SELECT mid FROM modules WHERE module_pkg_name = ?") @@ -330,7 +336,7 @@ object ModuleDatabase { db.delete( "scope", "mid = ? AND app_pkg_name = ? AND user_id = ?", - arrayOf(mid.toString(), scopePackageName, userId.toString())) + arrayOf(mid.toString(), scopePackageName, storedUserId.toString())) ConfigCache.requestCacheUpdate() return true } 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 eebf12c8d..7dd7b7d42 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 @@ -98,11 +98,14 @@ object ApplicationService : IFrameworkService.Stub() { * The same module installed for two users is two module apps with two sets of preferences, and * neither has any business reloading the other's processes. * - * The carve-out is for the AID_* uids below [FIRST_APPLICATION_UID], not for user 0: system_server - * runs once for the whole device and carries a module enabled in any user, so scoping it to user 0 - * would make it unreachable from every other one. Testing `uid < PER_USER_RANGE` instead would - * admit the whole of user 0 - every app process on a single-user device - which is the opposite of - * what this is for. + * The carve-out is for the AID_* uids below [FIRST_APPLICATION_UID], and it has to be: a module in + * any user may take the framework into its scope, and `ModuleDatabase.setModuleScope` stores that + * row against user 0 whoever asked for it, because system_server is one process for the whole + * device. Nothing downstream can therefore tell which user requested it - so every user holding + * the module is equally entitled to the one generation loaded there. + * + * Not `uid < PER_USER_RANGE`, which is what this said first: that admits the whole of user 0, + * every app process on a single-user device, and leaves the check doing nothing at all. */ private fun addressableBy(target: HotReloadTarget, userId: Int): Boolean = target.uid < FIRST_APPLICATION_UID || target.uid / PER_USER_RANGE == userId From 1e595f3cf8830b2e44cc01434e6f92ac1ebbed9b Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Mon, 3 Aug 2026 18:03:42 +0200 Subject: [PATCH 13/13] Keep a module inside the users that installed it A module is one package and one APK for the whole device, so the configuration keys it by package alone: one enabled flag, one scope set, and a user id on each scope row naming which instance of the target app it points at. Nothing checked that the module itself existed for that user, so a row was expanded on the strength of the target resolving and the module went wherever it pointed. Reproduced on a device: a module installed for user 11 alone loaded into and hooked a user 0 app. The rebuild now records which users hold each module and refuses to expand a row into a user that does not. The framework is exempt, and has to be: system_server is one process for the whole device belonging to no user, its row is stored under user 0 whoever asked for it, and every user holding the module is equally entitled to it. Which users hold it has to be asked separately. MATCH_ALL_FLAGS carries MATCH_ANY_USER and MATCH_UNINSTALLED_PACKAGES, so getPackageInfoCompat answers for a user that does not hold the package - deliberately, because answering for every user is what distinguishes "no user has this any more", which deletes the configuration, from "not in this user", which must not. Nor does the uid in the answer help: the ApplicationInfo is generated for the user asked about, so a module held only by users 11 and 12 still reports 10136 for user 0. isPackageAvailable is the per-user installed state and answers correctly, and hidden counts as held so a locked private space keeps its modules. Two things the same scenario exposed. A holder now wins the ApplicationInfo, so the data directory the module is handed is one that exists - which means appId is read from a secondary user's uid, and it is stored modulo the user range because every reader compares it against someUid % PER_USER_RANGE. Otherwise a module held only by user 11 would fail its own authentication in ensureModule and never be sent its binder. And the system_server path read the module's uid off /data/user_de/0, which such a module does not have, so it started life with an app id of -1 and data paths pointing at nothing; it now looks for the directory that exists. getScope now answers with the caller's user plus the framework row. requestScope asks for the caller's user and removeScope gives back the caller's user, so returning every row showed a copy in user 11 packages in user 0 it could neither have asked for nor give back. The self-scope and legacy-self-scope expansions walked every user on the device to build uids that could never start a process. They walk the users holding the module instead. --- .../matrix/vector/daemon/data/ConfigCache.kt | 123 ++++++++++++++++-- .../vector/daemon/data/ModuleDatabase.kt | 13 +- .../vector/daemon/ipc/ApplicationService.kt | 6 +- .../matrix/vector/daemon/ipc/ModuleService.kt | 24 +++- .../ui/screens/modules/ScopeViewModel.kt | 10 +- 5 files changed, 149 insertions(+), 27 deletions(-) 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 04a6b9e93..ca909dc13 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 @@ -153,6 +153,15 @@ object ConfigCache { val newModules = mutableMapOf() val newStaticScopes = mutableMapOf>() + + // Which users actually hold each module, which is what bounds where it may be injected. + // + // A module is one package and one APK for the whole device — Android has no way to hold two + // different builds under one package name — so the configuration keys it by package alone: + // one enabled flag, one scope set. What genuinely varies per user is whether that package is + // installed at all, and its uid and data directory when it is. This map is that dimension, + // and the scope expansion below refuses to cross it. + val moduleUsers = mutableMapOf>() // Deleted from the configuration: the package is not installed for any user, so what it was // configured to do cannot mean anything. val obsoleteModules = mutableSetOf() @@ -181,10 +190,40 @@ object ConfigCache { TAG, "No users available; skipping this rebuild rather than assuming nothing exists") return } - for (user in users) { - pkgInfo = packageManager?.getPackageInfoCompat(pkgName, MATCH_ALL_FLAGS, user.id) - if (pkgInfo?.applicationInfo != null) break + // Every user, not the first one that answers, because which users hold the module is what + // keeps it out of a user that never installed it. + // + // Whether the query answered is not whether this user holds the package. [MATCH_ALL_FLAGS] + // carries MATCH_ANY_USER and MATCH_UNINSTALLED_PACKAGES, deliberately - answering for + // every user is what tells "no user has this any more", which deletes the configuration, + // from "not in this user", which must not. So asking about user 0 for a module only user + // 11 holds returns the package, and a boundary built on that admitted every user and + // enforced nothing. + // + // Nor is the uid in the answer a discriminator, which is the other thing it looks like: the + // ApplicationInfo is generated for the user that was *asked about*. Measured on a device, + // for a module held only by users 11 and 12, the three queries returned 10136, 1110136 and + // 1210136 - user 0 included, though user 0 does not have it. + // + // `isPackageAvailable` is the per-user installed state and answers correctly: false, true, + // true for the same module, and the exact inverse for one installed only for user 0. It is + // the same test the target resolution below has always used. Hidden counts as held, because + // a locked private space hides its apps without ceasing to hold them. + // + // A holder wins the ApplicationInfo, so the data directory the module is handed is one + // that exists; the lowest user id among them, so it stays put as other users come and go. + var anyPkgInfo: android.content.pm.PackageInfo? = null + for (user in users.sortedBy { it.id }) { + val info = packageManager?.getPackageInfoCompat(pkgName, MATCH_ALL_FLAGS, user.id) + if (info?.applicationInfo == null) continue + if (anyPkgInfo == null) anyPkgInfo = info + if (packageManager?.isPackageAvailable(pkgName, user.id, true) != true) continue + moduleUsers[pkgName] = moduleUsers[pkgName].orEmpty() + user.id + if (pkgInfo == null) pkgInfo = info } + // Nothing held it, but something answered: still installed somewhere as far as the package + // manager is concerned, so it is not obsolete and must not have its configuration deleted. + if (pkgInfo == null) pkgInfo = anyPkgInfo // Gone, not broken. No user has this package any more, so the configuration for it is // meaningless and is cleaned up. This is the only case that deletes anything. @@ -204,7 +243,14 @@ object ConfigCache { apkPath == oldModule.apkPath && File(appInfo.sourceDir).parent == File(apkPath).parent) { - if (oldModule.appId == -1) oldModule.applicationInfo = appInfo + // -1 is what `getModulesForSystemServer` leaves behind when it could not stat the + // module's data directory before the package manager existed. This is the first point at + // which the real answer is available, so both halves of it are filled in — the appId as + // well as the ApplicationInfo, which is what identifies the module to itself. + if (oldModule.appId == -1) { + oldModule.applicationInfo = appInfo + oldModule.appId = appInfo.uid % PER_USER_RANGE + } // This path skips re-reading the APK, so what the module claims has to be carried // over; the new map replaces the old one wholesale and would otherwise lose it. staticScopes[pkgName]?.let { newStaticScopes[pkgName] = it } @@ -232,7 +278,15 @@ object ConfigCache { LoadedModule().apply { packageName = pkgName this.apkPath = apkPath - appId = appInfo.uid + // An app id, as the name says, not the uid it is read from. Every reader compares + // it against `someUid % PER_USER_RANGE`, and the raw uid only agreed with that + // while the ApplicationInfo came from user 0 — which it did by luck, user 0 + // being first in the list and answering for packages it does not even hold. + // The resolution above now deliberately prefers a *holder*, so for a module only + // user 11 has this reads 1110136, and without the modulo the module would fail + // its own authentication in `ModuleService.ensureModule` against a caller's + // 10136 and never be sent its binder. + appId = appInfo.uid % PER_USER_RANGE versionCode = pkgInfo.longVersionCode applicationInfo = appInfo service = oldModule?.service ?: InjectedModuleService(pkgName) @@ -288,12 +342,27 @@ object ConfigCache { val userId = scopeRow.userId val module = newModules[modPkg] ?: return@forEach + val holders = moduleUsers[modPkg].orEmpty() if (appPkg == "system") { - addToScope("system_server", 1000, module) + // system_server is one process for the whole device and belongs to no user, so any user + // holding the module may hook it and the row is stored under user 0 whoever asked. It is + // the one target the boundary below does not apply to, because there is no second copy + // of it to keep a module out of. + if (holders.isNotEmpty()) addToScope("system_server", 1000, module) return@forEach } + // The user boundary. A row names one app instance, and reaching it means running the + // module's code in that user — so a user that never installed the module is not somewhere + // its rows may take it. A module held only by user 11 stays out of user 0's processes even + // when a row points at one. + // + // Nothing enforced this before. The row was expanded on the strength of the *target* + // resolving for that user, and the module followed wherever it pointed; a module installed + // for user 11 alone was observed loading into and hooking a user 0 app. + if (userId !in holders) return@forEach + val pkgInfo = packageManager?.getPackageInfoWithComponents(appPkg, MATCH_ALL_FLAGS, userId) if (pkgInfo?.applicationInfo == null) return@forEach @@ -305,10 +374,14 @@ object ConfigCache { for (processName in processNames) { addToScope(processName, appUid, module) + // A module in its own scope hooks itself in every user that has it — the copies share + // one APK, so what one copy hooks in itself the others may expect too. Over the users + // holding the module rather than over every user on the device: a uid in a user without + // the package names no process that can ever start, so it was only ever dead weight. if (modPkg == appPkg) { val appId = appUid % PER_USER_RANGE - userManager?.getRealUsers()?.forEach { user -> - val moduleUid = user.id * PER_USER_RANGE + appId + holders.forEach { holder -> + val moduleUid = holder * PER_USER_RANGE + appId if (moduleUid != appUid) addToScope(processName, moduleUid, module) } } @@ -326,10 +399,12 @@ object ConfigCache { newModules.values .filter { it.code?.legacy == true } .forEach { module -> - userManager?.getRealUsers()?.forEach { user -> + // The users holding it, for the same reason as the self-scope above: the other users + // have no copy for the module to report itself active in. + moduleUsers[module.packageName].orEmpty().forEach { userId -> val pkgInfo = packageManager?.getPackageInfoWithComponents( - module.packageName, MATCH_ALL_FLAGS, user.id) ?: return@forEach + module.packageName, MATCH_ALL_FLAGS, userId) ?: return@forEach val moduleUid = pkgInfo.applicationInfo?.uid ?: return@forEach pkgInfo.fetchProcesses().forEach { processName -> addToScope(processName, moduleUid, module) @@ -389,6 +464,25 @@ object ConfigCache { fun getModuleByUid(uid: Int): LoadedModule? = state.modules.values.firstOrNull { it.appId == uid % PER_USER_RANGE } + /** + * A module's device-protected data directory, found by looking rather than by assuming user 0. + * + * This runs while system_server is starting, so there is no package manager to ask and the + * directory the installer made is the only record of the module on disk. A module installed for a + * secondary user alone has no `/data/user_de/0` entry, so hardcoding that one both left the + * module's paths pointing at nothing and made its app id -1 — which then travelled into + * `ApplicationInfo.uid` as the identity of a module about to be loaded into the system server. + * + * Lowest user id first, so the owner's copy wins when there is one. + */ + private fun resolveModuleDataDir(pkgName: String): String? { + val userDirs = FileSystem.toGlobalNamespace("/data/user_de").listFiles() ?: return null + return userDirs + .sortedBy { it.name.toIntOrNull() ?: Int.MAX_VALUE } + .map { FileSystem.toGlobalNamespace("/data/user_de/${it.name}/$pkgName").absolutePath } + .firstOrNull { runCatching { Os.stat(it) }.isSuccess } + } + fun getModulesForSystemServer(): List { val modules = mutableListOf() if (!android.os.SELinux.checkSELinuxAccess( @@ -413,12 +507,17 @@ object ConfigCache { return@forEach } - val statPath = FileSystem.toGlobalNamespace("/data/user_de/0/$pkgName").absolutePath + val statPath = + resolveModuleDataDir(pkgName) + ?: FileSystem.toGlobalNamespace("/data/user_de/0/$pkgName").absolutePath val module = LoadedModule().apply { packageName = pkgName this.apkPath = apkPath - appId = runCatching { Os.stat(statPath).st_uid }.getOrDefault(-1) + // An app id, matching what the rebuild stores, so it means the same thing + // whichever user's directory answered above. + appId = + runCatching { Os.stat(statPath).st_uid % PER_USER_RANGE }.getOrDefault(-1) service = InjectedModuleService(pkgName) } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt index 4133c827b..1e3793427 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ModuleDatabase.kt @@ -269,9 +269,16 @@ object ModuleDatabase { val values = ContentValues().apply { put("mid", mid) } for (app in scope) { - // The system server is one process for the whole device, so it is stored against user 0 - // whoever asked for it — a module in a work profile hooking the framework is hooking the - // same system_server as everyone else. + // A module is one package, one APK and one scope set for the whole device — Android cannot + // hold two different builds under one package name, so there is nothing here to key by + // user. What [Application.userId] names is the *target*: which installed instance of + // [Application.packageName] this row points at. `ConfigCache` refuses to expand a row whose + // user does not hold the module, which is what keeps a module installed for one user out of + // another user's processes. + // + // The system server is the exception and is stored against user 0 whoever asked for it: it + // is one process for the whole device belonging to no user, so a module in a work profile + // hooking the framework is hooking the same system_server as everyone else. // // Normalised rather than dropped, which is what this used to do. Dropping meant restoring // a backup written by an older manager, which recorded the framework under the module's 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 7dd7b7d42..247f66cab 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 @@ -95,8 +95,10 @@ object ApplicationService : IFrameworkService.Stub() { /** * Whether [userId]'s copy of the module may address [target]. * - * The same module installed for two users is two module apps with two sets of preferences, and - * neither has any business reloading the other's processes. + * One module is one package and one APK, but the copies installed for two users are two apps with + * two uids and two sets of preferences, and neither has any business reloading the other's + * processes. `ConfigCache` draws the same line when it decides where a module may be injected at + * all; this is that boundary applied to reloading what is already there. * * The carve-out is for the AID_* uids below [FIRST_APPLICATION_UID], and it has to be: a module in * any user may take the framework into its scope, and `ModuleDatabase.setModuleScope` stores that 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 afcd5357d..31c0a56a9 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 @@ -167,11 +167,19 @@ class ModuleService(private val loadedModule: LoadedModule) : IXposedService.Stu } override fun getScope(): List { - ensureModule() - // The scope table has one row per (app, user), so a module enabled for several users saw the - // same package repeatedly. A scope is a set of package names. - return ModuleDatabase.getModuleScope(loadedModule.packageName)?.map { it.packageName }?.distinct() - ?: emptyList() + val userId = ensureModule() + // The caller's own user, and the framework row that belongs to none. The scope set is one set + // for the whole module, but the other two calls on this interface are not: [requestScope] asks + // for the caller's user and [removeScope] gives back the caller's user. Returning every row + // meant a copy in user 11 was shown user 0's packages, which it could neither have asked for + // nor give back - the removal is keyed on its own user and would match nothing. + // + // The scope table has one row per (app, user), so a module held by several users saw the same + // package repeatedly. A scope is a set of package names. + return ModuleDatabase.getModuleScope(loadedModule.packageName) + ?.filter { it.userId == userId || it.packageName == "system" } + ?.map { it.packageName } + ?.distinct() ?: emptyList() } override fun requestScope(packages: List, callback: IXposedScopeCallback) { @@ -216,8 +224,10 @@ class ModuleService(private val loadedModule: LoadedModule) : IXposedService.Stu override fun hotReloadModule(targetId: Long, data: Bundle?, callback: IHotReloadCallback?) { // The user id matters as much as the app id here: ensureModule only proves the caller shares - // the module's appId, and the same module installed for two users is two separate module apps. - // Without this, the copy in user 10 could reload user 0's processes. + // the module's app id, which every copy of it does. The copies are one module and one APK, but + // they are separate apps with separate uids and separate preferences, and the boundary that + // keeps a module out of a user that never installed it applies to reloading too. Without this, + // the copy in user 11 could reload user 0's processes. val userId = ensureModule() // SecurityException is reserved by the AIDL for exactly these two conditions, so it must not be // raised for anything else on this path - a module-thrown SecurityException in particular has diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt index b18fca4eb..3ec5a4dc2 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeViewModel.kt @@ -72,9 +72,13 @@ data class ScopeUiState( class ScopeViewModel( private val modulePackageName: String, /** - * The user the module is installed for. It has to travel with the package name: the same - * module in a work profile is a different copy with a different scope, and resolving it - * against the owner would edit the wrong one. + * The user whose copy of the module was opened, and so whose apps this screen offers. + * + * Not a second scope. A module is one package and one APK for the whole device and has one + * scope set; what varies per user is which apps exist to point at, and the daemon will not + * expand a row for a user that does not hold the module. So this selects the half of the + * device being edited — [apply] merges into the whole stored set rather than replacing it, + * which is what leaves another user's rows alone. */ private val userId: Int, private val daemonClient: DaemonClient,