From 0ace50c71be3d8540e4338fe28929bf755c618bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 14 Apr 2026 14:17:07 +0200 Subject: [PATCH 1/4] Add network connectivity stress test for VPN resilience Instrumented test that simulates real-world network disruptions (WiFi/mobile switching, airplane mode, flapping, long outages) and verifies VPN recovery via ping. Auto-detects real device vs emulator and uses appropriate network control strategy for each. --- app/build.gradle.kts | 3 + app/src/androidTest/README.md | 119 +++ .../client/NetworkConnectivityStressTest.java | 679 ++++++++++++++++++ gradle/libs.versions.toml | 4 + 4 files changed, 805 insertions(+) create mode 100644 app/src/androidTest/README.md create mode 100644 app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java 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..c450b6a6 --- /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 (5s 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: + +``` +=== 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..4f035c5d --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java @@ -0,0 +1,679 @@ +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.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

+ * + * + *

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())); + } + + // 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 (5s 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); + socket.setSoTimeout(5000); + + BufferedReader reader = new BufferedReader( + new InputStreamReader(socket.getInputStream())); + PrintWriter writer = new PrintWriter(socket.getOutputStream(), true); + + // 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"); + socket.close(); + } 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) { + StringBuilder sb = new StringBuilder(); + try { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + if (line.startsWith("OK") || line.contains("OK")) { + break; + } + } + } catch (Exception e) { + // timeout or read error, return what we have + } + return sb.toString(); + } + + // --- 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..d2b9cdbc 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.6.1" 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" } From 5379b7d8257b79cacdcb042b9aca853bf0cca931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 14 Apr 2026 15:28:11 +0200 Subject: [PATCH 2/4] Fix review findings: resource leak, version bump, markdown lint - Use try-with-resources in sendEmulatorConsoleCommand to prevent socket leak - Bump testRules version from 1.6.1 to 1.7.0 - Add language specifier to fenced code block in README --- app/src/androidTest/README.md | 2 +- .../io/netbird/client/NetworkConnectivityStressTest.java | 9 ++++----- gradle/libs.versions.toml | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/README.md b/app/src/androidTest/README.md index c450b6a6..e85ccb4b 100644 --- a/app/src/androidTest/README.md +++ b/app/src/androidTest/README.md @@ -111,7 +111,7 @@ Falls back to `iptables` rules if the console is not reachable. The test prints a summary at the end: -``` +```text === Results: 18/20 passed, 2 failed === ``` diff --git a/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java b/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java index 4f035c5d..e6a3a0aa 100644 --- a/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java +++ b/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java @@ -461,14 +461,14 @@ private void emuNetworkSpeed(String speed) { * inside the emulator (host loopback mapped address). */ private void sendEmulatorConsoleCommand(String command) { - try { + try ( // From inside the emulator, 10.0.2.2 is the host loopback Socket socket = new Socket("10.0.2.2", EMULATOR_CONSOLE_PORT); - socket.setSoTimeout(5000); - BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream())); - PrintWriter writer = new PrintWriter(socket.getOutputStream(), true); + PrintWriter writer = new PrintWriter(socket.getOutputStream(), true) + ) { + socket.setSoTimeout(5000); // Read the initial banner readUntilOK(reader); @@ -485,7 +485,6 @@ private void sendEmulatorConsoleCommand(String command) { log(" EMU console: " + command + " -> " + response.trim()); writer.println("quit"); - socket.close(); } catch (Exception e) { log(" EMU console command failed: " + command + " - " + e.getMessage()); // Fallback: try via shell for basic disconnect/connect diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d2b9cdbc..018bc26c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" uiautomator = "2.3.0" -testRules = "1.6.1" +testRules = "1.7.0" appcompat = "1.7.0" lottie = "6.4.0" material = "1.12.0" From 9c226f7809be2fb01bdb3bff5827f527cb677617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 14 Apr 2026 15:35:06 +0200 Subject: [PATCH 3/4] Fix review findings: restore in finally, label mismatch, readUntilOK error handling - Always call disruption.restore in finally block to prevent stale network state - Fix EMU latency label from 5s to 10s to match actual delay value - Make readUntilOK propagate IOException and detect KO/EOF failures --- app/src/androidTest/README.md | 2 +- .../client/NetworkConnectivityStressTest.java | 31 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/app/src/androidTest/README.md b/app/src/androidTest/README.md index e85ccb4b..71c504a9 100644 --- a/app/src/androidTest/README.md +++ b/app/src/androidTest/README.md @@ -99,7 +99,7 @@ Falls back to `iptables` rules if the console is not reachable. | 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 (5s delay) | 10s network delay | Delay 0 | +| 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 | diff --git a/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java b/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java index e6a3a0aa..a7b1931b 100644 --- a/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java +++ b/app/src/androidTest/java/io/netbird/client/NetworkConnectivityStressTest.java @@ -18,6 +18,7 @@ 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; @@ -189,6 +190,13 @@ public void testVpnSurvivesRandomNetworkDisruptions() throws Exception { } 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 @@ -315,7 +323,7 @@ private DisruptionType pickEmulatorDisruption() { this::emuNetworkConnect), // Scenario 4: Extreme latency (simulates very poor connection) - new DisruptionType("EMU: Extreme latency (5s delay)", + new DisruptionType("EMU: Extreme latency (10s delay)", () -> emuNetworkDelay("10000"), () -> emuNetworkDelay("0")), @@ -498,20 +506,19 @@ private void sendEmulatorConsoleCommand(String command) { } } - private String readUntilOK(BufferedReader reader) { + private String readUntilOK(BufferedReader reader) throws IOException { StringBuilder sb = new StringBuilder(); - try { - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append("\n"); - if (line.startsWith("OK") || line.contains("OK")) { - break; - } + 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(); } - } catch (Exception e) { - // timeout or read error, return what we have } - return sb.toString(); + throw new IOException("Emulator console EOF without OK: " + sb); } // --- Emulator detection ------------------------------------------------------ From 6ae05a821e72c49bd53a35760322483f29a6a823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Tue, 14 Apr 2026 15:44:52 +0200 Subject: [PATCH 4/4] Exclude NetworkConnectivityStressTest from CI This test requires a configured VPN and real network interfaces, so it cannot run on the CI emulator. Run it manually on a real device. --- .github/workflows/build-debug.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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()