diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml
index cf517579..936762e4 100644
--- a/.github/workflows/build-debug.yml
+++ b/.github/workflows/build-debug.yml
@@ -123,7 +123,7 @@ jobs:
disk-size: 4096M
heap-size: 512M
disable-animations: true
- script: ./gradlew connectedDebugAndroidTest --no-daemon
+ script: ./gradlew connectedDebugAndroidTest --no-daemon -Pandroid.testInstrumentationRunnerArguments.notClass=io.netbird.client.NetworkConnectivityStressTest
- name: Upload test results
if: always()
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 54fa8554..febc2632 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -45,6 +45,7 @@ android {
versionName = rootProject.extra["appVersionName"] as String
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunnerArguments["timeout_msec"] = "3600000"
}
buildTypes {
@@ -80,6 +81,8 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
+ androidTestImplementation(libs.uiautomator)
+ androidTestImplementation(libs.test.rules)
implementation(libs.browser) // Added for CustomTabsIntent
implementation(libs.lottie)
implementation(libs.zxing)
diff --git a/app/src/androidTest/README.md b/app/src/androidTest/README.md
new file mode 100644
index 00000000..71c504a9
--- /dev/null
+++ b/app/src/androidTest/README.md
@@ -0,0 +1,119 @@
+# Network Connectivity Stress Test
+
+Automated stress test that verifies the VPN recovers after real-world network disruptions.
+The test randomly picks disruption scenarios, applies them, waits a random amount of time,
+restores connectivity, then verifies VPN recovery by pinging a peer through the tunnel.
+
+## Prerequisites
+
+- The app must be **already logged in / authenticated**
+- A reachable VPN peer IP configured in `PING_TARGET` (default: `100.72.116.70`)
+- **Real device**: WiFi and mobile data (SIM card) available
+- **Emulator**: Copy auth token from `~/.emulator_console_auth_token` into `EMULATOR_AUTH_TOKEN` constant, or clear the file to disable auth
+
+## How to run
+
+```bash
+# 1. Build both APKs
+./gradlew assembleDebug assembleDebugAndroidTest
+
+# 2. Install them
+adb install -r -t app/build/outputs/apk/debug/app-debug.apk
+adb install -r -t app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
+
+# 3. Run the test directly via adb
+adb shell am instrument -w -e class io.netbird.client.NetworkConnectivityStressTest \
+ io.netbird.client.test/androidx.test.runner.AndroidJUnitRunner
+```
+
+> **Note**: Do not use `./gradlew connectedAndroidTest` — Gradle's Unified Test Platform
+> may reinstall the APK mid-test, killing the process during long-running disruption cycles.
+
+## Watching live progress
+
+In a separate terminal:
+
+```bash
+adb logcat -s NBConnStressTest:D
+```
+
+## Useful helper commands
+
+```bash
+# Simulate battery mode (realistic power management: Doze, app standby)
+adb shell dumpsys battery unplug
+
+# Restore charging state after test
+adb shell dumpsys battery reset
+
+# Manually test airplane mode
+adb shell cmd connectivity airplane-mode enable
+adb shell cmd connectivity airplane-mode disable
+
+# Manually test WiFi/mobile data
+adb shell svc wifi disable
+adb shell svc wifi enable
+adb shell svc data disable
+adb shell svc data enable
+```
+
+## Configuration
+
+Constants at the top of `NetworkConnectivityStressTest.java`:
+
+| Constant | Default | Description |
+|----------|---------|-------------|
+| `PING_TARGET` | `100.72.116.70` | VPN peer IP to ping for connectivity verification |
+| `NUM_CYCLES` | `20` | Number of random disruption cycles |
+| `VPN_RECOVERY_TIMEOUT_SEC` | `90` | Max seconds to wait for VPN to recover per cycle |
+| `SETTLE_DELAY_SEC` | `5` | Seconds to wait after network change before checking |
+| `MAX_RANDOM_EXTRA_WAIT_SEC` | `15` | Max random extra wait to simulate real-world timing |
+| `PING_TIMEOUT_SEC` | `5` | Timeout for a single ping attempt |
+| `EMULATOR_AUTH_TOKEN` | `""` | Emulator console auth token (emulator only) |
+| `EMULATOR_CONSOLE_PORT` | `5554` | Emulator console telnet port (emulator only) |
+
+## Real Device Scenarios
+
+The test auto-detects real device vs emulator and picks from the appropriate scenario set.
+
+| # | Scenario | Disruption | Restore |
+|---|----------|-----------|---------|
+| 1 | WiFi→Mobile (leave office) | Disable WiFi | Enable WiFi |
+| 2 | Mobile→WiFi (at home) | Disable mobile data | Enable mobile data |
+| 3 | All networks lost (tunnel/elevator) | WiFi off + mobile off + airplane on | Airplane off + WiFi on + mobile on |
+| 4 | WiFi reconnect (switch network) | WiFi off → 2s → WiFi on | (already restored) |
+| 5 | WiFi flapping (unstable) | 3x: WiFi off 1.5s → WiFi on 1.5s | Enable WiFi |
+| 6 | Airplane mode toggle | Airplane on → 5s → airplane off | WiFi on + mobile on |
+| 7 | Mobile data flapping | 3x: mobile off 1s → mobile on 1s | Enable mobile |
+| 8 | Long outage (30s no network) | WiFi off + mobile off + airplane on → 30s | Airplane off + WiFi on + mobile on |
+| 9 | Sequential network loss | WiFi off → 3s → mobile off | Mobile on → 2s → WiFi on |
+| 10 | Airplane mode flapping | 2x: airplane on 3s → airplane off 3s | Airplane off + WiFi on + mobile on |
+
+## Emulator Scenarios
+
+Uses the emulator console (`telnet 10.0.2.2:5554`) to control the virtual network.
+Falls back to `iptables` rules if the console is not reachable.
+
+| # | Scenario | Disruption | Restore |
+|---|----------|-----------|---------|
+| 1 | Network disconnect | Full network cut | Network connect |
+| 2 | Long outage (30s) | Network cut → 30s wait | Network connect |
+| 3 | Network flapping (3x) | 3x: disconnect 1.5s → connect 1.5s | Network connect |
+| 4 | Extreme latency (10s delay) | 10s network delay | Delay 0 |
+| 5 | GPRS speed throttle | Speed → GSM | Speed → full |
+| 6 | Disconnect then slow reconnect | Disconnect → 5s → connect at GSM → 5s | Speed → full |
+| 7 | Flap then long disconnect (20s) | Disconnect → connect → disconnect → 20s | Network connect |
+| 8 | Latency flapping | 3x: 5s delay 2s → 0 delay 2s | Delay 0 |
+| 9 | Speed degradation cycle | HSDPA → 3s → EDGE → 3s → GSM → 5s | Speed → full |
+| 10 | Disconnect + degraded recovery | Disconnect → 10s → connect with 3s delay + EDGE → 5s | Delay 0 + speed full |
+
+## Test output
+
+The test prints a summary at the end:
+
+```text
+=== Results: 18/20 passed, 2 failed ===
+```
+
+If any cycle fails, the test fails with a message indicating how many cycles did not recover.
+Full details are in logcat under tag `NBConnStressTest`.
diff --git a/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java b/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java
new file mode 100644
index 00000000..a7b1931b
--- /dev/null
+++ b/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java
@@ -0,0 +1,685 @@
+package io.netbird.client;
+
+import android.app.Instrumentation;
+import android.app.UiAutomation;
+import android.os.Build;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.ActivityTestRule;
+import androidx.test.uiautomator.UiDevice;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Connectivity stress test for VPN resilience.
+ *
+ * Automatically detects whether it is running on a real device or emulator and
+ * uses the appropriate network control strategy:
+ *
+ *
Real device
+ * Uses {@code svc wifi/data} and airplane mode shell commands to toggle
+ * WiFi, mobile data, and airplane mode independently.
+ *
+ * Emulator
+ * Uses the emulator console (telnet to {@code 10.0.2.2:5554}) to run
+ * {@code network disconnect / network connect} and {@code network delay / speed}
+ * commands that actually cut the virtual network at the host level.
+ *
+ * Prerequisites
+ *
+ * - The app must be already logged in / authenticated
+ * - A reachable VPN peer IP must be configured in {@link #PING_TARGET}
+ * - Real device: both WiFi and mobile data available;
+ * grant {@code adb shell pm grant io.netbird.client android.permission.WRITE_SECURE_SETTINGS}
+ * - Emulator: copy the auth token from {@code ~/.emulator_console_auth_token}
+ * into the {@link #EMULATOR_AUTH_TOKEN} constant, or clear the file to disable auth
+ *
+ *
+ * Run with
+ *
+ * ./gradlew connectedAndroidTest \
+ * -Pandroid.testInstrumentationRunnerArguments.class=io.netbird.client.NetworkConnectivityStressTest
+ *
+ */
+@RunWith(AndroidJUnit4.class)
+public class NetworkConnectivityStressTest {
+
+ private static final String TAG = "NBConnStressTest";
+
+ // --- Configuration -----------------------------------------------------------
+
+ /** IP to ping through the VPN tunnel to verify connectivity. */
+ private static final String PING_TARGET = "100.72.116.70";
+
+ /** How many random disruption cycles to run. */
+ private static final int NUM_CYCLES = 20;
+
+ /** Maximum seconds to wait for VPN to recover after a disruption. */
+ private static final int VPN_RECOVERY_TIMEOUT_SEC = 90;
+
+ /** Seconds to wait after network state change before checking recovery. */
+ private static final int SETTLE_DELAY_SEC = 5;
+
+ /** Max random extra wait (seconds) to simulate variable real-world timing. */
+ private static final int MAX_RANDOM_EXTRA_WAIT_SEC = 15;
+
+ /** Ping timeout in seconds for a single ping attempt. */
+ private static final int PING_TIMEOUT_SEC = 5;
+
+ /**
+ * Emulator console auth token. On the host machine run:
+ * {@code cat ~/.emulator_console_auth_token}
+ * and paste the value here. If the file is empty, leave this empty too.
+ */
+ private static final String EMULATOR_AUTH_TOKEN = "";
+
+ /** Emulator console port (default 5554 for first emulator instance). */
+ private static final int EMULATOR_CONSOLE_PORT = 5554;
+
+ // --- End configuration -------------------------------------------------------
+
+ private UiDevice device;
+ private UiAutomation uiAutomation;
+ private Random random;
+ private boolean isEmulator;
+ private final List testLog = new ArrayList<>();
+
+ @SuppressWarnings("deprecation")
+ @Rule
+ public ActivityTestRule activityRule =
+ new ActivityTestRule<>(MainActivity.class, true, true);
+
+ @Before
+ public void setUp() throws Exception {
+ Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
+ device = UiDevice.getInstance(instrumentation);
+ uiAutomation = instrumentation.getUiAutomation();
+ random = new Random();
+ isEmulator = detectEmulator();
+
+ log("=== NetworkConnectivityStressTest started ===");
+ log("Platform: " + (isEmulator ? "EMULATOR" : "REAL DEVICE"));
+ log("Device: " + Build.MANUFACTURER + " " + Build.MODEL);
+ log("Ping target: " + PING_TARGET);
+ log("Cycles: " + NUM_CYCLES);
+ log("Recovery timeout: " + VPN_RECOVERY_TIMEOUT_SEC + "s");
+
+ // Ensure we start with a clean network state
+ restoreAllNetworks();
+ Thread.sleep(3000);
+
+ // Wait for initial VPN connection
+ log("Waiting for initial VPN connection...");
+ connectVpnViaUI();
+ assertTrue("VPN must be connected before stress test begins",
+ waitForPingSuccess(VPN_RECOVERY_TIMEOUT_SEC));
+ log("Initial VPN connection verified via ping");
+ }
+
+ @After
+ public void tearDown() {
+ log("=== Restoring network state ===");
+ try {
+ restoreAllNetworks();
+ } catch (Exception e) {
+ log("WARNING: Failed to restore network state: " + e.getMessage());
+ }
+
+ log("=== Test log summary ===");
+ for (String entry : testLog) {
+ Log.i(TAG, entry);
+ }
+ }
+
+ @Test
+ public void testVpnSurvivesRandomNetworkDisruptions() throws Exception {
+ int passCount = 0;
+ int failCount = 0;
+
+ for (int cycle = 1; cycle <= NUM_CYCLES; cycle++) {
+ DisruptionType disruption = pickRandomDisruption();
+ log(String.format("--- Cycle %d/%d: %s ---", cycle, NUM_CYCLES, disruption.name));
+
+ try {
+ // Apply disruption
+ disruption.apply.run();
+
+ // Random wait to simulate real-world timing variability
+ int extraWait = random.nextInt(MAX_RANDOM_EXTRA_WAIT_SEC + 1);
+ int totalWait = SETTLE_DELAY_SEC + extraWait;
+ log(String.format(" Disruption applied. Waiting %ds before recovery...", totalWait));
+ Thread.sleep(totalWait * 1000L);
+
+ // Restore connectivity
+ disruption.restore.run();
+ log(" Network restored. Waiting for VPN recovery...");
+ Thread.sleep(SETTLE_DELAY_SEC * 1000L);
+
+ // Verify VPN recovers
+ boolean recovered = waitForPingSuccess(VPN_RECOVERY_TIMEOUT_SEC);
+ if (recovered) {
+ passCount++;
+ log(String.format(" PASS - VPN recovered after %s", disruption.name));
+ } else {
+ failCount++;
+ log(String.format(" FAIL - VPN did NOT recover after %s (timeout %ds)",
+ disruption.name, VPN_RECOVERY_TIMEOUT_SEC));
+ }
+ } catch (Exception e) {
+ failCount++;
+ log(String.format(" ERROR during cycle %d: %s", cycle, e.getMessage()));
+ } finally {
+ try {
+ disruption.restore.run();
+ } catch (Exception restoreEx) {
+ log(String.format(" WARNING: restore failed after cycle %d: %s",
+ cycle, restoreEx.getMessage()));
+ }
+ }
+
+ // Brief pause between cycles
+ Thread.sleep(2000);
+ }
+
+ log(String.format("=== Results: %d/%d passed, %d failed ===",
+ passCount, NUM_CYCLES, failCount));
+
+ if (failCount > 0) {
+ fail(String.format("VPN failed to recover in %d out of %d disruption cycles. " +
+ "Check logcat tag '%s' for details.", failCount, NUM_CYCLES, TAG));
+ }
+ }
+
+ // --- Disruption scenarios ----------------------------------------------------
+
+ private DisruptionType pickRandomDisruption() {
+ if (isEmulator) {
+ return pickEmulatorDisruption();
+ }
+ return pickRealDeviceDisruption();
+ }
+
+ private DisruptionType pickRealDeviceDisruption() {
+ DisruptionType[] types = {
+ // Scenario 1: WiFi off -> mobile only (leaving office)
+ new DisruptionType("WiFi->Mobile (leave office)",
+ this::disableWifi,
+ this::enableWifi),
+
+ // Scenario 2: Mobile off -> WiFi only (common at home)
+ new DisruptionType("Mobile->WiFi (at home)",
+ this::disableMobileData,
+ this::enableMobileData),
+
+ // Scenario 3: All connectivity lost temporarily (elevator/tunnel)
+ new DisruptionType("All networks lost (tunnel/elevator)",
+ this::enableAirplaneMode,
+ this::restoreAllNetworks),
+
+ // Scenario 4: WiFi switch (disconnect from one, connect to another)
+ new DisruptionType("WiFi reconnect (switch network)",
+ () -> { disableWifi(); sleep(2000); enableWifi(); },
+ () -> { /* already restored */ }),
+
+ // Scenario 5: Rapid WiFi flapping (unstable connection)
+ new DisruptionType("WiFi flapping (unstable)",
+ () -> {
+ for (int i = 0; i < 3; i++) {
+ disableWifi();
+ sleep(1500);
+ enableWifi();
+ sleep(1500);
+ }
+ },
+ this::enableWifi),
+
+ // Scenario 6: Airplane mode toggle (quick on/off)
+ new DisruptionType("Airplane mode toggle",
+ () -> { enableAirplaneMode(); sleep(5000); disableAirplaneMode(); },
+ () -> { enableWifi(); enableMobileData(); }),
+
+ // Scenario 7: Mobile data flapping
+ new DisruptionType("Mobile data flapping",
+ () -> {
+ for (int i = 0; i < 3; i++) {
+ disableMobileData();
+ sleep(1000);
+ enableMobileData();
+ sleep(1000);
+ }
+ },
+ this::enableMobileData),
+
+ // Scenario 8: Long network outage (30+ seconds with no network)
+ new DisruptionType("Long outage (30s no network)",
+ () -> { enableAirplaneMode(); sleep(30000); },
+ this::restoreAllNetworks),
+
+ // Scenario 9: WiFi off then mobile off then both back
+ new DisruptionType("Sequential network loss",
+ () -> { disableWifi(); sleep(3000); disableMobileData(); },
+ () -> { enableMobileData(); sleep(2000); enableWifi(); }),
+
+ // Scenario 10: Rapid airplane mode flapping
+ new DisruptionType("Airplane mode flapping",
+ () -> {
+ for (int i = 0; i < 2; i++) {
+ enableAirplaneMode();
+ sleep(3000);
+ disableAirplaneMode();
+ sleep(3000);
+ }
+ },
+ this::restoreAllNetworks),
+ };
+
+ return types[random.nextInt(types.length)];
+ }
+
+ private DisruptionType pickEmulatorDisruption() {
+ DisruptionType[] types = {
+ // Scenario 1: Full network disconnect (like losing all signal)
+ new DisruptionType("EMU: Network disconnect",
+ this::emuNetworkDisconnect,
+ this::emuNetworkConnect),
+
+ // Scenario 2: Network disconnect with long outage
+ new DisruptionType("EMU: Long outage (30s)",
+ () -> { emuNetworkDisconnect(); sleep(30000); },
+ this::emuNetworkConnect),
+
+ // Scenario 3: Rapid connect/disconnect flapping
+ new DisruptionType("EMU: Network flapping (3x)",
+ () -> {
+ for (int i = 0; i < 3; i++) {
+ emuNetworkDisconnect();
+ sleep(1500);
+ emuNetworkConnect();
+ sleep(1500);
+ }
+ },
+ this::emuNetworkConnect),
+
+ // Scenario 4: Extreme latency (simulates very poor connection)
+ new DisruptionType("EMU: Extreme latency (10s delay)",
+ () -> emuNetworkDelay("10000"),
+ () -> emuNetworkDelay("0")),
+
+ // Scenario 5: Very slow speed (GPRS-level)
+ new DisruptionType("EMU: GPRS speed throttle",
+ () -> emuNetworkSpeed("gsm"),
+ () -> emuNetworkSpeed("full")),
+
+ // Scenario 6: Disconnect then slow reconnect
+ new DisruptionType("EMU: Disconnect then slow reconnect",
+ () -> {
+ emuNetworkDisconnect();
+ sleep(5000);
+ emuNetworkConnect();
+ emuNetworkSpeed("gsm");
+ sleep(5000);
+ },
+ () -> { emuNetworkSpeed("full"); }),
+
+ // Scenario 7: Rapid flapping with long disconnect
+ new DisruptionType("EMU: Flap then long disconnect (20s)",
+ () -> {
+ emuNetworkDisconnect();
+ sleep(1000);
+ emuNetworkConnect();
+ sleep(1000);
+ emuNetworkDisconnect();
+ sleep(20000);
+ },
+ this::emuNetworkConnect),
+
+ // Scenario 8: High latency flapping
+ new DisruptionType("EMU: Latency flapping",
+ () -> {
+ for (int i = 0; i < 3; i++) {
+ emuNetworkDelay("5000");
+ sleep(2000);
+ emuNetworkDelay("0");
+ sleep(2000);
+ }
+ },
+ () -> emuNetworkDelay("0")),
+
+ // Scenario 9: Speed changes (LTE -> GPRS -> full)
+ new DisruptionType("EMU: Speed degradation cycle",
+ () -> {
+ emuNetworkSpeed("hsdpa");
+ sleep(3000);
+ emuNetworkSpeed("edge");
+ sleep(3000);
+ emuNetworkSpeed("gsm");
+ sleep(5000);
+ },
+ () -> emuNetworkSpeed("full")),
+
+ // Scenario 10: Combined disconnect + slow recovery
+ new DisruptionType("EMU: Disconnect + degraded recovery",
+ () -> {
+ emuNetworkDisconnect();
+ sleep(10000);
+ emuNetworkConnect();
+ emuNetworkDelay("3000");
+ emuNetworkSpeed("edge");
+ sleep(5000);
+ },
+ () -> { emuNetworkDelay("0"); emuNetworkSpeed("full"); }),
+ };
+
+ return types[random.nextInt(types.length)];
+ }
+
+ // --- Real device network control ---------------------------------------------
+
+ private void enableWifi() {
+ executeShellCommand("svc wifi enable");
+ }
+
+ private void disableWifi() {
+ executeShellCommand("svc wifi disable");
+ }
+
+ private void enableMobileData() {
+ executeShellCommand("svc data enable");
+ }
+
+ private void disableMobileData() {
+ executeShellCommand("svc data disable");
+ }
+
+ private void enableAirplaneMode() {
+ // Explicitly disable WiFi and mobile data first — Android preserves
+ // WiFi state across airplane mode toggles if the user had it enabled
+ executeShellCommand("svc wifi disable");
+ executeShellCommand("svc data disable");
+ executeShellCommand("cmd connectivity airplane-mode enable");
+ }
+
+ private void disableAirplaneMode() {
+ executeShellCommand("cmd connectivity airplane-mode disable");
+ }
+
+ private void restoreAllNetworks() {
+ if (isEmulator) {
+ emuNetworkConnect();
+ emuNetworkDelay("0");
+ emuNetworkSpeed("full");
+ } else {
+ disableAirplaneMode();
+ enableWifi();
+ enableMobileData();
+ }
+ }
+
+ // --- Emulator console network control ----------------------------------------
+
+ private void emuNetworkDisconnect() {
+ sendEmulatorConsoleCommand("network disconnect");
+ }
+
+ private void emuNetworkConnect() {
+ sendEmulatorConsoleCommand("network connect");
+ }
+
+ /**
+ * Set network delay in milliseconds.
+ * Values: "0" (none), "500", "1000", "5000", "10000", etc.
+ */
+ private void emuNetworkDelay(String delayMs) {
+ sendEmulatorConsoleCommand("network delay " + delayMs);
+ }
+
+ /**
+ * Set network speed.
+ * Values: "gsm", "hscsd", "gprs", "edge", "umts", "hsdpa", "lte", "evdo", "full"
+ */
+ private void emuNetworkSpeed(String speed) {
+ sendEmulatorConsoleCommand("network speed " + speed);
+ }
+
+ /**
+ * Sends a command to the emulator console via telnet.
+ * The emulator console listens on 10.0.2.2:{@link #EMULATOR_CONSOLE_PORT} from
+ * inside the emulator (host loopback mapped address).
+ */
+ private void sendEmulatorConsoleCommand(String command) {
+ try (
+ // From inside the emulator, 10.0.2.2 is the host loopback
+ Socket socket = new Socket("10.0.2.2", EMULATOR_CONSOLE_PORT);
+ BufferedReader reader = new BufferedReader(
+ new InputStreamReader(socket.getInputStream()));
+ PrintWriter writer = new PrintWriter(socket.getOutputStream(), true)
+ ) {
+ socket.setSoTimeout(5000);
+
+ // Read the initial banner
+ readUntilOK(reader);
+
+ // Authenticate if token is set
+ if (!EMULATOR_AUTH_TOKEN.isEmpty()) {
+ writer.println("auth " + EMULATOR_AUTH_TOKEN);
+ readUntilOK(reader);
+ }
+
+ // Send the actual command
+ writer.println(command);
+ String response = readUntilOK(reader);
+ log(" EMU console: " + command + " -> " + response.trim());
+
+ writer.println("quit");
+ } catch (Exception e) {
+ log(" EMU console command failed: " + command + " - " + e.getMessage());
+ // Fallback: try via shell for basic disconnect/connect
+ if (command.equals("network disconnect")) {
+ executeShellCommand("iptables -A OUTPUT -j DROP");
+ executeShellCommand("iptables -A INPUT -j DROP");
+ } else if (command.equals("network connect")) {
+ executeShellCommand("iptables -F OUTPUT");
+ executeShellCommand("iptables -F INPUT");
+ }
+ }
+ }
+
+ private String readUntilOK(BufferedReader reader) throws IOException {
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ sb.append(line).append("\n");
+ if (line.contains("KO")) {
+ throw new IOException("Emulator console error: " + sb);
+ }
+ if (line.contains("OK")) {
+ return sb.toString();
+ }
+ }
+ throw new IOException("Emulator console EOF without OK: " + sb);
+ }
+
+ // --- Emulator detection ------------------------------------------------------
+
+ private boolean detectEmulator() {
+ boolean emulator =
+ Build.FINGERPRINT.startsWith("generic")
+ || Build.FINGERPRINT.startsWith("unknown")
+ || Build.MODEL.contains("google_sdk")
+ || Build.MODEL.contains("Emulator")
+ || Build.MODEL.contains("Android SDK built for x86")
+ || Build.MODEL.contains("sdk_gphone")
+ || Build.MANUFACTURER.contains("Genymotion")
+ || Build.BRAND.startsWith("generic")
+ || Build.DEVICE.startsWith("generic")
+ || "google_sdk".equals(Build.PRODUCT)
+ || Build.PRODUCT.contains("sdk")
+ || Build.PRODUCT.contains("emulator")
+ || Build.HARDWARE.contains("goldfish")
+ || Build.HARDWARE.contains("ranchu");
+ return emulator;
+ }
+
+ // --- VPN UI interaction ------------------------------------------------------
+
+ private void connectVpnViaUI() throws Exception {
+ MainActivity activity = activityRule.getActivity();
+
+ AtomicReference state = new AtomicReference<>(ConnectionState.UNKNOWN);
+ CountDownLatch connectedLatch = new CountDownLatch(1);
+
+ StateListener listener = new StateListenerAdapter() {
+ @Override
+ public void onConnected() {
+ state.set(ConnectionState.CONNECTED);
+ connectedLatch.countDown();
+ }
+
+ @Override
+ public void onDisconnected() {
+ state.set(ConnectionState.DISCONNECTED);
+ }
+ };
+
+ activity.runOnUiThread(() -> activity.registerServiceStateListener(listener));
+
+ // Check if already connected
+ if (pingOnce()) {
+ log("VPN already connected");
+ activity.runOnUiThread(() -> activity.unregisterServiceStateListener(listener));
+ return;
+ }
+
+ // Trigger connection
+ activity.runOnUiThread(() -> activity.switchConnection(true));
+
+ // Wait for connected state
+ boolean connected = connectedLatch.await(VPN_RECOVERY_TIMEOUT_SEC, TimeUnit.SECONDS);
+ activity.runOnUiThread(() -> activity.unregisterServiceStateListener(listener));
+
+ if (!connected) {
+ log("WARNING: VPN connect timed out via state listener, will verify via ping...");
+ }
+ }
+
+ // --- Ping verification -------------------------------------------------------
+
+ private boolean waitForPingSuccess(int timeoutSeconds) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
+ int attempt = 0;
+
+ while (System.currentTimeMillis() < deadline) {
+ attempt++;
+ if (pingOnce()) {
+ log(String.format(" Ping succeeded on attempt %d", attempt));
+ return true;
+ }
+ // Exponential backoff: 2s, 4s, 8s, capped at 10s
+ long backoff = Math.min(2000L * (1L << Math.min(attempt - 1, 3)), 10000);
+ Thread.sleep(backoff);
+ }
+
+ log(String.format(" Ping failed after %d attempts over %ds", attempt, timeoutSeconds));
+ return false;
+ }
+
+ private boolean pingOnce() {
+ try {
+ String output = executeShellCommand(
+ String.format("ping -c 1 -W %d %s", PING_TIMEOUT_SEC, PING_TARGET));
+ boolean success = output.contains("1 received") || output.contains("1 packets received");
+ if (!success) {
+ // Some devices use different ping output format
+ success = output.contains("time=") && !output.contains("100% packet loss");
+ }
+ return success;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ // --- Utilities ---------------------------------------------------------------
+
+ private String executeShellCommand(String command) {
+ try {
+ ParcelFileDescriptor pfd = uiAutomation.executeShellCommand(command);
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(new ParcelFileDescriptor.AutoCloseInputStream(pfd)))) {
+ StringBuilder output = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ output.append(line).append("\n");
+ }
+ return output.toString();
+ }
+ } catch (Exception e) {
+ log("Shell command failed: " + command + " - " + e.getMessage());
+ return "";
+ }
+ }
+
+ private void log(String message) {
+ String entry = String.format("[%tT] %s", System.currentTimeMillis(), message);
+ Log.d(TAG, entry);
+ testLog.add(entry);
+ }
+
+ private void sleep(long millis) {
+ try {
+ Thread.sleep(millis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ // --- Inner types -------------------------------------------------------------
+
+ private enum ConnectionState {
+ UNKNOWN, CONNECTED, DISCONNECTED
+ }
+
+ private static class DisruptionType {
+ final String name;
+ final Runnable apply;
+ final Runnable restore;
+
+ DisruptionType(String name, Runnable apply, Runnable restore) {
+ this.name = name;
+ this.apply = apply;
+ this.restore = restore;
+ }
+ }
+
+ private static class StateListenerAdapter implements StateListener {
+ @Override public void onEngineStarted() {}
+ @Override public void onEngineStopped() {}
+ @Override public void onAddressChanged(String fqdn, String ip) {}
+ @Override public void onConnected() {}
+ @Override public void onConnecting() {}
+ @Override public void onDisconnected() {}
+ @Override public void onDisconnecting() {}
+ @Override public void onPeersListChanged(long count) {}
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 29550032..018bc26c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,6 +4,8 @@ browser = "1.8.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
+uiautomator = "2.3.0"
+testRules = "1.7.0"
appcompat = "1.7.0"
lottie = "6.4.0"
material = "1.12.0"
@@ -22,6 +24,8 @@ browser = { module = "androidx.browser:browser", version.ref = "browser" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
+test-rules = { group = "androidx.test", name = "rules", version.ref = "testRules" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
lottie = { module = "com.airbnb.android:lottie", version.ref = "lottie" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }