diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java index 3df1a1c3a..56dd2b7bc 100644 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java +++ b/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java @@ -642,7 +642,7 @@ public void onLoadCleared(@Nullable Drawable placeholder) { } }); holder.appVersion.setVisibility(View.VISIBLE); - holder.appVersion.setText(item.versionName); + holder.appVersion.setText(formatVersion(item)); holder.appVersion.setSelected(true); } else { holder.itemView.setTag(item); @@ -652,6 +652,19 @@ public void onLoadCleared(@Nullable Drawable placeholder) { } } + /** + * The module version line, with the Xposed API the module was built against. Which API a + * module targets decides how the framework loads it, so it belongs next to the version + * rather than only in the warning that fires when it is out of range. + */ + private String formatVersion(ModuleUtil.InstalledModule item) { + if (item.targetVersion <= 0) return item.versionName; + var api = item.minVersion > 0 && item.minVersion != item.targetVersion + ? getString(R.string.module_api_version_range, item.minVersion, item.targetVersion) + : getString(R.string.module_api_version, item.targetVersion); + return item.versionName + " · " + api; + } + @Override public void onViewRecycled(@NonNull ViewHolder holder) { holder.itemView.setTag(null); diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2f3fc89c9..5616471fb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -37,8 +37,7 @@ %d 个模块可更新 加入我们的 %2$s 频道
加入我们的 QQ 频道]]>
- LSPosed -JingMatrix + LSPosed、JingMatrix 安装 点击安装 LSPosed 未安装 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 82de0478d..c77fce0ce 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -37,7 +37,7 @@ %d 個模組可更新 加入我們的 %2$s 頻道]]> - 孟武. 尼德霍格. 龍、david082321、beigua87 + 孟武. 尼德霍格. 龍、david082321、beigua87、HSSkyBoy 安裝 點選安裝 LSPosed 未安裝 @@ -138,9 +138,13 @@ 模組 作用域列表儲存失敗 版本:%1$s + 選擇 推薦程式 未選擇任何程式,選擇推薦的程式? 選擇推薦的程式? + 全部 + + 自動加入 Xposed 模組尚未啟用 推薦啟用 可用更新:%1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d3c4dbb2..064d2e8d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,6 +57,8 @@ Tips for module developer Please disable deploy optimizations on Android Studio, or use `gradlew installDebug` command to install. Otherwise the module apk will not be updated. API version + API %1$d + API %1$d–%2$d Framework version Manager package name System version diff --git a/daemon/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 cfeb9d107..e8b57f62c 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 @@ -16,7 +16,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 @@ -187,6 +189,7 @@ object ConfigCache { packageName = pkgName this.apkPath = apkPath appId = appInfo.uid + versionCode = pkgInfo?.longVersionCode ?: 0L applicationInfo = appInfo service = oldModule?.service ?: InjectedModuleService(pkgName) file = preLoadedApk @@ -259,6 +262,16 @@ object ConfigCache { state = oldState.copy(modules = newModules, scopes = newScopes) Log.d(TAG, "Cache Update Complete. Map Swap successful.") + + // After the swap, so a target is only dropped once the module is really gone. + (oldState.modules.keys - newModules.keys).forEach { + ApplicationService.forgetHotReloadTargets(it) + } + + ApplicationService.backfillLoadedVersions() + + // After the swap, so targets are asked 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}") } @@ -382,6 +395,7 @@ object ConfigCache { 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 5eeb7a1b9..14ee0ef8e 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -27,6 +27,7 @@ import java.nio.file.attribute.PosixFilePermissions import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.util.Properties import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipOutputStream @@ -160,6 +161,10 @@ object FileSystem { return memory } + // Matches ModuleUtil.extractIntPart in the manager, so the two cannot disagree on "101.0". + private fun leadingInt(value: String?): Int = + value?.trim()?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0 + /** Parses the module APK, extracts init lists, and loads DEXes into SharedMemory. */ fun loadModule(apkPath: String, obfuscate: Boolean): PreLoadedApk? { val file = File(apkPath) @@ -170,23 +175,31 @@ object FileSystem { val moduleClassNames = mutableListOf() val moduleLibraryNames = mutableListOf() var isLegacy = false + var targetApiVersion = 0 + var minApiVersion = 0 + var autoHotReload = false runCatching { ZipFile(file).use { zip -> - // Parse module.prop to get targetApiVersion + // module.prop is specified as Java Properties format. Parsing it by hand mishandles + // ':' as a separator, '!' comments, escapes and line continuations, and the manager + // app already reads the same file with Properties.load. val props = - zip.getEntry("META-INF/xposed/module.prop")?.let { entry -> - zip.getInputStream(entry).bufferedReader().useLines { lines -> - lines - .filter { it.contains("=") } - .associate { - val parts = it.split("=", limit = 2) - parts[0].trim() to parts[1].trim() - } + Properties().apply { + zip.getEntry("META-INF/xposed/module.prop")?.let { entry -> + // Properties.load rejects a malformed \uXXXX escape, which the old hand-rolled + // parser tolerated. Keep that tolerance: a bad module.prop must not make the + // APK unloadable, not least because a legacy module is selected by + // assets/xposed_init and needs no module.prop at all. + runCatching { zip.getInputStream(entry).use { load(it) } } + .onFailure { Log.w(TAG, "Malformed module.prop in $apkPath", it) } } - } ?: emptyMap() + } - val targetApi = props["targetApiVersion"]?.toIntOrNull() ?: 0 + val targetApi = leadingInt(props.getProperty("targetApiVersion")) + targetApiVersion = targetApi + minApiVersion = leadingInt(props.getProperty("minApiVersion")) + autoHotReload = props.getProperty("autoHotReload")?.trim().toBoolean() val hasLegacyFile = zip.getEntry("assets/xposed_init") != null // Determine Loading Strategy based on Priority: API 101+ > Legacy > API 100 @@ -263,6 +276,9 @@ object FileSystem { this.moduleClassNames = moduleClassNames this.moduleLibraryNames = moduleLibraryNames this.legacy = isLegacy + this.targetApiVersion = targetApiVersion + this.minApiVersion = minApiVersion + this.autoHotReload = autoHotReload } return preLoadedApk 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 c71715102..c2b8dfe55 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt @@ -4,6 +4,7 @@ import java.io.File import java.io.FileNotFoundException import java.io.IOException import org.lsposed.lspd.models.Application +import io.github.libxposed.service.IXposedService import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.CliRequest import org.matrix.vector.daemon.CliResponse @@ -40,6 +41,7 @@ object CliHandler { return mapOf( "Framework Version" to BuildConfig.VERSION_NAME, "Version Code" to BuildConfig.VERSION_CODE, + "API Version" to IXposedService.LIB_API, "Enabled Modules" to ConfigCache.state.modules.size, "Status Notification" to PreferenceStore.isStatusNotificationEnabled()) } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ModuleService.kt index 26d0a25bb..31bb4e2c9 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) } + } + } + } } /** @@ -140,6 +162,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 7c4c6ef37..a0f1a0003 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,6 +52,7 @@ appiconloader = { module = "me.zhanghai.android.appiconloader:appiconloader", ve material = { module = "com.google.android.material:material", version = "1.12.0" } gson = { module = "com.google.code.gson:gson", version = "2.14.0" } hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version = "6.1" } +libxposed-annotation = { module = "io.github.libxposed:annotation", version = "1.0.0" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } diff --git a/services/daemon-service/build.gradle.kts b/services/daemon-service/build.gradle.kts index e583a2570..7062e6ded 100644 --- a/services/daemon-service/build.gradle.kts +++ b/services/daemon-service/build.gradle.kts @@ -18,5 +18,6 @@ android { dependencies { compileOnly(libs.androidx.annotation) + compileOnly(libs.libxposed.annotation) compileOnly(projects.hiddenapi.stubs) } diff --git a/services/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 a9b5b9ef3..293f6dca3 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl @@ -5,4 +5,7 @@ parcelable PreLoadedApk { List moduleClassNames; List moduleLibraryNames; boolean legacy; + int targetApiVersion; + int minApiVersion; + boolean autoHotReload; } diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl new file mode 100644 index 000000000..35d34e767 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/IHotReloadTarget.aidl @@ -0,0 +1,20 @@ +package org.lsposed.lspd.service; + +import org.lsposed.lspd.models.HotReloadOutcome; +import org.lsposed.lspd.models.Module; + +/** + * Daemon-to-process entry point for hot reloading a module generation in place. + * + *

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

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

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

+ */ + HotReloadOutcome hotReload(String modulePackageName, in Bundle extras, in Module newModule) = 1; +} diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl index b85b6ed21..c8fa3c8cb 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl @@ -1,6 +1,7 @@ package org.lsposed.lspd.service; import org.lsposed.lspd.models.Module; +import org.lsposed.lspd.service.IHotReloadTarget; interface ILSPApplicationService { boolean isLogMuted(); @@ -12,4 +13,14 @@ interface ILSPApplicationService { String getPrefsPath(String packageName); ParcelFileDescriptor requestInjectedManagerBinder(out List binder); + + /** + * Registers this process's hot reload entry point. Called once while the framework bootstraps, + * independently of module loading, so that system_server - whose modules are loaded before the + * daemon's module cache exists - is a reloadable target like any other process. + * + *

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

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