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/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/VectorService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt index 86e4ee744..53fbef318 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 @@ -449,11 +449,15 @@ object VectorService : IDaemonService.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/ConfigCache.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt index b2700306f..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 @@ -14,10 +14,12 @@ 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 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 @@ -149,8 +151,17 @@ object ConfigCache { Log.d(TAG, "Executing Cache Update...") val oldState = state - val newModules = mutableMapOf() + 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() @@ -179,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. @@ -202,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 } @@ -227,13 +275,22 @@ 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 + // 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) - file = loaded.apk + code = loaded.apk } newModules[pkgName] = module } @@ -269,12 +326,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) } @@ -285,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 @@ -302,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) } } @@ -321,12 +397,14 @@ 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 -> + // 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) @@ -354,6 +432,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}") } @@ -365,7 +452,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") @@ -374,11 +461,30 @@ 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() + /** + * 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( "u:r:system_server:s0", "u:r:system_server:s0", "process", "execmem")) { Log.e(TAG, "Skipping system_server injection: sepolicy execmem denied") @@ -401,18 +507,24 @@ 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 = - Module().apply { + 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) } - 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 { @@ -431,7 +543,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. @@ -451,8 +563,8 @@ 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) { - val file = module.file ?: return + private fun stageNativeLibrariesFor(module: LoadedModule) { + 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/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 d61a95912..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 @@ -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,12 +234,14 @@ 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() var isLegacy = false var exceptionPassthrough = false + var targetApiVersion = 0 + var autoHotReload = false runCatching { ZipFile(file).use { zip -> @@ -258,7 +260,9 @@ object FileSystem { } } - val targetApi = props.getProperty("targetApiVersion")?.trim()?.toIntOrNull() ?: 0 + val targetApi = leadingInt(props.getProperty("targetApiVersion")) + targetApiVersion = targetApi + 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 +346,8 @@ object FileSystem { this.moduleLibraryNames = moduleLibraryNames this.legacy = isLegacy this.exceptionPassthrough = exceptionPassthrough + this.targetApiVersion = targetApiVersion + this.autoHotReload = autoHotReload } return ModuleLoad.Loaded(preLoadedApk) @@ -602,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()) } } @@ -669,4 +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/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..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 @@ -321,7 +328,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 +343,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 fb4f817e8..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 @@ -6,11 +6,17 @@ 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 org.lsposed.lspd.models.Module -import org.lsposed.lspd.service.ILSPApplicationService +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +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.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 @@ -24,14 +30,36 @@ 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) 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: IProcessChannel? = null + init { heartBeat.linkToDeath(this, 0) processes[key] = this @@ -40,9 +68,149 @@ 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.code.moduleClassNames.size == 1, + ) + id + } + } + } + + /** + * Whether [userId]'s copy of the module may address [target]. + * + * 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 + * 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 + + // 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, userId: Int): List { + val installedVersion = ConfigCache.state.modules[modulePackageName]?.versionCode + return hotReloadTargets.values + .filter { it.modulePackageName == modulePackageName && addressableBy(it, userId) } + .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, userId: Int): HotReloadTarget? = + hotReloadTargets[targetId]?.takeIf { + it.modulePackageName == modulePackageName && addressableBy(it, userId) + } + + /** + * 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) { + 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): IProcessChannel? = + processes[ProcessKey(target.uid, target.pid)]?.hotReloadBinder + + 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 = 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 { when (code) { DEX_TRANSACTION_CODE -> { @@ -87,7 +255,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() @@ -98,9 +266,10 @@ object ApplicationService : ILSPApplicationService.Stub() { return ConfigCache.getModulesForProcess(info.processName, info.key.uid) } - override fun getModulesList() = getAllModules().filter { !it.file.legacy } + 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 @@ -109,17 +278,8 @@ object ApplicationService : ILSPApplicationService.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()) @@ -129,4 +289,15 @@ object ApplicationService : ILSPApplicationService.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 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/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/InjectedModuleService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/InjectedModuleService.kt index 3765b7940..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 @@ -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. @@ -66,7 +66,7 @@ class InjectedModuleService(private val packageName: String) : ILSPInjectedModul .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) : ILSPInjectedModul 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/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 5bbeb3798..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 @@ -7,29 +7,47 @@ import android.os.Bundle import android.os.ParcelFileDescriptor import android.os.RemoteException import android.util.Log +import io.github.libxposed.service.HookedProcess +import io.github.libxposed.service.IHotReloadCallback import io.github.libxposed.service.IXposedScopeCallback import io.github.libxposed.service.IXposedService import java.io.Serializable import java.util.Collections import java.util.WeakHashMap import java.util.concurrent.ConcurrentHashMap -import org.lsposed.lspd.models.Module +import java.util.concurrent.CountDownLatch +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.IHotReloadOutcomeReceiver import org.matrix.vector.daemon.BuildConfig import org.matrix.vector.daemon.data.ConfigCache 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 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 + // delaying another. + 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()) + private val serviceMap = Collections.synchronizedMap(WeakHashMap()) fun uidClear() { uidSet.clear() @@ -38,7 +56,7 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { 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) } @@ -48,6 +66,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: LoadedModule) { + if (!module.code.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) } + } + } + } } /** @@ -137,11 +167,19 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { } 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) { @@ -179,6 +217,160 @@ class ModuleService(private val loadedModule: Module) : IXposedService.Stub() { } } + override fun getRunningTargets(): List { + val userId = ensureModule() + return ApplicationService.getHotReloadTargets(loadedModule.packageName, userId) + } + + 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 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 + // to reach the caller as a FAILED result, not as "invalid target id". + val target = + ApplicationService.getHotReloadTarget(targetId, loadedModule.packageName, userId) + ?: 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 + val answered = CountDownLatch(1) + var outcome: HotReloadOutcome? = 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 + } + 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 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.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 + message = "Process ${target.processName} is frozen and could not be thawed" + return + } + + val callbackStub = + object : IHotReloadOutcomeReceiver.Stub() { + override fun onOutcome(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 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 (answer.generationChanged) loadedVersion = newModule.versionCode + // A null message is reserved for a refusal, so anything else gets one supplied. + message = + answer.message + ?: if (status == IXposedService.HOT_RELOAD_FAILED && !answer.refused) { + "Hot reload failed without a diagnostic message" + } else { + null + } + } catch (t: Throwable) { + // 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() + 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/ipc/SystemServerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt index e53a5d4b4..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,19 +1,28 @@ package org.matrix.vector.daemon.ipc +import android.os.Binder import android.os.Build import android.os.IBinder 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.daemon.* import org.matrix.vector.daemon.system.getSystemServiceManager private const val TAG = "VectorSystemServer" -object SystemServerService : ILSPSystemServerService.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 : ILSPSystemServerService.Stub(), IBinder.DeathRecipi .onFailure { Log.e(TAG, "Failed to register proxy service `$serviceName`", it) } } - override fun requestApplicationService( + /** + * 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? - ): ILSPApplicationService? { - if (uid != 1000 || heartBeat == null || processName != "system") return null + processLifeToken: IBinder? + ): IFrameworkService? { + 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 : ILSPSystemServerService.Stub(), IBinder.DeathRecipi val uid = data.readInt() val pid = data.readInt() val processName = data.readString() ?: "" - val heartBeat = data.readStrongBinder() + val processLifeToken = data.readStrongBinder() - val service = requestApplicationService(uid, pid, processName, heartBeat) + val service = attachProcess(uid, pid, processName, processLifeToken) if (service != null) { reply?.writeNoException() reply?.writeStrongBinder(service.asBinder()) 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..09770e613 --- /dev/null +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/system/ProcessFreezer.kt @@ -0,0 +1,85 @@ +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 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 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(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 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(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 pid=$pid through ${file.path}") + return null + } + + Log.d(TAG, "Thawed pid=$pid for a daemon transaction") + return { + 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) } + } + } +} 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/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/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/legacy/src/main/java/de/robv/android/xposed/XposedInit.java b/legacy/src/main/java/de/robv/android/xposed/XposedInit.java index 595c5c5c2..e45c8b3a8 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; @@ -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); @@ -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/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, diff --git a/native/src/core/native_api.cpp b/native/src/core/native_api.cpp index 14920e2e8..308b5a5e9 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 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()); + return; + } g_module_native_libs.push_back(library_name); LOGD("Native module library '{}' has been registered.", library_name.c_str()); } diff --git a/native/src/jni/hook_bridge.cpp b/native/src/jni/hook_bridge.cpp index 29ff2ec64..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" @@ -203,6 +204,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. @@ -594,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. * @@ -712,6 +813,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/" @@ -728,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/services/daemon-service/build.gradle.kts b/services/daemon-service/build.gradle.kts index 41a5df9bf..64e28c91c 100644 --- a/services/daemon-service/build.gradle.kts +++ b/services/daemon-service/build.gradle.kts @@ -12,11 +12,12 @@ android { } } - aidlPackagedList += "org/lsposed/lspd/models/Module.aidl" + aidlPackagedList += "org/matrix/vector/ipc/LoadedModule.aidl" namespace = "org.lsposed.lspd.daemonservice" } 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/Module.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/Module.aidl deleted file mode 100644 index d2886f902..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/Module.aidl +++ /dev/null @@ -1,12 +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; - 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 96c25a019..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl +++ /dev/null @@ -1,15 +0,0 @@ -package org.lsposed.lspd.models; - -parcelable PreLoadedApk { - List preLoadedDexes; - List moduleClassNames; - List moduleLibraryNames; - boolean legacy; - // 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/ILSPApplicationService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl deleted file mode 100644 index b85b6ed21..000000000 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl +++ /dev/null @@ -1,15 +0,0 @@ -package org.lsposed.lspd.service; - -import org.lsposed.lspd.models.Module; - -interface ILSPApplicationService { - boolean isLogMuted(); - - List getLegacyModulesList(); - - List getModulesList(); - - String getPrefsPath(String packageName); - - ParcelFileDescriptor requestInjectedManagerBinder(out List binder); -} 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..3a7d402ca --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/HotReloadOutcome.aidl @@ -0,0 +1,43 @@ +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, 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; + + /** + * 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..2d74b199e --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IFrameworkService.aidl @@ -0,0 +1,76 @@ +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. + * + *

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 + * 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 getLegacyModules(); + + /** + * 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 getModules(); + + /** Where this process should look for a module's XSharedPreferences files. */ + String getPrefsPath(String packageName); + + /** + * 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 hosting the manager. + * + *

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(); + + /** + * 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/IHotReloadOutcomeReceiver.aidl b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadOutcomeReceiver.aidl new file mode 100644 index 000000000..419403691 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IHotReloadOutcomeReceiver.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 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 new file mode 100644 index 000000000..2b08f57e1 --- /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[] 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 new file mode 100644 index 000000000..fb31b92a1 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IProcessChannel.aidl @@ -0,0 +1,38 @@ +package org.matrix.vector.ipc; + +import org.matrix.vector.ipc.LoadedModule; +import org.matrix.vector.ipc.IHotReloadOutcomeReceiver; + +/** + * The one thing the daemon calls into an injected process for. + * + *

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 + * 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, + 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 new file mode 100644 index 000000000..4e66755dd --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/IRemotePreferenceCallback.aidl @@ -0,0 +1,16 @@ +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 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/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..9a7f0a1d5 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/LoadedModule.aidl @@ -0,0 +1,44 @@ +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 - 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 + * 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 code; + + 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..deb2767b8 --- /dev/null +++ b/services/daemon-service/src/main/aidl/org/matrix/vector/ipc/ModuleCode.aidl @@ -0,0 +1,75 @@ +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}, 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 + * {@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/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..94240a965 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,12 +59,21 @@ 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 { 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, + ) } /** @@ -153,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/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/VectorRemotePreferences.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt index 2c66241c9..77ee6c173 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() @@ -30,7 +30,7 @@ internal class VectorRemotePreferences(service: ILSPInjectedModuleService, group 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 c9983bf0f..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 @@ -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 org.lsposed.lspd.models.Module +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantLock +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 +import org.matrix.vector.impl.hooks.VectorHookBuilder import org.matrix.vector.impl.utils.VectorModuleClassLoader import org.matrix.vector.nativebridge.NativeAPI @@ -21,10 +31,63 @@ 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 { + fun loadModule(module: LoadedModule, 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) + } + } + + // Native entry points are recorded by buildGeneration, which has to do it before the entry + // 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 + } + + // Publishes nothing, so a reload can fail before the old generation is touched. + private fun buildGeneration( + module: LoadedModule, + isSystemServer: Boolean, + processName: String, + ): Pair>? { try { Log.d(TAG, "Loading module ${module.packageName}") @@ -35,7 +98,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) } } @@ -52,9 +115,10 @@ object VectorModuleManager { val moduleClassLoader = VectorModuleClassLoader.loadApk( module.apkPath, - module.file.preLoadedDexes, + module.code.preLoadedDexes, librarySearchPath, initLoader, + blockLegacyApi = module.code.targetApiVersion >= 102, ) // Security/Integrity Check: Ensure the module isn't bundling its own API classes @@ -63,7 +127,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 @@ -73,7 +137,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, ) @@ -81,12 +145,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 - for (className in module.file.moduleClassNames) { + val entries = mutableListOf() + for (className in module.code.moduleClassNames) { runCatching { val moduleClass = moduleClassLoader.loadClass(className) Log.v(TAG, "Loading class $moduleClass") @@ -100,29 +165,230 @@ 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 + // 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 } catch (e: Throwable) { Log.e(TAG, "Fatal error loading module ${module.packageName}", e) - return false + return null + } + } + + fun hotReload( + modulePackageName: String?, + extras: Bundle?, + newModule: LoadedModule?, + ): 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: LoadedModule?, + ): 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.code.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. + // 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 = + 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) + + 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 + } + + // 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 } + } + + // 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) + } + + Log.d(TAG, "Hot reloaded $packageName") + return outcome(IXposedService.HOT_RELOAD_SUCCEEDED, null, generationChanged = true) + } + + /** + * 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, + 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 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) + + private fun describe(t: Throwable) = "${t.javaClass.name}: ${t.message ?: "no message"}" } 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 new file mode 100644 index 000000000..31b9146d2 --- /dev/null +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt @@ -0,0 +1,52 @@ +package org.matrix.vector.impl.core + +import android.os.Binder +import android.os.Bundle +import android.os.Process +import java.util.concurrent.Executors +import org.matrix.vector.ipc.LoadedModule +import org.matrix.vector.ipc.IHotReloadOutcomeReceiver +import org.matrix.vector.ipc.IProcessChannel +import org.lsposed.lspd.util.Utils.Log + +private const val TAG = "VectorProcessChannel" + +/** + * 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 + * 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?, + module: LoadedModule?, + 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 + // 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, module) + 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 fc70e9097..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 @@ -2,24 +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.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 { @@ -31,6 +32,25 @@ object VectorServiceClient : ILSPApplicationService, IBinder.DeathRecipient { Log.e(TAG, "Failed to link to death for service in process: $niceName", it) service = null } + + // 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.attachProcessChannel(VectorProcessChannel) + } catch (t: Throwable) { + Log.e(TAG, "Failed to attach the process channel in process: $niceName", t) + } + } + } + } + + override fun attachProcessChannel(channel: IProcessChannel?) { + try { + service?.attachProcessChannel(channel) + } catch (t: Throwable) { + Log.e(TAG, "Failed to attach the process channel", t) } } @@ -38,20 +58,24 @@ object VectorServiceClient : ILSPApplicationService, 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/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/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..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 @@ -1,17 +1,26 @@ 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 org.lsposed.lspd.util.Utils -/** Represents a registered hook configuration, stored natively by [HookBridge]. */ -data class VectorHookRecord( - val hooker: XposedInterface.Hooker, +/** + * 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( + val hooker: Hooker, val priority: Int, val exceptionMode: ExceptionMode, + val id: String?, ) /** @@ -39,9 +48,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] @@ -64,17 +70,23 @@ class VectorChain( } val record = hooks[hookIndex] + val hooker = record.hooker + val exceptionMode = record.exceptionMode val nextChain = VectorChain(executable, thisObject, currentArgs, hooks, 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 +94,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 +111,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 +123,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/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 920018bcd..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 @@ -15,16 +15,20 @@ 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: String? = 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 +36,10 @@ class VectorHookBuilder( this.exceptionMode = mode } + override fun setId(id: String?): HookBuilder = apply { this.id = id } + override fun intercept(hooker: Hooker): HookHandle { + ensureNotFrozen() if (Modifier.isAbstract(origin.modifiers)) { throw IllegalArgumentException( "$origin is abstract: it has no body to hook. Hook the concrete override instead." @@ -71,27 +78,73 @@ 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 + 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) + } + } + return register(record, moduleId) + } + } + + 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") } - return object : HookHandle { - override fun getExecutable(): Executable = origin - - override fun unhook() { - HookBridge.unhookMethod(true, 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 + } + + companion object { + /** + * 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) } } @@ -109,8 +162,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..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 @@ -26,6 +27,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 +36,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 +48,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 +63,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 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" + ) + } + findLoadedClass(name)?.let { return it } @@ -130,6 +146,27 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { companion object { private const val TAG = "VectorModuleClassLoader" private const val ZIP_SEPARATOR = "!/" + + /** + * 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 { @@ -143,11 +180,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 +205,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) } 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..1bcbdc1a4 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,14 @@ object HookBridge { artMethods: LongArray, 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 } 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 79be41415..79e4db7b2 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]) + // 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) if (isParasitic) { managerFd = pfd.detachFd() 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,