From 7d3fcdffa5ba4305d736bc354fcf67283de848f0 Mon Sep 17 00:00:00 2001 From: bartasc4 <210133930+bartasc4@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:46:08 +0200 Subject: [PATCH] Restore injected system-server connections after daemon restart --- .../org/matrix/vector/daemon/VectorService.kt | 33 ++- .../matrix/vector/daemon/data/ConfigCache.kt | 2 +- .../vector/daemon/ipc/FrameworkService.kt | 86 +++++- .../vector/daemon/ipc/SystemServerService.kt | 30 +- .../org/matrix/vector/ipc/IVectorDaemon.aidl | 12 + tests/reattach/README.md | 29 ++ tests/reattach/ReattachTest.java | 274 ++++++++++++++++++ tests/reattach/handshake_fixture.cpp | 88 ++++++ tests/reattach/run_tests.py | 57 ++++ .../stubs/android/app/ActivityThread.java | 13 + .../stubs/android/app/ContextImpl.java | 2 + .../stubs/android/app/IApplicationThread.java | 2 + .../stubs/android/content/Context.java | 2 + .../android/content/pm/ApplicationInfo.java | 2 + tests/reattach/stubs/android/os/Binder.java | 51 ++++ tests/reattach/stubs/android/os/Build.java | 4 + tests/reattach/stubs/android/os/Bundle.java | 18 ++ tests/reattach/stubs/android/os/Parcel.java | 17 ++ tests/reattach/stubs/android/os/Process.java | 6 + .../stubs/android/os/RemoteException.java | 5 + .../reattach/stubs/android/util/ArraySet.java | 2 + tests/reattach/stubs/android/util/Log.java | 15 + .../stubs/hidden/HiddenApiBridge.java | 7 + .../vector/daemon/data/ConfigCache.java | 14 + .../vector/daemon/data/ModuleDatabase.java | 14 + .../org/matrix/vector/impl/VectorContext.kt | 27 +- .../vector/impl/VectorRemotePreferences.kt | 135 ++++++--- .../vector/impl/core/VectorModuleManager.kt | 39 ++- .../vector/impl/core/VectorServiceClient.kt | 97 +++++-- zygisk/src/main/cpp/include/ipc_bridge.h | 4 +- zygisk/src/main/cpp/ipc_bridge.cpp | 18 +- zygisk/src/main/cpp/module.cpp | 11 +- .../matrix/vector/service/BridgeService.kt | 93 +++--- 33 files changed, 1048 insertions(+), 161 deletions(-) create mode 100644 tests/reattach/README.md create mode 100644 tests/reattach/ReattachTest.java create mode 100644 tests/reattach/handshake_fixture.cpp create mode 100644 tests/reattach/run_tests.py create mode 100644 tests/reattach/stubs/android/app/ActivityThread.java create mode 100644 tests/reattach/stubs/android/app/ContextImpl.java create mode 100644 tests/reattach/stubs/android/app/IApplicationThread.java create mode 100644 tests/reattach/stubs/android/content/Context.java create mode 100644 tests/reattach/stubs/android/content/pm/ApplicationInfo.java create mode 100644 tests/reattach/stubs/android/os/Binder.java create mode 100644 tests/reattach/stubs/android/os/Build.java create mode 100644 tests/reattach/stubs/android/os/Bundle.java create mode 100644 tests/reattach/stubs/android/os/Parcel.java create mode 100644 tests/reattach/stubs/android/os/Process.java create mode 100644 tests/reattach/stubs/android/os/RemoteException.java create mode 100644 tests/reattach/stubs/android/util/ArraySet.java create mode 100644 tests/reattach/stubs/android/util/Log.java create mode 100644 tests/reattach/stubs/hidden/HiddenApiBridge.java create mode 100644 tests/reattach/stubs/org/matrix/vector/daemon/data/ConfigCache.java create mode 100644 tests/reattach/stubs/org/matrix/vector/daemon/data/ModuleDatabase.java 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 7376ac66f..89bf29a62 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt @@ -10,6 +10,8 @@ import android.os.Binder import android.os.Build import android.os.Bundle import android.os.IBinder +import android.os.Process +import android.os.SystemProperties import android.telephony.TelephonyManager import android.util.Log import hidden.HiddenApiBridge @@ -18,6 +20,7 @@ import kotlinx.coroutines.launch import org.matrix.vector.ipc.ScopeEntry import org.matrix.vector.ipc.IVectorDaemon import org.matrix.vector.ipc.IFrameworkService +import org.matrix.vector.ipc.IModuleService import org.matrix.vector.daemon.data.ConfigCache import org.matrix.vector.daemon.data.ModuleDatabase import org.matrix.vector.daemon.data.PreferenceStore @@ -25,6 +28,7 @@ import org.matrix.vector.daemon.data.ProcessScope import org.matrix.vector.daemon.ipc.FrameworkService import org.matrix.vector.daemon.ipc.ManagerService import org.matrix.vector.daemon.ipc.ModuleAppService +import org.matrix.vector.daemon.ipc.SystemServerService import org.matrix.vector.daemon.system.* private const val TAG = "VectorService" @@ -32,6 +36,7 @@ private const val TAG = "VectorService" object VectorService : IVectorDaemon.Stub() { private var bootCompleted = false + private var contextOwner: IBinder? = null /** * What the dialer broadcasts when a secret code is entered, which changed name in Q. @@ -49,18 +54,22 @@ object VectorService : IVectorDaemon.Stub() { /** Dial *#*#832867#*#* ("VECTOR" on the keypad) to open the manager. */ private const val SECRET_CODE = "832867" + @Synchronized override fun dispatchSystemServerContext( appThread: IBinder?, activityToken: IBinder?, ) { - appThread?.let { SystemContext.appThread = IApplicationThread.Stub.asInterface(it) } + if (Binder.getCallingUid() != Process.SYSTEM_UID || appThread == null) return + if (contextOwner == appThread) return + SystemContext.appThread = IApplicationThread.Stub.asInterface(appThread) SystemContext.token = activityToken // Initialize OS Observers using Coroutines for the dispatch blocks registerReceivers() + contextOwner = appThread - if (VectorDaemon.isLateInject) { - Log.i(TAG, "Late injection detected. Forcing boot completed event.") + if (VectorDaemon.isLateInject || SystemProperties.get("sys.boot_completed") == "1") { + Log.i(TAG, "Connected to an already booted system server.") dispatchBootCompleted() } } @@ -69,17 +78,22 @@ object VectorService : IVectorDaemon.Stub() { uid: Int, pid: Int, processName: String, - heartBeat: IBinder + heartBeat: IBinder, ): IFrameworkService? { if (Binder.getCallingUid() != 1000) { Log.w(TAG, "Unauthorized attachProcess call") return null } + if (processName == "system") { + return SystemServerService.attachProcess(uid, pid, processName, heartBeat) + } if (FrameworkService.hasRegister(uid, pid)) return null val scope = ProcessScope(processName, uid) - if (!ManagerService.tryRegisterManagerProcess(pid, uid, processName) && - ConfigCache.shouldSkipProcess(scope)) { + if ( + !ManagerService.tryRegisterManagerProcess(pid, uid, processName) && + ConfigCache.shouldSkipProcess(scope) + ) { Log.d(TAG, "Skipped $processName/$uid") return null } @@ -91,6 +105,13 @@ object VectorService : IVectorDaemon.Stub() { override fun preStartManager() = ManagerService.preStartManager() + override fun reconnectSystemModule( + packageName: String, + loadedVersionCode: Long, + hotReloadable: Boolean, + ): IModuleService? = + FrameworkService.reconnectSystemModule(packageName, loadedVersionCode, hotReloadable) + private fun createReceiver() = object : IIntentReceiver.Stub() { override fun performReceive( 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 b79aa1181..ee8797d51 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 @@ -65,7 +65,7 @@ object ConfigCache { applySqliteHelperWorkaround() } - private fun ensureCacheReady() { + internal fun ensureCacheReady() { if (!state.isCacheReady && packageManager?.asBinder()?.isBinderAlive == true) { // The rebuild lock, not `this`: this is the one place that starts a rebuild without going // through the channel, so it has to queue behind a rebuild already in flight rather than diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/FrameworkService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/FrameworkService.kt index 44ed1c650..6b99d7e43 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/FrameworkService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/FrameworkService.kt @@ -13,8 +13,10 @@ 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.ipc.IModuleService 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.system.FIRST_APPLICATION_UID import org.matrix.vector.daemon.system.PER_USER_RANGE import org.matrix.vector.daemon.utils.InstallerVerifier @@ -45,6 +47,7 @@ object FrameworkService : IFrameworkService.Stub() { data class ProcessKey(val uid: Int, val pid: Int) private val processes = ConcurrentHashMap() + private val processRegistrationLock = Any() /** One module generation loaded into one process: what a hot reload request addresses. */ class HotReloadTarget( @@ -57,6 +60,7 @@ object FrameworkService : IFrameworkService.Stub() { val hotReloadable: Boolean, ) { val state = AtomicInteger(HookedProcess.TARGET_STATE_UP_TO_DATE) + @Volatile var mayBackfillVersion = true } private val hotReloadTargets = ConcurrentHashMap() @@ -70,15 +74,13 @@ object FrameworkService : IFrameworkService.Stub() { @Volatile var hotReloadBinder: IProcessChannel? = null - init { - heartBeat.linkToDeath(this, 0) - processes[key] = this - } - override fun binderDied() { - heartBeat.unlinkToDeath(this, 0) - processes.remove(key) - targetIds.values.forEach { hotReloadTargets.remove(it) } + synchronized(processRegistrationLock) { + runCatching { heartBeat.unlinkToDeath(this, 0) } + if (processes.remove(key, this)) { + targetIds.values.forEach { hotReloadTargets.remove(it) } + } + } } } @@ -156,7 +158,7 @@ object FrameworkService : IFrameworkService.Stub() { // system_server records its targets before PMS exists, so they start without a version. fun backfillLoadedVersions() { hotReloadTargets.values - .filter { it.loadedVersionCode == 0L } + .filter { it.loadedVersionCode == 0L && it.mayBackfillVersion } .forEach { target -> ConfigCache.state.modules[target.modulePackageName] ?.versionCode @@ -246,11 +248,67 @@ object FrameworkService : IFrameworkService.Stub() { } fun registerHeartBeat(uid: Int, pid: Int, processName: String, heartBeat: IBinder): Boolean { - return runCatching { - ProcessInfo(ProcessKey(uid, pid), processName, heartBeat) - true - } - .getOrDefault(false) + return synchronized(processRegistrationLock) { + val key = ProcessKey(uid, pid) + processes[key]?.let { + // A repeated system-server handshake must retain its process channel and module targets. + if (it.heartBeat.isBinderAlive) return@synchronized it.processName == processName + // Death delivery may be queued while a new process has already reused the PID. + it.binderDied() + } + runCatching { + val info = ProcessInfo(key, processName, heartBeat) + heartBeat.linkToDeath(info, 0) + if (!heartBeat.isBinderAlive) { + heartBeat.unlinkToDeath(info, 0) + false + } else { + processes[key] = info + true + } + } + .getOrDefault(false) + } + } + + fun isSystemServerRegistered(): Boolean = + processes.values.any { + it.key.uid == Process.SYSTEM_UID && it.processName == "system" && it.heartBeat.isBinderAlive + } + + fun reconnectSystemModule( + packageName: String, + loadedVersionCode: Long, + hotReloadable: Boolean, + ): IModuleService? { + val info = ensureRegistered() + if (info.key.uid != Process.SYSTEM_UID || info.processName != "system") { + throw SecurityException("Only system_server may restore its module connection") + } + require(loadedVersionCode >= 0) { "Invalid loaded module version" } + ConfigCache.ensureCacheReady() + if (!ConfigCache.state.isCacheReady) throw RemoteException("Module cache is not ready") + if (ModuleDatabase.systemServerModuleRows().none { it.packageName == packageName }) return null + // Reuse the cache-owned service: it receives subsequent preference updates. Do not parse + // another APK or stage native libraries merely to reconnect code that is already loaded. + val module = + ConfigCache.state.modules[packageName]?.takeUnless { it.code.legacy } ?: return null + info.targetIds.computeIfAbsent(packageName) { + val id = nextHotReloadTargetId.getAndIncrement() + hotReloadTargets[id] = + HotReloadTarget( + id, + packageName, + info.processName, + info.key.uid, + info.key.pid, + loadedVersionCode, + hotReloadable, + ) + .apply { mayBackfillVersion = false } + id + } + return module.service } fun hasRegister(uid: Int, pid: Int): Boolean = processes.containsKey(ProcessKey(uid, pid)) 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 4aa4f9fb5..fdfa57ee4 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/SystemServerService.kt @@ -6,6 +6,7 @@ import android.os.IBinder import android.os.IServiceCallback import android.os.Parcel import android.os.ServiceManager +import android.os.Process import android.util.Log import org.matrix.vector.ipc.IFrameworkService import org.matrix.vector.daemon.* @@ -59,7 +60,8 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { private var proxyServiceName: String? = null private var originService: IBinder? = null - var systemServerRequested = false + val systemServerRequested: Boolean + get() = FrameworkService.isSystemServerRegistered() fun registerProxyService(serviceName: String) { // Register as the service name early to setup an IPC for `system_server`. @@ -93,24 +95,26 @@ object SystemServerService : Binder(), IBinder.DeathRecipient { /** * Registers system_server and answers with its framework service, or null if this is not - * system_server. Only ever called from [onTransact] below. + * system_server. Used by both the initial rendezvous and the existing bridge after a daemon + * restart. */ - private fun attachProcess( + internal fun attachProcess( uid: Int, pid: Int, processName: String, - processLifeToken: IBinder? + processLifeToken: IBinder?, ): IFrameworkService? { - if (uid != 1000 || processLifeToken == null || processName != "system") return null - - // Latched only once the registration has actually succeeded, not on the way in. It used to be - // set immediately after the gate above, so a registration that then failed — registerHeartBeat - // answers false when the life token cannot be linked to death — still read as attached. The - // symptom was the worst kind: the manager's status page reported the framework as present in - // system_server while no module hooking the system ever loaded, which sends a reader looking - // at their module instead of at the injection. + if ( + uid != Process.SYSTEM_UID || + processLifeToken == null || + processName != "system" || + getCallingUid() != uid || + getCallingPid() != pid + ) + return null + + // Status is derived from the live registration, including failure and process death. if (!FrameworkService.registerHeartBeat(uid, pid, processName, processLifeToken)) return null - systemServerRequested = true return FrameworkService } 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 index b449f1362..831dead78 100644 --- 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 @@ -1,6 +1,7 @@ package org.matrix.vector.ipc; import org.matrix.vector.ipc.IFrameworkService; +import org.matrix.vector.ipc.IModuleService; /** * The daemon's front door, reached from a newly specialized process through the zygisk bridge. @@ -26,6 +27,9 @@ interface IVectorDaemon { * this process, which is most of them; or the registration itself failed. A process that is * answered null simply carries on unhooked.

* + *

The authenticated system_server may repeat its own attachment after a daemon restart. + * An existing live registration keeps its process channel and module targets.

+ * * @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 @@ -43,4 +47,12 @@ interface IVectorDaemon { /** Asks the daemon to bring the manager up before it is needed. */ boolean preStartManager(); + + /** + * Rebinds an already loaded modern module after the daemon restarts. Only a registered + * system_server may call this. The reported version is the generation still in memory; + * this does not load code or claim that an updated APK has already been applied. + */ + @nullable IModuleService reconnectSystemModule(String packageName, long loadedVersionCode, + boolean hotReloadable); } diff --git a/tests/reattach/README.md b/tests/reattach/README.md new file mode 100644 index 000000000..746b9fdfc --- /dev/null +++ b/tests/reattach/README.md @@ -0,0 +1,29 @@ +# System-server reconnection tests + +Build `:daemon:compileReleaseKotlin` and `:zygisk:compileReleaseKotlin`, then run: + +```sh +python3 tests/reattach/run_tests.py \ + --jdk /path/to/jdk-21 \ + --sdk /path/to/android-sdk \ + --gradle-cache /path/to/gradle-user-home +``` + +Requires Python 3 and a C++17 compiler. The Java fixture loads the compiled +production classes with controlled Android/Binder doubles. It checks live +registration, caller authentication, duplicate handshakes, queued old death +notifications, PID reuse, partial restore retries, existing module contexts, +remote-preference snapshots and callbacks, and restored hot-reload targets. +The same assertions run for selected API branches from 26 through 37; this is +not an Android device or minimum-SDK compatibility matrix. + +The native fixture extracts the production rendezvous and registration functions +from `ipc_bridge.cpp`. It checks absent, ordinary, and throwing services followed +by successful registration for both proxy names, plus bounded failure. Only +sleep and transport are replaced by deterministic doubles. + +Device validation must additionally establish that restarting only the Vector +daemon changes its PID while preserving the kernel boot ID and the PID/start time +of `system_server` and zygote, then confirm application injection still works. +This recovery requires Vector's bridge to have already been injected; it cannot +inject an untouched running `system_server` or repair an external APEX mount failure. diff --git a/tests/reattach/ReattachTest.java b/tests/reattach/ReattachTest.java new file mode 100644 index 000000000..5d3748208 --- /dev/null +++ b/tests/reattach/ReattachTest.java @@ -0,0 +1,274 @@ +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.os.*; +import io.github.libxposed.api.XposedInterface.ExceptionMode; +import java.io.Serializable; +import java.lang.reflect.*; +import java.util.*; +import org.matrix.vector.daemon.data.ConfigCache; +import org.matrix.vector.daemon.data.ModuleDatabase; +import org.matrix.vector.daemon.ipc.FrameworkService; +import org.matrix.vector.daemon.ipc.SystemServerService; +import org.matrix.vector.impl.VectorContext; +import org.matrix.vector.impl.VectorRemotePreferences; +import org.matrix.vector.impl.core.VectorModuleManager; +import org.matrix.vector.impl.core.VectorServiceClient; +import org.matrix.vector.ipc.*; +import org.matrix.vector.service.BridgeService; + +public class ReattachTest { + private static int passed; + + private static void check(boolean condition, String message) { + if (!condition) throw new AssertionError(message, android.util.Log.lastFailure); + passed++; + } + + private static Object invoke(Object target, String name, Object... args) throws Exception { + for (Method method : target.getClass().getDeclaredMethods()) { + if (method.getName().equals(name) || method.getName().startsWith(name + "$")) { + method.setAccessible(true); + try { return method.invoke(target, args); } + catch (InvocationTargetException e) { + if (e.getCause() instanceof Exception ex) throw ex; + throw e; + } + } + } + throw new AssertionError("Missing production method " + name); + } + + @SuppressWarnings("unchecked") + private static T field(Class type, String name) throws Exception { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + return (T) field.get(null); + } + + private static Bundle bundle(String key, Map values) { + Bundle bundle = new Bundle(); + bundle.putSerializable(key, new HashMap<>(values)); + return bundle; + } + + private static class ModuleService extends IModuleService.Stub { + Map values = new HashMap<>(); + IRemotePreferenceCallback callback; + int requests; + boolean fail; + Bundle updateInFlight; + public long getFrameworkProperties() { return 0; } + public String[] getRemoteFileNames() { return new String[]{"current"}; } + public ParcelFileDescriptor openRemoteFile(String path) { return null; } + public Bundle requestRemotePreferences(String group, IRemotePreferenceCallback next) + throws RemoteException { + requests++; + if (fail) throw new RemoteException("temporary failure"); + callback = next; + Bundle snapshot = bundle("map", values); + if (updateInFlight != null) next.onRemotePreferencesChanged(updateInFlight); + return snapshot; + } + } + + private static class Framework extends IFrameworkService.Stub { + int channels; + boolean rejectChannel; + public void attachProcessChannel(IProcessChannel channel) throws RemoteException { + if (rejectChannel) throw new RemoteException("retry channel"); + channels++; + } + public List getModules() { throw new AssertionError("must not reload modules"); } + public List getLegacyModules() { throw new AssertionError("must not reload legacy modules"); } + public boolean isLogMuted() { return false; } + public String getPrefsPath(String module) { return null; } + public ParcelFileDescriptor openManagerApk() { return null; } + public IBinder requestManagerService() { return null; } + } + + private static class Daemon extends IVectorDaemon.Stub { + final Framework framework = new Framework(); + final ModuleService module = new ModuleService(); + int attaches, contexts, modules; + long reportedVersion = -1; + public IFrameworkService attachProcess(int uid, int pid, String name, IBinder life) { + check(Binder.getCallingUid() == 1000 && Binder.getCallingPid() == 123, + "nested calls must use system_server identity"); + check(uid == 1000 && pid == 123 && name.equals("system") && life != null, + "reattach identifies the same live process"); + attaches++; + return framework; + } + public void dispatchSystemServerContext(IBinder thread, IBinder token) { contexts++; } + public boolean preStartManager() { return true; } + public IModuleService reconnectSystemModule(String name, long version, boolean reloadable) { + check(name.equals("test.module"), "only loaded system modules reconnect"); + check(reloadable, "actual generation capability retained"); + modules++; + reportedVersion = version; + return module; + } + } + + private static boolean send(Daemon daemon, int uid) { + Binder.callerUid = uid; + Binder.callerPid = 999; + Parcel data = Parcel.obtain(); + data.writeInt(1); + data.writeStrongBinder(daemon); + boolean result = BridgeService.onTransact(data, Parcel.obtain(), 0); + check(Binder.callerUid == uid && Binder.callerPid == 999, "calling identity restored"); + return result; + } + + private static void registry() throws Exception { + FrameworkService registry = FrameworkService.INSTANCE; + SystemServerService system = SystemServerService.INSTANCE; + Binder.callerUid = 1000; Binder.callerPid = 123; + Binder life = new Binder(); + check(!system.getSystemServerRequested(), "status starts disconnected"); + check(invoke(system, "attachProcess", 1001, 123, "system", life) == null, "wrong UID denied"); + check(invoke(system, "attachProcess", 1000, 124, "system", life) == null, "wrong PID denied"); + check(invoke(system, "attachProcess", 1000, 123, "other", life) == null, "wrong process denied"); + Binder broken = new Binder(); broken.rejectLink = true; + check(invoke(system, "attachProcess", 1000, 123, "system", broken) == null, + "failed heartbeat denied"); + check(!system.getSystemServerRequested(), "failed registration does not latch injected"); + check(invoke(system, "attachProcess", 1000, 123, "system", life) == registry, + "initial native handshake accepted"); + check(system.getSystemServerRequested(), "live registration drives status"); + + ModuleService moduleService = new ModuleService(); + LoadedModule module = new LoadedModule(); + module.packageName = "test.module"; module.versionCode = 22; + module.service = moduleService; module.code = new ModuleCode(); + module.code.legacy = false; + ConfigCache.INSTANCE.modules.put(module.packageName, module); + ModuleDatabase.INSTANCE.scopes.add(module.packageName); + check(registry.reconnectSystemModule(module.packageName, 11, true) == moduleService, + "cache-owned module service reused"); + var target = registry.getHotReloadTargets(module.packageName, 0).get(0); + check(target.loadedVersionCode == 11, "in-memory version retained instead of installed version"); + check(target.state == io.github.libxposed.service.HookedProcess.TARGET_STATE_STALE, + "older generation reported stale"); + IProcessChannel channel = new IProcessChannel.Default(); + registry.attachProcessChannel(channel); + check(invoke(system, "attachProcess", 1000, 123, "system", new Binder()) == registry, + "duplicate handshake accepted"); + var actualTarget = registry.getHotReloadTarget(target.targetId, module.packageName, 0); + check(registry.getHotReloadBinder(actualTarget) == channel, "duplicate preserves process channel"); + registry.reconnectSystemModule(module.packageName, 22, false); + check(registry.getHotReloadTargets(module.packageName, 0).get(0).loadedVersionCode == 11, + "retry does not replace registered generation"); + + List oldDeaths = List.copyOf(life.deaths); + life.alive = false; + check(!system.getSystemServerRequested(), "death visible before queued callback"); + Binder replacement = new Binder(); + check(registry.registerHeartBeat(1000, 123, "system", replacement), "dead PID record may be replaced"); + oldDeaths.forEach(IBinder.DeathRecipient::binderDied); + check(system.getSystemServerRequested(), "late old death preserves replacement"); + check(registry.getHotReloadTargets(module.packageName, 0).isEmpty(), "dead targets removed"); + registry.reconnectSystemModule(module.packageName, 0, true); + registry.backfillLoadedVersions(); + check(registry.getHotReloadTargets(module.packageName, 0).get(0).loadedVersionCode == 0, + "unknown restored version not invented from installed APK"); + ModuleDatabase.INSTANCE.scopes.clear(); + check(registry.reconnectSystemModule(module.packageName, 11, true) == null, "revoked scope denied"); + replacement.die(); + check(!system.getSystemServerRequested(), "process death clears injected status"); + check(registry.getHotReloadTargets(module.packageName, 0).isEmpty(), "process death clears targets"); + } + + private static void preferences() throws Exception { + ModuleService first = new ModuleService(); + first.values.put("removed", 1); first.values.put("changed", 1); + VectorRemotePreferences prefs = new VectorRemotePreferences(first, "settings"); + IRemotePreferenceCallback old = first.callback; + Set changed = new HashSet<>(); + prefs.registerOnSharedPreferenceChangeListener((p, key) -> changed.add(key)); + ModuleService second = new ModuleService(); + second.values.put("changed", 2); + second.updateInFlight = bundle("put", Map.of("racing", 3)); + invoke(prefs, "reconnect", second); + check(!prefs.contains("removed") && prefs.getInt("changed", 0) == 2, + "reconnect replaces old preference snapshot including deletions"); + check(prefs.getInt("racing", 0) == 3, "in-flight callback applied after snapshot"); + check(changed.equals(Set.of("removed", "changed", "racing")), "listener receives snapshot differences"); + old.onRemotePreferencesChanged(bundle("put", Map.of("changed", -1))); + check(prefs.getInt("changed", 0) == 2, "old subscription cannot overwrite new values"); + invoke(prefs, "reconnect", second); + check(second.requests == 1, "successful subscription not duplicated on retry"); + ModuleService failed = new ModuleService(); failed.fail = true; + try { invoke(prefs, "reconnect", failed); throw new AssertionError("expected IPC error"); } + catch (RemoteException expected) {} + second.callback.onRemotePreferencesChanged(bundle("put", Map.of("changed", 4))); + check(prefs.getInt("changed", 0) == 4, "failed rebind retains previous subscription"); + prefs.registerOnSharedPreferenceChangeListener((p, key) -> { throw new RuntimeException("module listener"); }); + ModuleService third = new ModuleService(); third.values.put("changed", 5); + invoke(prefs, "reconnect", third); + invoke(prefs, "reconnect", third); + check(third.requests == 1 && prefs.getInt("changed", 0) == 5, + "listener failure cannot undo successful binding"); + } + + private static void bridge() throws Exception { + Daemon first = new Daemon(); + first.module.values.put("value", 1); + VectorServiceClient.INSTANCE.init(first.framework, "system"); + VectorContext context = new VectorContext("test.module", new ApplicationInfo(), first.module, + ExceptionMode.PROTECTIVE); + SharedPreferences prefs = context.getRemotePreferences("settings"); + Class generationType = Class.forName("org.matrix.vector.impl.core.VectorModuleManager$Generation"); + Constructor constructor = generationType.getDeclaredConstructors()[0]; + constructor.setAccessible(true); + Object generation = constructor.newInstance(ReattachTest.class.getClassLoader(), context, + List.of(), true, "system", 11L, true); + Map generations = field(VectorModuleManager.class, "generations"); + generations.put("test.module", generation); + Object appGeneration = constructor.newInstance(ReattachTest.class.getClassLoader(), context, + List.of(), false, "test.app", 11L, true); + generations.put("test.app.module", appGeneration); + check(!send(first, 10001) && first.attaches == 0, "application cannot push a daemon binder"); + check(send(first, 0), "initial bridge delivery works"); + check(first.modules == 0 && first.framework.channels == 1, "initial delivery does not replay bootstrap"); + List oldBridgeDeaths = List.copyOf(first.deaths); + List oldFrameworkDeaths = List.copyOf(first.framework.deaths); + first.alive = false; first.framework.alive = false; + Daemon second = new Daemon(); second.module.values.put("value", 2); + check(send(second, 0), "new daemon reattaches to existing process"); + check(second.reportedVersion == 11 && second.framework.channels == 1, + "actual loaded version and process channel restored"); + check(context.getRemotePreferences("settings") == prefs && prefs.getInt("value", 0) == 2, + "existing module context and preference object survive"); + check(generations.get("test.module") == generation && generations.get("test.app.module") == appGeneration, + "module generations and class loaders unchanged"); + oldBridgeDeaths.forEach(IBinder.DeathRecipient::binderDied); + oldFrameworkDeaths.forEach(IBinder.DeathRecipient::binderDied); + check(BridgeService.getService() == second && VectorServiceClient.INSTANCE.asBinder() == second.framework, + "delayed old daemon death cannot clear new connections"); + int attaches = second.attaches; + check(send(second, 0) && second.attaches == attaches && second.module.requests == 1, + "duplicate delivery has no subscription or channel side effects"); + + Daemon third = new Daemon(); third.module.fail = true; + check(!send(third, 0), "partial restoration does not report successful bridge"); + third.module.fail = false; third.module.values.put("value", 3); + third.framework.rejectChannel = true; + check(!send(third, 0), "failed process channel is retried"); + int requests = third.module.requests; + third.framework.rejectChannel = false; + check(send(third, 0), "next delivery completes partial restore"); + check(third.module.requests == requests && prefs.getInt("value", 0) == 3, + "channel retry keeps completed preference subscription"); + third.die(); third.framework.die(); + check(BridgeService.getService() == null && VectorServiceClient.INSTANCE.asBinder() == null, + "current daemon death disconnects both clients"); + } + + public static void main(String[] args) throws Exception { + Build.VERSION.SDK_INT = Integer.parseInt(args[0]); + registry(); preferences(); bridge(); + System.out.println("PASS API branch " + Build.VERSION.SDK_INT + ": " + passed + " lifecycle assertions"); + } +} diff --git a/tests/reattach/handshake_fixture.cpp b/tests/reattach/handshake_fixture.cpp new file mode 100644 index 000000000..3ad5b0c2d --- /dev/null +++ b/tests/reattach/handshake_fixture.cpp @@ -0,0 +1,88 @@ +#include +#include +#include +#include +#include +#include + +// JNI/transport are controlled; both IPCBridge functions below are taken from production. +struct Handle { int kind; }; +using jobject = Handle*; +using jstring = Handle*; +Handle ordinary{1}, working{2}, throwing{3}, framework{4}, heartbeat{5}, text{6}, parcel{7}; +struct JNIEnv { + bool exception = false; + int retained = 0, cleared = 0; + jobject NewObject(int, int) { return &heartbeat; } + jstring NewStringUTF(const char*) { return &text; } + bool ExceptionCheck() { return exception; } + void ExceptionClear() { exception = false; ++cleared; } + jobject NewGlobalRef(jobject object) { ++retained; return object; } +}; +namespace lsplant { +template struct ScopedLocalRef { + T object; + ScopedLocalRef(JNIEnv*, T object) : object(object) {} + T get() const { return object; } + explicit operator bool() const { return object != nullptr; } +}; +std::vector services; +int polls, transactions, waits; +ScopedLocalRef JNI_CallStaticObjectMethod(JNIEnv* env, int, int, jobject) { + jobject result = services.at(polls++); + return {env, result}; +} +template void JNI_CallVoidMethod(JNIEnv*, jobject, int, Args...) {} +template bool JNI_CallBooleanMethod(JNIEnv* env, jobject binder, int, Args...) { + ++transactions; + if (binder == &throwing) env->exception = true; + return binder == &working; +} +ScopedLocalRef JNI_CallObjectMethod(JNIEnv* env, jobject, int) { return {env, &framework}; } +} +namespace fixture { +void sleep_for(std::chrono::seconds) { ++lsplant::waits; } +} +#define LOGE(...) ((void)0) +#define LOGW(...) ((void)0) +#define LOGI(...) ((void)0) +#define LOGD(...) ((void)0) +constexpr int kBridgeTransactionCode = 0; +class IPCBridge { +public: + bool initialized_ = true; + int service_manager_class_ = 0, get_service_method_ = 0, binder_class_ = 0, binder_ctor_ = 0; + int write_int_method_ = 0, write_string_method_ = 0, write_strong_binder_method_ = 0; + int transact_method_ = 0, read_exception_method_ = 0, read_strong_binder_method_ = 0; + struct ParcelWrapper { + lsplant::ScopedLocalRef data, reply; + ParcelWrapper(JNIEnv* env, IPCBridge*) : data(env, &parcel), reply(env, &parcel) {} + }; + lsplant::ScopedLocalRef RequestSystemServerBinder(JNIEnv*, std::string); + lsplant::ScopedLocalRef RequestManagerBinderFromSystemServer(JNIEnv*, jobject); +}; + +// PRODUCTION_FUNCTIONS + +int main() { + IPCBridge bridge; + for (const auto& name : {"serial", "serial_vector"}) { + JNIEnv env; + lsplant::services = {nullptr, &ordinary, &throwing, &working}; + lsplant::polls = lsplant::transactions = lsplant::waits = 0; + auto result = bridge.RequestSystemServerBinder(&env, name); + assert(result.get() == &framework); + assert(lsplant::polls == 4 && lsplant::transactions == 3 && lsplant::waits == 3); + assert(!env.exception && env.cleared == 1 && env.retained == 1); + std::cout << "PASS " << name << ": absent/occupied/exception endpoints retried until handshake\n"; + } + JNIEnv env; + lsplant::services.assign(10, &ordinary); + lsplant::polls = lsplant::transactions = lsplant::waits = 0; + assert(!bridge.RequestSystemServerBinder(&env, "serial")); + assert(lsplant::polls == 10 && env.retained == 0); + bridge.initialized_ = false; + assert(!bridge.RequestSystemServerBinder(&env, "serial")); + assert(lsplant::polls == 10); + std::cout << "PASS bounded failure, no unverified service returned, no failed-attempt heartbeat retained\n"; +} diff --git a/tests/reattach/run_tests.py b/tests/reattach/run_tests.py new file mode 100644 index 000000000..e8d008ea9 --- /dev/null +++ b/tests/reattach/run_tests.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Run compiled production reconnection classes with deterministic Android transport doubles. + +Build :daemon:compileReleaseKotlin and :zygisk:compileReleaseKotlin first. +This checks lifecycle logic, not Binder kernel transport or Zygisk injection on a device. +""" +import argparse +import hashlib +import os +from pathlib import Path +import subprocess + +ROOT = Path(__file__).resolve().parents[2] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--jdk', type=Path, required=True) + parser.add_argument('--sdk', type=Path, required=True) + parser.add_argument('--gradle-cache', type=Path, required=True) + parser.add_argument('--output', type=Path, default=ROOT / 'build/reattach-tests') + args = parser.parse_args() + classes = args.output.resolve() / 'classes' + classes.mkdir(parents=True, exist_ok=True) + paths = [classes] + for module in ['daemon', 'zygisk', 'xposed', 'services/daemon-service', 'services/manager-service']: + paths += [ROOT / module / 'build/intermediates/built_in_kotlinc/release/compileReleaseKotlin/classes', + ROOT / module / 'build/intermediates/javac/release/compileReleaseJavaWithJavac/classes'] + stdlib = args.gradle_cache / 'caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib' + paths.append(sorted(stdlib.glob('*/*/*.jar'))[-1]) + paths.append(sorted((args.sdk / 'platforms').glob('android-*/android.jar'))[-1]) + paths += list((ROOT / 'hiddenapi/stubs/build/libs').glob('*.jar')) + cp = os.pathsep.join(map(str, paths)) + sources = sorted((ROOT / 'tests/reattach/stubs').rglob('*.java')) + sources.append(ROOT / 'tests/reattach/ReattachTest.java') + subprocess.run([str(args.jdk / 'bin/javac'), '-cp', cp, '-d', str(classes), + *map(str, sources)], check=True) + for api in [26, 28, 30, 31, 33, 34, 35, 36, 37]: + subprocess.run([str(args.jdk / 'bin/java'), '-ea', '-cp', cp, 'ReattachTest', str(api)], + check=True) + native = (ROOT / 'zygisk/src/main/cpp/ipc_bridge.cpp').read_text() + start = native.index('lsplant::ScopedLocalRef IPCBridge::RequestSystemServerBinder(') + end = native.index('std::tuple IPCBridge::FetchFrameworkDex(', start) + functions = native[start:end].replace('std::this_thread::sleep_for', 'fixture::sleep_for') + fixture = (ROOT / 'tests/reattach/handshake_fixture.cpp').read_text() + generated = args.output / 'handshake.cpp' + generated.write_text(fixture.replace('// PRODUCTION_FUNCTIONS', functions)) + executable = args.output / 'handshake' + subprocess.run(['c++', '-std=c++17', '-Wall', '-Wextra', '-Werror', '-Wno-unused-parameter', + str(generated), '-o', str(executable)], check=True) + subprocess.run([str(executable)], check=True) + print('Native source SHA-256:', hashlib.sha256(native.encode()).hexdigest()) + print('LIMIT: API branches use Android doubles, not a device compatibility matrix.') + + +if __name__ == '__main__': + main() diff --git a/tests/reattach/stubs/android/app/ActivityThread.java b/tests/reattach/stubs/android/app/ActivityThread.java new file mode 100644 index 000000000..bbc75d4a2 --- /dev/null +++ b/tests/reattach/stubs/android/app/ActivityThread.java @@ -0,0 +1,13 @@ +package android.app; +import android.os.Binder; +import android.os.IBinder; +public class ActivityThread { + private static final ActivityThread INSTANCE = new ActivityThread(); + private final ApplicationThread thread = new ApplicationThread(); + public static ActivityThread currentActivityThread() { return INSTANCE; } + public ApplicationThread getApplicationThread() { return thread; } + public ContextImpl getSystemContext() { return new ContextImpl(); } + public class ApplicationThread extends Binder implements IApplicationThread { + public IBinder asBinder() { return this; } + } +} diff --git a/tests/reattach/stubs/android/app/ContextImpl.java b/tests/reattach/stubs/android/app/ContextImpl.java new file mode 100644 index 000000000..87e8e722e --- /dev/null +++ b/tests/reattach/stubs/android/app/ContextImpl.java @@ -0,0 +1,2 @@ +package android.app; +public class ContextImpl extends android.content.Context {} diff --git a/tests/reattach/stubs/android/app/IApplicationThread.java b/tests/reattach/stubs/android/app/IApplicationThread.java new file mode 100644 index 000000000..cc907d852 --- /dev/null +++ b/tests/reattach/stubs/android/app/IApplicationThread.java @@ -0,0 +1,2 @@ +package android.app; +public interface IApplicationThread extends android.os.IInterface {} diff --git a/tests/reattach/stubs/android/content/Context.java b/tests/reattach/stubs/android/content/Context.java new file mode 100644 index 000000000..2ab504754 --- /dev/null +++ b/tests/reattach/stubs/android/content/Context.java @@ -0,0 +1,2 @@ +package android.content; +public abstract class Context {} diff --git a/tests/reattach/stubs/android/content/pm/ApplicationInfo.java b/tests/reattach/stubs/android/content/pm/ApplicationInfo.java new file mode 100644 index 000000000..197b31409 --- /dev/null +++ b/tests/reattach/stubs/android/content/pm/ApplicationInfo.java @@ -0,0 +1,2 @@ +package android.content.pm; +public class ApplicationInfo { public String packageName; } diff --git a/tests/reattach/stubs/android/os/Binder.java b/tests/reattach/stubs/android/os/Binder.java new file mode 100644 index 000000000..95f2abf65 --- /dev/null +++ b/tests/reattach/stubs/android/os/Binder.java @@ -0,0 +1,51 @@ +package android.os; + +import java.io.FileDescriptor; +import java.util.ArrayList; +import java.util.List; + +/** Deterministic transport double: death delivery can lag behind a new connection. */ +public class Binder implements IBinder { + public static int callerUid = 1000, callerPid = 123; + public boolean alive = true, rejectLink = false; + public final List deaths = new ArrayList<>(); + private IInterface owner; + private String descriptor; + public Binder() {} + public void attachInterface(IInterface owner, String descriptor) { + this.owner = owner; + this.descriptor = descriptor; + } + public static int getCallingUid() { return callerUid; } + public static int getCallingPid() { return callerPid; } + public static long clearCallingIdentity() { + long saved = ((long) callerUid << 32) | (callerPid & 0xffffffffL); + callerUid = Process.myUid(); callerPid = Process.myPid(); + return saved; + } + public static void restoreCallingIdentity(long saved) { + callerUid = (int) (saved >>> 32); callerPid = (int) saved; + } + public String getInterfaceDescriptor() { return descriptor; } + public IInterface queryLocalInterface(String name) { return name.equals(descriptor) ? owner : null; } + public boolean isBinderAlive() { return alive; } + public boolean pingBinder() { return alive; } + public void linkToDeath(DeathRecipient recipient, int flags) throws RemoteException { + if (rejectLink || !alive) throw new RemoteException("dead"); + deaths.add(recipient); + } + public boolean unlinkToDeath(DeathRecipient recipient, int flags) { return deaths.remove(recipient); } + public void die() { + alive = false; + for (DeathRecipient recipient : List.copyOf(deaths)) recipient.binderDied(); + } + public boolean transact(int code, Parcel data, Parcel reply, int flags) throws RemoteException { + data.setDataPosition(0); + return onTransact(code, data, reply, flags); + } + protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException { + return false; + } + public void dump(FileDescriptor fd, String[] args) {} + public void dumpAsync(FileDescriptor fd, String[] args) {} +} diff --git a/tests/reattach/stubs/android/os/Build.java b/tests/reattach/stubs/android/os/Build.java new file mode 100644 index 000000000..bc3b0a335 --- /dev/null +++ b/tests/reattach/stubs/android/os/Build.java @@ -0,0 +1,4 @@ +package android.os; +public class Build { + public static class VERSION { public static int SDK_INT = 31; } +} diff --git a/tests/reattach/stubs/android/os/Bundle.java b/tests/reattach/stubs/android/os/Bundle.java new file mode 100644 index 000000000..010e9e5bd --- /dev/null +++ b/tests/reattach/stubs/android/os/Bundle.java @@ -0,0 +1,18 @@ +package android.os; +import java.io.Serializable; +import java.util.HashMap; +public class Bundle { + private final HashMap values = new HashMap<>(); + public Bundle() {} + public Bundle(Bundle other) { values.putAll(other.values); } + public void putSerializable(String key, Serializable value) { values.put(key, value); } + public Serializable getSerializable(String key) { return (Serializable) values.get(key); } + public T getSerializable(String key, Class type) { + return type.cast(values.get(key)); + } + public boolean containsKey(String key) { return values.containsKey(key); } + public void putBoolean(String key, boolean value) { values.put(key, value); } + public boolean getBoolean(String key, boolean fallback) { + return values.containsKey(key) ? (Boolean) values.get(key) : fallback; + } +} diff --git a/tests/reattach/stubs/android/os/Parcel.java b/tests/reattach/stubs/android/os/Parcel.java new file mode 100644 index 000000000..6257e8817 --- /dev/null +++ b/tests/reattach/stubs/android/os/Parcel.java @@ -0,0 +1,17 @@ +package android.os; +import java.util.ArrayList; +public class Parcel { + private final ArrayList values = new ArrayList<>(); + private int position; + public static Parcel obtain() { return new Parcel(); } + public void writeInt(int value) { values.add(value); } + public int readInt() { return (Integer) values.get(position++); } + public void writeString(String value) { values.add(value); } + public String readString() { return (String) values.get(position++); } + public void writeStrongBinder(IBinder value) { values.add(value); } + public IBinder readStrongBinder() { return (IBinder) values.get(position++); } + public void writeNoException() {} + public void writeException(Exception e) { throw new AssertionError(e); } + public void setDataPosition(int position) { this.position = position; } + public void recycle() {} +} diff --git a/tests/reattach/stubs/android/os/Process.java b/tests/reattach/stubs/android/os/Process.java new file mode 100644 index 000000000..d7993c8d7 --- /dev/null +++ b/tests/reattach/stubs/android/os/Process.java @@ -0,0 +1,6 @@ +package android.os; +public class Process { + public static final int SYSTEM_UID = 1000; + public static int myUid() { return 1000; } + public static int myPid() { return 123; } +} diff --git a/tests/reattach/stubs/android/os/RemoteException.java b/tests/reattach/stubs/android/os/RemoteException.java new file mode 100644 index 000000000..fac793140 --- /dev/null +++ b/tests/reattach/stubs/android/os/RemoteException.java @@ -0,0 +1,5 @@ +package android.os; +public class RemoteException extends Exception { + public RemoteException() {} + public RemoteException(String message) { super(message); } +} diff --git a/tests/reattach/stubs/android/util/ArraySet.java b/tests/reattach/stubs/android/util/ArraySet.java new file mode 100644 index 000000000..e175202f9 --- /dev/null +++ b/tests/reattach/stubs/android/util/ArraySet.java @@ -0,0 +1,2 @@ +package android.util; +public class ArraySet extends java.util.HashSet {} diff --git a/tests/reattach/stubs/android/util/Log.java b/tests/reattach/stubs/android/util/Log.java new file mode 100644 index 000000000..33865b713 --- /dev/null +++ b/tests/reattach/stubs/android/util/Log.java @@ -0,0 +1,15 @@ +package android.util; +public class Log { + public static Throwable lastFailure; + public static int d(String tag, String message) { return 0; } + public static int d(String tag, String message, Throwable t) { return 0; } + public static int v(String tag, String message) { return 0; } + public static int v(String tag, String message, Throwable t) { return 0; } + public static int i(String tag, String message) { return 0; } + public static int i(String tag, String message, Throwable t) { return 0; } + public static int w(String tag, String message) { return 0; } + public static int w(String tag, String message, Throwable t) { return 0; } + public static int e(String tag, String message) { return 0; } + public static int e(String tag, String message, Throwable t) { lastFailure = t; return 0; } + public static int println(int priority, String tag, String message) { return 0; } +} diff --git a/tests/reattach/stubs/hidden/HiddenApiBridge.java b/tests/reattach/stubs/hidden/HiddenApiBridge.java new file mode 100644 index 000000000..900f931ee --- /dev/null +++ b/tests/reattach/stubs/hidden/HiddenApiBridge.java @@ -0,0 +1,7 @@ +package hidden; +import android.os.IBinder; +import android.content.Context; +public class HiddenApiBridge { + public static IBinder Binder_allowBlocking(IBinder binder) { return binder; } + public static IBinder Context_getActivityToken(Context context) { return null; } +} diff --git a/tests/reattach/stubs/org/matrix/vector/daemon/data/ConfigCache.java b/tests/reattach/stubs/org/matrix/vector/daemon/data/ConfigCache.java new file mode 100644 index 000000000..00a000550 --- /dev/null +++ b/tests/reattach/stubs/org/matrix/vector/daemon/data/ConfigCache.java @@ -0,0 +1,14 @@ +package org.matrix.vector.daemon.data; +import java.util.*; +import org.matrix.vector.ipc.LoadedModule; +/** Configuration I/O is controlled; the registry and client under test are production classes. */ +public class ConfigCache { + public static final ConfigCache INSTANCE = new ConfigCache(); + public final Map modules = new HashMap<>(); + public boolean ready = true; + public DaemonState getState() { + return new DaemonState(false, ready, -1, null, modules, Map.of(), Map.of()); + } + public void ensureCacheReady$daemon() {} + public List getModulesForSystemServer() { return new ArrayList<>(modules.values()); } +} diff --git a/tests/reattach/stubs/org/matrix/vector/daemon/data/ModuleDatabase.java b/tests/reattach/stubs/org/matrix/vector/daemon/data/ModuleDatabase.java new file mode 100644 index 000000000..af89373ef --- /dev/null +++ b/tests/reattach/stubs/org/matrix/vector/daemon/data/ModuleDatabase.java @@ -0,0 +1,14 @@ +package org.matrix.vector.daemon.data; +import java.util.*; +public class ModuleDatabase { + public static final ModuleDatabase INSTANCE = new ModuleDatabase(); + public final Set scopes = new HashSet<>(); + public static class EnabledModuleRow { + private final String name; + public EnabledModuleRow(String name, String ignoredPath) { this.name = name; } + public String getPackageName() { return name; } + } + public List systemServerModuleRows() { + return scopes.stream().map(name -> new EnabledModuleRow(name, null)).toList(); + } +} 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 1acefd69f..355de5824 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorContext.kt @@ -59,12 +59,33 @@ private val artMethodField: Field? by lazy { class VectorContext( private val packageName: String, private val applicationInfo: ApplicationInfo, - private val service: IModuleService, + @Volatile private var service: IModuleService, // What ExceptionMode.DEFAULT resolves to for this module, from module.prop. private val defaultExceptionMode: ExceptionMode = ExceptionMode.PROTECTIVE, ) : XposedInterface { private val remotePrefs = ConcurrentHashMap() + private val serviceLock = Any() + + // Reconnection is serialized by VectorServiceClient. Take a snapshot under the creation lock, + // then release it before IPC or listener callbacks, which may ask this context for preferences. + internal fun reconnectService(replacement: IModuleService): Boolean { + val preferences = + synchronized(serviceLock) { + service = replacement + remotePrefs.values.toList() + } + var restored = true + preferences.forEach { + runCatching { (it as VectorRemotePreferences).reconnect(replacement) } + .onFailure { + restored = false + Log.e(TAG, "Cannot reconnect remote preferences for $packageName", it) + } + } + return restored + } + @Volatile private var frozen = false fun freeze() { @@ -163,7 +184,9 @@ class VectorContext( override fun getModuleApplicationInfo(): ApplicationInfo = applicationInfo override fun getRemotePreferences(name: String): SharedPreferences { - return remotePrefs.getOrPut(name) { VectorRemotePreferences(service, name) } + return synchronized(serviceLock) { + remotePrefs.getOrPut(name) { VectorRemotePreferences(service, name) } + } } override fun listRemoteFiles(): Array { 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 a29e9b355..8027533fa 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/VectorRemotePreferences.kt @@ -3,6 +3,7 @@ package org.matrix.vector.impl import android.content.SharedPreferences import android.os.Bundle import android.os.RemoteException +import android.os.IBinder import android.util.ArraySet import io.github.libxposed.api.error.XposedFrameworkError import java.util.TreeMap @@ -21,65 +22,115 @@ private inline fun Bundle.getSerializableCompat(key: String): T? { } @Suppress("UNCHECKED_CAST") -internal class VectorRemotePreferences(service: IModuleService, group: String) : +internal class VectorRemotePreferences(service: IModuleService, private val group: String) : SharedPreferences { private val map = ConcurrentHashMap() private val listeners = mutableSetOf() - private val callback = - object : IRemotePreferenceCallback.Stub() { - @Synchronized - override fun onRemotePreferencesChanged(bundle: Bundle) { - val changes = ArraySet() + private val subscriptionLock = Any() + private var subscription: Subscription? = null - // Sent for edit().clear() and for deleteRemotePreferences. Without this the cache - // keeps serving values the module app already removed. - if (bundle.getBoolean("clear", false)) { - changes.addAll(map.keys) - map.clear() - } + private inner class Subscription(val binder: IBinder) : IRemotePreferenceCallback.Stub() { + var ready = false + val pending = ArrayList() - if (bundle.containsKey("delete")) { - val deletes = - bundle.getSerializableCompat>("delete") as? Set - if (deletes != null) { - changes.addAll(deletes) - for (key in deletes) { - map.remove(key) - } + override fun onRemotePreferencesChanged(bundle: Bundle) { + val changes = + synchronized(subscriptionLock) { + if (subscription !== this) return + if (!ready) { + pending.add(Bundle(bundle)) + return } + applyChanges(bundle) } + notifyChanges(changes) + } + } - if (bundle.containsKey("put")) { - val puts = - bundle.getSerializableCompat>("put") as? Map - if (puts != null) { - map.putAll(puts) - changes.addAll(puts.keys) - } - } + private fun applyChanges(bundle: Bundle): Set { + val changes = ArraySet() - synchronized(listeners) { - for (key in changes) { - listeners.forEach { listener -> - listener.onSharedPreferenceChanged(this@VectorRemotePreferences, key) - } - } + // Sent for edit().clear() and for deleteRemotePreferences. Without this the cache + // keeps serving values the module app already removed. + if (bundle.getBoolean("clear", false)) { + changes.addAll(map.keys) + map.clear() + } + + if (bundle.containsKey("delete")) { + val deletes = bundle.getSerializableCompat>("delete") as? Set + if (deletes != null) { + changes.addAll(deletes) + for (key in deletes) { + map.remove(key) } } } - init { - try { - val output = service.requestRemotePreferences(group, callback) - if (output.containsKey("map")) { - val initialMap = - output.getSerializableCompat>("map") as? Map - if (initialMap != null) { - map.putAll(initialMap) + if (bundle.containsKey("put")) { + val puts = bundle.getSerializableCompat>("put") as? Map + if (puts != null) { + map.putAll(puts) + changes.addAll(puts.keys) + } + } + + return changes + } + + private fun notifyChanges(changes: Set) { + val snapshot = synchronized(listeners) { listeners.toList() } + for (key in changes) { + snapshot.forEach { listener -> + listener.onSharedPreferenceChanged(this, key) + } + } + } + + internal fun reconnect(service: IModuleService) { + val next = Subscription(service.asBinder()) + val previous = + synchronized(subscriptionLock) { + val old = subscription + if (old?.binder == next.binder && old.ready) return + subscription = next + old + } + val changes = + try { + val output = service.requestRemotePreferences(group, next) + synchronized(subscriptionLock) { + if (subscription !== next) return + val before = HashMap(map) + val initial = output.getSerializableCompat>("map").orEmpty() + map.clear() + map.putAll(initial) + // Updates may arrive while the initial snapshot is in flight. Apply them after + // it. + next.pending.forEach { applyChanges(it) } + next.pending.clear() + next.ready = true + (before.keys + map.keys).filterTo(mutableSetOf()) { before[it] != map[it] } + } + } catch (failure: Throwable) { + synchronized(subscriptionLock) { + if (subscription === next) subscription = previous } + throw failure } + // The binding has succeeded. A module listener throwing must not roll it back or cause + // the bridge to register this subscription again on the next handshake. + runCatching { notifyChanges(changes) } + .onFailure { + Log.e("VectorContext", "Remote preference listener failed for $group", it) + } + } + + init { + try { + reconnect(service) } catch (e: RemoteException) { Log.e("VectorContext", "Failed to request remote preferences for group: $group", e) throw XposedFrameworkError("Remote preferences IPC failure", e) 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 ae03bf278..69517ab5d 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 @@ -16,6 +16,7 @@ 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.matrix.vector.ipc.IVectorDaemon import org.matrix.vector.util.Log import org.matrix.vector.impl.VectorContext import org.matrix.vector.impl.VectorLifecycleManager @@ -39,6 +40,8 @@ object VectorModuleManager { entries: List, val isSystemServer: Boolean, val processName: String, + val loadedVersionCode: Long, + val hotReloadable: Boolean, ) { private val entryRefs = entries.map { WeakReference(it) } @@ -52,11 +55,35 @@ object VectorModuleManager { private val reloadLocks = ConcurrentHashMap() /** The class loader a loaded module runs in, or null if no generation is loaded for it. */ - fun getModuleClassLoader(packageName: String): ClassLoader? = generations[packageName]?.classLoader + fun getModuleClassLoader(packageName: String): ClassLoader? = + generations[packageName]?.classLoader /** Packages of the modules currently loaded in this process. */ fun loadedModulePackages(): Set = generations.keys.toSet() + internal fun reconnectSystemServerServices(daemon: IVectorDaemon): Boolean { + var restored = true + generations.forEach { (packageName, generation) -> + if (!generation.isSystemServer) return@forEach + runCatching { + val replacement = + daemon.reconnectSystemModule( + packageName, + generation.loadedVersionCode, + generation.hotReloadable, + ) + if (replacement != null && generations[packageName] === generation) { + if (!generation.context.reconnectService(replacement)) restored = false + } + } + .onFailure { + restored = false + Log.e(TAG, "Cannot reconnect module service for $packageName", it) + } + } + return restored + } + /** * Loads a module APK, instantiates its entry classes, and binds them to the Vector framework. */ @@ -195,7 +222,15 @@ object VectorModuleManager { } val generation = - Generation(moduleClassLoader, vectorContext, entries, isSystemServer, processName) + Generation( + moduleClassLoader, + vectorContext, + entries, + isSystemServer, + processName, + module.versionCode, + module.code.moduleClassNames.size == 1, + ) return generation to entries } catch (e: Throwable) { Log.e(TAG, "Fatal error loading module ${module.packageName}", e) 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 33710418e..0c881cec2 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 @@ -5,52 +5,94 @@ import android.os.ParcelFileDescriptor import org.matrix.vector.ipc.LoadedModule import org.matrix.vector.ipc.IProcessChannel import org.matrix.vector.ipc.IFrameworkService +import org.matrix.vector.ipc.IVectorDaemon import org.matrix.vector.util.Log /** * Singleton client for managing IPC communication with the injected manager service. Handles Binder * death gracefully and ensures safe remote execution. */ -object VectorServiceClient : IFrameworkService, IBinder.DeathRecipient { +object VectorServiceClient : IFrameworkService { private const val TAG = "VectorServiceClient" - private var service: IFrameworkService? = null + private class Connection(val service: IFrameworkService) : IBinder.DeathRecipient { + val binder: IBinder = service.asBinder() + var restored = false + + override fun binderDied() { + synchronized(VectorServiceClient) { + // A queued death notification from the old daemon must not clear its replacement. + if (connection === this) connection = null + } + unlink() + } + + fun unlink() { + runCatching { binder.unlinkToDeath(this, 0) } + } + } + + @Volatile private var connection: Connection? = null + private val service: IFrameworkService? + get() = connection?.service + var processName: String = "" private set @Synchronized fun init(appService: IFrameworkService?, niceName: String) { - val binder = appService?.asBinder() - if (service == null && binder != null) { - runCatching { - service = appService - processName = niceName - binder.linkToDeath(this, 0) - } - .onFailure { - 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) - } - } + if (connection != null || appService == null) return + if (connect(appService, niceName)) { + connection?.let { it.restored = attachChannel(it.service, VectorProcessChannel) } } } + @Synchronized + private fun connect(appService: IFrameworkService, niceName: String): Boolean { + val next = Connection(appService) + return runCatching { + next.binder.linkToDeath(next, 0) + check(next.binder.isBinderAlive) { "Framework service already died" } + val previous = connection + connection = next + processName = niceName + previous?.unlink() + true + } + .getOrElse { + next.unlink() + Log.e(TAG, "Cannot connect framework service in process: $niceName", it) + false + } + } + + /** Rebind existing objects; never run module entry points or install hooks a second time. */ + @Synchronized + fun reconnectSystemServer(appService: IFrameworkService, daemon: IVectorDaemon): Boolean { + if (service?.asBinder() != appService.asBinder() && !connect(appService, "system")) + return false + val current = connection ?: return false + if (!current.binder.isBinderAlive) return false + if (current.restored) return true + // An interrupted restore is retried by the existing bridge send loop. Successful module + // subscriptions are idempotent; no module entry points or hook installation are replayed. + if (!VectorModuleManager.reconnectSystemServerServices(daemon)) return false + current.restored = attachChannel(current.service, VectorProcessChannel) + return current.restored + } + override fun attachProcessChannel(channel: IProcessChannel?) { + service?.let { attachChannel(it, channel) } + } + + private fun attachChannel(service: IFrameworkService, channel: IProcessChannel?): Boolean { try { - service?.attachProcessChannel(channel) + service.attachProcessChannel(channel) + return true } catch (t: Throwable) { Log.e(TAG, "Failed to attach the process channel", t) + return false } } @@ -81,9 +123,4 @@ object VectorServiceClient : IFrameworkService, IBinder.DeathRecipient { override fun asBinder(): IBinder? { return service?.asBinder() } - - override fun binderDied() { - service?.asBinder()?.unlinkToDeath(this, 0) - service = null - } } diff --git a/zygisk/src/main/cpp/include/ipc_bridge.h b/zygisk/src/main/cpp/include/ipc_bridge.h index 748840671..f9ca48da6 100644 --- a/zygisk/src/main/cpp/include/ipc_bridge.h +++ b/zygisk/src/main/cpp/include/ipc_bridge.h @@ -47,10 +47,10 @@ class IPCBridge { lsplant::ScopedLocalRef RequestAppBinder(JNIEnv *env, jstring nice_name); /** - * @brief Requests the system_server's dedicated Binder from the host service. + * @brief Waits for the rendezvous and completes system_server's framework handshake. * @param env JNI environment pointer. * @param bridgeServiceName rendezvous point used by the system_server - * @return A ScopedLocalRef to the Binder object, or nullptr on failure. + * @return The registered framework Binder, or nullptr on failure. */ lsplant::ScopedLocalRef RequestSystemServerBinder(JNIEnv *env, std::string bridgeServiceName); diff --git a/zygisk/src/main/cpp/ipc_bridge.cpp b/zygisk/src/main/cpp/ipc_bridge.cpp index bc19adfd8..3a4555049 100644 --- a/zygisk/src/main/cpp/ipc_bridge.cpp +++ b/zygisk/src/main/cpp/ipc_bridge.cpp @@ -309,14 +309,20 @@ lsplant::ScopedLocalRef IPCBridge::RequestSystemServerBinder( // The daemon process and system_server specialization run in parallel. // On slower devices, the daemon may take several seconds to call addService. - // We poll for up to 10 seconds to ensure the early IPC channel for system_server is available. + // Wait for a successful Vector handshake, not merely a service with the expected name. + // During a restart the name may still refer to an old or ordinary Android service. const int max_retry = 10; for (int i = 0; i < max_retry; ++i) { binder = lsplant::JNI_CallStaticObjectMethod(env, service_manager_class_, get_service_method_, service_name.get()); - if (binder) { - LOGI("Got system server binder via {} on attempt {}.", bridgeServiceName.data(), i + 1); - return binder; + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } else if (binder) { + auto framework = RequestManagerBinderFromSystemServer(env, binder.get()); + if (framework) { + LOGI("Got system server framework via {} on attempt {}.", bridgeServiceName.data(), i + 1); + return framework; + } } LOGW("Failed to get system server binder via {}, will retry in 1 second...", bridgeServiceName.data()); @@ -347,6 +353,10 @@ lsplant::ScopedLocalRef IPCBridge::RequestManagerBinderFromSystemServer parcels.reply.get(), 0); lsplant::ScopedLocalRef result_binder = {env, nullptr}; + if (env->ExceptionCheck()) { + env->ExceptionClear(); + return result_binder; + } if (success) { lsplant::JNI_CallVoidMethod(env, parcels.reply.get(), read_exception_method_); if (env->ExceptionCheck()) { diff --git a/zygisk/src/main/cpp/module.cpp b/zygisk/src/main/cpp/module.cpp index 84ff88312..b61ee8ade 100644 --- a/zygisk/src/main/cpp/module.cpp +++ b/zygisk/src/main/cpp/module.cpp @@ -418,19 +418,14 @@ void VectorModule::postServerSpecialize(const zygisk::ServerSpecializeArgs *args std::string bridgeServiceName = "serial"; bool is_late_inject = (args->runtime_flags & RuntimeFlags::LATE_INJECT) != 0; if (is_late_inject) bridgeServiceName = "serial_vector"; - auto system_binder = ipc_bridge.RequestSystemServerBinder(env_, bridgeServiceName); - if (!system_binder) { + auto manager_binder = ipc_bridge.RequestSystemServerBinder(env_, bridgeServiceName); + if (!manager_binder) { LOGE("Failed to get system server IPC binder. Aborting injection."); SetAllowUnload(true); // Allow unload on failure. return; } - auto manager_binder = - ipc_bridge.RequestManagerBinderFromSystemServer(env_, system_binder.get()); - - // Use either the direct manager binder if available, - // otherwise proxy through the system binder. - jobject effective_binder = manager_binder ? manager_binder.get() : system_binder.get(); + jobject effective_binder = manager_binder.get(); auto [dex_fd, dex_size] = ipc_bridge.FetchFrameworkDex(env_, effective_binder); if (dex_fd < 0) { 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 09c7bd7ac..889b094aa 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/service/BridgeService.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/service/BridgeService.kt @@ -5,10 +5,12 @@ import android.os.Binder import android.os.IBinder import android.os.IBinder.DeathRecipient import android.os.Parcel +import android.os.Process import hidden.HiddenApiBridge.Binder_allowBlocking import hidden.HiddenApiBridge.Context_getActivityToken import org.matrix.vector.ipc.IVectorDaemon import org.matrix.vector.util.Log +import org.matrix.vector.impl.core.VectorServiceClient /** * Manages manual Binder transactions for the Vector framework. @@ -28,59 +30,76 @@ object BridgeService { GET_BINDER, // Process requesting its specific application service } - @Volatile private var serviceBinder: IBinder? = null + private class Connection(val binder: IBinder) : DeathRecipient { + val service: IVectorDaemon = IVectorDaemon.Stub.asInterface(binder) - @Volatile private var service: IVectorDaemon? = null + override fun binderDied() { + synchronized(BridgeService) { + if (connection === this) connection = null + } + unlink() + } - /** Cleans up service references if the remote Vector daemon crashes. */ - private val serviceRecipient: DeathRecipient = DeathRecipient { - Log.e(TAG, "Vector daemin service died.") - serviceBinder?.unlinkToDeath(this.serviceRecipient, 0) - serviceBinder = null - service = null + fun unlink() { + runCatching { binder.unlinkToDeath(this, 0) } + } } + @Volatile private var connection: Connection? = null + private val connectedService: IVectorDaemon? + get() = connection?.service + + private val systemServerLifeToken = Binder() + /** Returns the active Vector daemin service interface. */ - @JvmStatic fun getService(): IVectorDaemon? = service + @JvmStatic fun getService(): IVectorDaemon? = connectedService /** * Initializes the client-side connection to the Vector daemin service. * * @param binder The raw binder for [IVectorDaemon]. */ - private fun receiveFromBridge(binder: IBinder?) { + @Synchronized + private fun receiveFromBridge(binder: IBinder?): Boolean { if (binder == null) { Log.e(TAG, "Received null binder from bridge.") - return + return false } - - // Cleanup old death recipient if we are re-initializing + // This transaction came from root. All nested calls must identify system_server itself. val token = Binder.clearCallingIdentity() + val next = Connection(Binder_allowBlocking(binder)) try { - serviceBinder?.unlinkToDeath(serviceRecipient, 0) + if (connection?.binder == binder && binder.isBinderAlive) return true + binder.linkToDeath(next, 0) + val framework = + next.service.attachProcess( + Process.myUid(), + Process.myPid(), + "system", + systemServerLifeToken, + ) ?: return false + if (!VectorServiceClient.reconnectSystemServer(framework, next.service)) return false + + val activityThread = ActivityThread.currentActivityThread() + val at = activityThread.applicationThread as android.app.IApplicationThread + val systemCtx = activityThread.systemContext + next.service.dispatchSystemServerContext( + at.asBinder(), + Context_getActivityToken(systemCtx), + ) + check(binder.isBinderAlive) { "Daemon died during system server reattachment" } + val previous = connection + connection = next + previous?.unlink() + Log.i(TAG, "Vector daemon service binder linked; system server registered.") + return true + } catch (failure: Throwable) { + Log.e(TAG, "Cannot reattach system server to Vector daemon", failure) + return false } finally { + if (connection !== next) next.unlink() Binder.restoreCallingIdentity(token) } - - // Allow blocking calls since we are often in a synchronous fork path - val blockingBinder = Binder_allowBlocking(binder) - serviceBinder = blockingBinder - service = IVectorDaemon.Stub.asInterface(blockingBinder) - - runCatching { blockingBinder.linkToDeath(serviceRecipient, 0) } - .onFailure { Log.e(TAG, "Failed to link to service death", it) } - - // Provide the system context to the service so it can manage system-wide states - runCatching { - val activityThread = ActivityThread.currentActivityThread() - val at = activityThread.applicationThread as android.app.IApplicationThread - val atBinder = at.asBinder() - val systemCtx = activityThread.systemContext - service?.dispatchSystemServerContext(atBinder, Context_getActivityToken(systemCtx)) - } - .onFailure { Log.e(TAG, "Failed to dispatch system context", it) } - - Log.i(TAG, "Vector daemin service binder linked.") } /** Handles manual parcel transactions. Called via reflection/JNI from the native hook. */ @@ -96,9 +115,9 @@ object BridgeService { Action.SEND_BINDER -> { // Only allow root (UID 0) to push the initial service binder if (Binder.getCallingUid() == 0) { - receiveFromBridge(data.readStrongBinder()) + val attached = receiveFromBridge(data.readStrongBinder()) reply?.writeNoException() - true + attached } else false } @@ -106,7 +125,7 @@ object BridgeService { val processName = data.readString() val heartBeat = data.readStrongBinder() val appService = - service?.attachProcess( + connectedService?.attachProcess( Binder.getCallingUid(), Binder.getCallingPid(), processName,