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