diff --git a/.github/actions/build-android/action.yml b/.github/actions/build-android/action.yml index a92c7efb..744839f3 100644 --- a/.github/actions/build-android/action.yml +++ b/.github/actions/build-android/action.yml @@ -49,8 +49,19 @@ runs: uses: actions/setup-go@v5 with: go-version-file: "netbird/go.mod" + cache-dependency-path: "netbird/go.sum" + + - name: Cache Android NDK + id: ndk-cache + uses: actions/cache@v4 + with: + # ANDROID_HOME is set by the runner image but not visible to ${{ env.X }} + # in composite actions; the ubuntu-latest image pins it to this path. + path: /usr/local/lib/android/sdk/ndk/23.1.7779620 + key: ndk-23.1.7779620 - name: Setup NDK + if: steps.ndk-cache.outputs.cache-hit != 'true' shell: bash run: ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager --install "ndk;23.1.7779620" @@ -58,7 +69,15 @@ runs: shell: bash run: echo "ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/23.1.7779620" >> $GITHUB_ENV + - name: Cache gomobile binary + id: gomobile-cache + uses: actions/cache@v4 + with: + path: ~/go/bin/gomobile + key: gomobile-v0.0.0-20251113184115-a159579294ab + - name: Install gomobile + if: steps.gomobile-cache.outputs.cache-hit != 'true' shell: bash run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index 936762e4..515e6f66 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -5,6 +5,7 @@ on: push: branches: - main + workflow_dispatch: permissions: contents: read @@ -85,9 +86,14 @@ jobs: tool/build/reports/tests/ retention-days: 3 + # Classic instrumented tests: everything outside io.netbird.client.e2e. + # They need no secrets and no external infrastructure, so they can run on + # every PR, forks included. The e2e package (production API, setup keys) + # runs from the private mobile-e2e repo instead. instrumented-tests: needs: build-debug runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -113,6 +119,34 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: AVD cache + id: avd-cache + uses: actions/cache@v4 + with: + # ANDROID_HOME is set by the runner image but not visible to + # ${{ env.X }} at expression-eval time; hardcoded to the path on + # ubuntu-latest. + path: | + ~/.android/avd/* + ~/.android/adb* + /usr/local/lib/android/sdk/system-images/android-30/google_apis/x86_64 + key: avd-api30-google_apis-x86_64-pixel_3a-v1 + + - name: Create AVD snapshot + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 30 + target: google_apis + arch: x86_64 + profile: pixel_3a + disk-size: 4096M + heap-size: 512M + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + disable-animations: true + script: echo "Generated AVD snapshot for caching." + - name: Run instrumented tests uses: reactivecircus/android-emulator-runner@v2 with: @@ -122,8 +156,10 @@ jobs: profile: pixel_3a disk-size: 4096M heap-size: 512M + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim disable-animations: true - script: ./gradlew connectedDebugAndroidTest --no-daemon -Pandroid.testInstrumentationRunnerArguments.notClass=io.netbird.client.NetworkConnectivityStressTest + script: ./gradlew --no-daemon connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.notPackage=io.netbird.client.e2e - name: Upload test results if: always() diff --git a/app/build.gradle.kts b/app/build.gradle.kts index febc2632..75fa55da 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,7 +45,18 @@ android { versionName = rootProject.extra["appVersionName"] as String testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArguments["timeout_msec"] = "3600000" + testInstrumentationRunnerArguments["timeout_msec"] = "300000" + + // Pass setup keys from the environment to the e2e tests (a -P arg still overrides). + mapOf( + "setupKey" to "INSTRUMENTATION_NB_SETUP_KEY", + "exitNodeSetupKey" to "INSTRUMENTATION_EXIT_NODE_SETUP_KEY" + ).forEach { (arg, envVar) -> + val value = System.getenv(envVar) + if (!value.isNullOrBlank()) { + testInstrumentationRunnerArguments[arg] = value + } + } } buildTypes { diff --git a/app/src/androidTest/README.md b/app/src/androidTest/README.md index 71c504a9..7c94b2ef 100644 --- a/app/src/androidTest/README.md +++ b/app/src/androidTest/README.md @@ -22,7 +22,7 @@ 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 \ +adb shell am instrument -w -e class io.netbird.client.e2e.NetworkConnectivityStressTest \ io.netbird.client.test/androidx.test.runner.AndroidJUnitRunner ``` diff --git a/app/src/androidTest/java/io/netbird/client/e2e/DnsResolutionTest.java b/app/src/androidTest/java/io/netbird/client/e2e/DnsResolutionTest.java new file mode 100644 index 00000000..3f0236ca --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/e2e/DnsResolutionTest.java @@ -0,0 +1,128 @@ +package io.netbird.client.e2e; + +import io.netbird.client.MainActivity; + +import android.os.Bundle; +import android.util.Log; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * DNS resolution test — the Android port of the Robot + * {@code client-tests.robot} case "Should resolve the domain and hostname". + * + *
With the tunnel up, the {@code dnstest} peer's internal name must resolve
+ * to its private address {@code 172.20.3.158} through the NetBird DNS. The
+ * original runs {@code dig Mirrors the two original assertions:
+ * The original additionally checks the main system resolver is a NetBird
+ * {@code 100.x} address via {@code /etc/resolv.conf} / {@code resolvectl}. That
+ * is Linux-specific and not portable to Android (no {@code resolv.conf} in the
+ * usual sense; the VpnService owns DNS), so it is omitted — the resolution
+ * results above already prove tunnel DNS is in effect.
+ *
+ * Only the setup key is injected:
+ * Run with:
+ * The client logs in with a setup key whose profile routes all traffic
+ * through an exit node (the Robot suite's {@code EXIT_NODE_TEST_SETUP_KEY}).
+ * With the tunnel up, a request to {@code https://api.ipify.org} must report
+ * the exit node's public IP ({@code 3.121.38.77}) rather than the device's own
+ * — proving egress goes through the exit node. This is a real HTTPS request
+ * over the tunnel, the equivalent of the Robot {@code GET https://api.ipify.org}.
+ *
+ * Unlike the other cases this needs a separate setup key (the
+ * exit-node profile), injected as its own argument — mirroring the original's
+ * distinct {@code EXIT_NODE_TEST_SETUP_KEY}:
+ * If {@code exitNodeSetupKey} is not provided the test fails fast on its own
+ * assertion, so the rest of the suite is unaffected.
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExitNodeRouteTest {
+
+ private static final String TAG = "NBExitNodeTest";
+
+ /** Where to ask for our public egress IP. */
+ private static final String EGRESS_CHECK_URL = "https://api.ipify.org";
+ /** The exit node's public IP — egress must appear to come from here. */
+ private static final String EXIT_NODE_PUBLIC_IP = "3.121.38.77";
+
+ /** Matches the Robot suite's peer-connected window (3 min). */
+ private static final long CONNECT_TIMEOUT_SEC = 20;
+ /** Time budget for the exit-node route to take effect after connecting. */
+ private static final long EGRESS_TIMEOUT_SEC = 20;
+ private VpnTestHarness harness;
+ private String profileName;
+
+ @Before
+
+ public void skipIfPreviousFailed() {
+
+ FailFast.skipIfAborted();
+
+ }
+
+
+ @After
+ public void tearDown() throws Exception {
+ if (profileName != null && harness != null) {
+ harness.disableTouchVisualization();
+ LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName);
+ }
+ }
+
+ @Test
+ public void egressGoesThroughExitNode() throws Exception {
+ Bundle args = InstrumentationRegistry.getArguments();
+ // Separate key from the basic tests, like the Robot EXIT_NODE_TEST_SETUP_KEY.
+ String setupKey = args.getString("exitNodeSetupKey");
+
+ assertNotNull("exitNodeSetupKey instrumentation argument is required", setupKey);
+ assertTrue("exitNodeSetupKey must not be blank", !setupKey.trim().isEmpty());
+
+ MainActivity activity = E2eAppRule.activity();
+ assertNotNull("MainActivity must be available", activity);
+ harness = new VpnTestHarness(activity);
+ harness.enableTouchVisualization();
+
+ harness.grantVpnConsent();
+
+ profileName = LoginFlow.createAndSwitchToFreshProfile(activity, harness.device(), "exit-node");
+ LoginFlow.loginWithSetupKey(activity, harness.device(), setupKey);
+
+ boolean connected = harness.connectAndAwait(CONNECT_TIMEOUT_SEC);
+ if (!connected) {
+ LoginFlow.dumpScreenshot(harness.device(), "vpn-connect-timeout");
+ }
+ assertTrue("VPN did not reach connected state within " + CONNECT_TIMEOUT_SEC + "s",
+ connected);
+
+ // Best-effort: log the route table for debugging (the egress IP below
+ // is the real assertion). Mirrors the Robot route-table dump.
+ Log.i(TAG, "ip route:\n" + harness.shell("ip route show table all"));
+
+ // The real check: our public egress IP must be the exit node's.
+ boolean viaExitNode = harness.waitForHttpBodyContains(
+ EGRESS_CHECK_URL, EXIT_NODE_PUBLIC_IP, EGRESS_TIMEOUT_SEC);
+ if (!viaExitNode) {
+ LoginFlow.dumpScreenshot(harness.device(), "exit-node-egress-mismatch");
+ }
+ assertTrue("Egress IP from " + EGRESS_CHECK_URL + " was not the exit node's "
+ + EXIT_NODE_PUBLIC_IP + " within " + EGRESS_TIMEOUT_SEC + "s", viaExitNode);
+
+ Log.i(TAG, "Egress verified through exit node " + EXIT_NODE_PUBLIC_IP);
+ }
+}
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/FailFast.java b/app/src/androidTest/java/io/netbird/client/e2e/FailFast.java
new file mode 100644
index 00000000..7980fa13
--- /dev/null
+++ b/app/src/androidTest/java/io/netbird/client/e2e/FailFast.java
@@ -0,0 +1,32 @@
+package io.netbird.client.e2e;
+
+import androidx.test.internal.runner.listener.InstrumentationRunListener;
+
+import org.junit.AssumptionViolatedException;
+import org.junit.runner.Description;
+import org.junit.runner.notification.Failure;
+
+/**
+ * Aborts the run after the first failure. The Gradle UTP layer ignores the
+ * {@code failFast} runner argument, so we do it inside the test process: this
+ * {@link org.junit.runner.notification.RunListener} (registered via the runner
+ * {@code listener} argument) flips a flag on the first failure, and
+ * {@link #skipIfAborted()} — called from each test's {@code @Before} — turns
+ * every subsequent test into a skipped (assumption-failed) result.
+ */
+public final class FailFast extends InstrumentationRunListener {
+
+ private static volatile boolean aborted = false;
+
+ @Override
+ public void testFailure(Failure failure) {
+ aborted = true;
+ }
+
+ /** Skip (not fail) the current test if an earlier test already failed. */
+ static void skipIfAborted() {
+ if (aborted) {
+ throw new AssumptionViolatedException("Skipped: a previous test already failed (fail-fast)");
+ }
+ }
+}
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/LoginFlow.java b/app/src/androidTest/java/io/netbird/client/e2e/LoginFlow.java
new file mode 100644
index 00000000..d9823576
--- /dev/null
+++ b/app/src/androidTest/java/io/netbird/client/e2e/LoginFlow.java
@@ -0,0 +1,363 @@
+package io.netbird.client.e2e;
+
+import io.netbird.client.MainActivity;
+import io.netbird.client.R;
+
+import android.os.Bundle;
+import android.util.Log;
+import android.view.View;
+
+import java.io.File;
+import java.util.List;
+import java.util.Random;
+
+import androidx.navigation.NavController;
+import androidx.navigation.NavOptions;
+import androidx.navigation.Navigation;
+import androidx.test.uiautomator.By;
+import androidx.test.uiautomator.BySelector;
+import androidx.test.uiautomator.UiDevice;
+import androidx.test.uiautomator.UiObject2;
+import androidx.test.uiautomator.UiScrollable;
+import androidx.test.uiautomator.UiSelector;
+import androidx.test.uiautomator.Until;
+
+import io.netbird.client.ui.server.ChangeServerFragment;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.fail;
+
+/**
+ * Shared "log in with a setup key via the UI" flow, extracted so both
+ * {@link SetupKeyAuthTest} (login smoke test) and {@link PeerConnectivityTest}
+ * (doc test case 1 — connectivity to a peer) drive the exact same screens.
+ *
+ * This mirrors what a user does by hand: open "Change server", type the
+ * setup key, submit, and dismiss the success dialog. It targets the management
+ * URL hard-coded in the app ({@code Preferences.defaultServer()}, the
+ * production server) via the "Use NetBird" button.
+ *
+ * The setup key comes from the {@code setupKey} instrumentation argument so
+ * CI can inject it as a secret:
+ * Mirrors the Robot {@code Try Peer Connectivity} keyword. It creates a
+ * fresh, isolated profile (via the Profiles UI — the Android equivalent of
+ * {@code netbird profile add test- Two cases, exactly as in the Robot suite (peer FQDNs hard-coded as in the
+ * original):
+ * Only the setup key is injected:
+ * Timeouts follow the Robot {@code Wait For Peer Ready} keyword: up to
+ * ~3 minutes for the peer to come up and the tunnel to carry traffic.
+ */
+@RunWith(AndroidJUnit4.class)
+public class PeerConnectivityTest {
+
+ private static final String TAG = "NBPeerConnTest";
+
+ /** Peer reachable with relay support (Robot "with relay support" case). */
+ private static final String PEER_FQDN_RELAY = "pingtest.netbird.cloud";
+ /** Peer reachable without relay (Robot "without relay support" case). */
+ private static final String PEER_FQDN_NO_RELAY = "pingtest-pre-relay.netbird.cloud";
+
+ /** Matches the Robot suite's peer-connected window (3 min). */
+ private static final long CONNECT_TIMEOUT_SEC = 20;
+ /** How long to keep retrying the ping once the engine reports connected. */
+ private static final long PING_TIMEOUT_SEC = 20;
+
+ private VpnTestHarness harness;
+ private String profileName;
+
+ @Before
+ public void skipIfPreviousFailed() {
+ FailFast.skipIfAborted();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (profileName != null && harness != null) {
+ harness.disableTouchVisualization();
+ LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName);
+ }
+ }
+
+ @Test
+ public void connectsWithRelay() throws Exception {
+ connectAndPing(PEER_FQDN_RELAY, "relay");
+ }
+
+ @Test
+ public void connectsWithoutRelay() throws Exception {
+ connectAndPing(PEER_FQDN_NO_RELAY, "no-relay");
+ }
+
+ /**
+ * Shared body: fresh profile → login → connect → ping {@code peerFqdn}
+ * through the tunnel. This is the Android equivalent of the Robot
+ * {@code Try Peer Connectivity ${peer_name}} keyword. Force-relay is
+ * disabled once for the whole suite (see {@link E2eSuite}).
+ */
+ private void connectAndPing(String peerFqdn, String scenario) throws Exception {
+ Bundle args = InstrumentationRegistry.getArguments();
+ String setupKey = args.getString("setupKey");
+
+ assertNotNull("setupKey instrumentation argument is required", setupKey);
+ assertTrue("setupKey must not be blank", !setupKey.trim().isEmpty());
+
+ MainActivity activity = E2eAppRule.activity();
+ harness = new VpnTestHarness(activity);
+ harness.enableTouchVisualization();
+
+ harness.grantVpnConsent();
+
+ // 1. Create a fresh, isolated profile (Android equivalent of the Robot
+ // suite's `netbird profile add test- The {@code acltest.netbird.cloud} peer has an ACL that blocks ICMP
+ * but allows TCP port 80. So, with the tunnel up, the original asserts:
+ * This is the proof the ACL is actually enforced: the peer is reachable
+ * (control plane connected, port 80 open) yet ICMP is filtered.
+ *
+ * Reuses the same fresh-profile + login + connect flow as
+ * {@link PeerConnectivityTest}. Only the setup key is injected:
+ * This test stops at a successful login (the success dialog). For the
+ * end-to-end "can actually reach a peer" check (doc test case 1) see
+ * {@link PeerConnectivityTest}. The shared login steps live in
+ * {@link LoginFlow}.
+ *
+ * The setup key is read from an instrumentation runner argument so CI can
+ * inject it as a secret without baking it into the APK:
+ * Connecting goes through {@link MainActivity#switchConnection(boolean)} and
+ * a {@link StateListener}, not the Lottie connect button, so it is robust to UI
+ * timing. Network probes run as shell commands / plain sockets so they observe
+ * the tunnel exactly as a user's traffic would.
+ */
+final class VpnTestHarness {
+
+ private static final String TAG = "NBVpnHarness";
+
+ private final MainActivity activity;
+ private final UiDevice device;
+ private final UiAutomation uiAutomation;
+
+ VpnTestHarness(MainActivity activity) {
+ Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
+ this.activity = activity;
+ this.device = UiDevice.getInstance(instrumentation);
+ this.uiAutomation = instrumentation.getUiAutomation();
+ }
+
+ UiDevice device() {
+ return device;
+ }
+
+ /** Show touch feedback on screen so recordings reveal what the test taps. */
+ void enableTouchVisualization() {
+ shell("settings put system show_touches 1");
+ shell("settings put system pointer_location 1");
+ }
+
+ void disableTouchVisualization() {
+ shell("settings put system show_touches 0");
+ shell("settings put system pointer_location 0");
+ }
+
+ /**
+ * Pre-grant the VPN consent (ACTIVATE_VPN appop) for this package so the
+ * VpnService starts without a system dialog. Best-effort: if the command is
+ * unavailable the test still works as long as consent was granted before.
+ */
+ void grantVpnConsent() {
+ String out = shell("appops set " + LoginFlow.PACKAGE + " ACTIVATE_VPN allow");
+ Log.i(TAG, "appops ACTIVATE_VPN -> " + out.trim());
+ }
+
+ /**
+ * Start the engine via the app's own API and block until a state listener
+ * reports {@code onConnected}, or the timeout elapses.
+ *
+ * @return true if the engine reported connected within the timeout
+ */
+ boolean connectAndAwait(long timeoutSec) throws InterruptedException {
+ CountDownLatch connectedLatch = new CountDownLatch(1);
+
+ StateListener listener = new StateListener() {
+ @Override public void onEngineStarted() {}
+ @Override public void onEngineStopped() {}
+ @Override public void onAddressChanged(String fqdn, String ip) {}
+ @Override public void onConnected() {
+ Log.i(TAG, "Engine reported connected");
+ connectedLatch.countDown();
+ }
+ @Override public void onConnecting() {}
+ @Override public void onDisconnected() {}
+ @Override public void onDisconnecting() {}
+ @Override public void onPeersListChanged(long count) {
+ Log.i(TAG, "Peers list changed: " + count);
+ }
+ };
+
+ activity.runOnUiThread(() -> activity.registerServiceStateListener(listener));
+ try {
+ activity.runOnUiThread(() -> activity.switchConnection(true));
+ return connectedLatch.await(timeoutSec, TimeUnit.SECONDS);
+ } finally {
+ activity.runOnUiThread(() -> activity.unregisterServiceStateListener(listener));
+ }
+ }
+
+ /** Retry {@link #pingOnce(String)} until it succeeds or the timeout elapses. */
+ boolean waitForPing(String target, long timeoutSec) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + (timeoutSec * 1000L);
+ int attempt = 0;
+ while (System.currentTimeMillis() < deadline) {
+ attempt++;
+ if (pingOnce(target)) {
+ Log.i(TAG, "Ping to " + target + " succeeded on attempt " + attempt);
+ return true;
+ }
+ Thread.sleep(3000);
+ }
+ Log.w(TAG, "Ping to " + target + " failed after " + attempt + " attempts");
+ return false;
+ }
+
+ /** Ping a host (FQDN or IP) once through the tunnel. */
+ boolean pingOnce(String target) {
+ String output = shell(String.format("ping -c 1 -W %d %s", PING_W_SEC, target));
+ if (output.contains("1 received") || output.contains("1 packets received")) {
+ return true;
+ }
+ // Some ROMs print a different summary; fall back to a positive RTT line.
+ return output.contains("time=") && !output.contains("100% packet loss");
+ }
+
+ /**
+ * Try to open a TCP connection to {@code host:port}, the equivalent of the
+ * Robot {@code Open Connection ... port=N connection_timeout=1} Telnet
+ * check. Returns true if the socket connects within {@code timeoutMs}.
+ */
+ boolean tcpConnects(String host, int port, int timeoutMs) {
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress(host, port), timeoutMs);
+ Log.i(TAG, "TCP connect to " + host + ":" + port + " succeeded");
+ return true;
+ } catch (IOException e) {
+ Log.i(TAG, "TCP connect to " + host + ":" + port + " failed: " + e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Resolve {@code host} via {@code ping}, which is the only resolver tool
+ * present on these devices (no nslookup/getent on Android 11). ping prints
+ * the resolved address in its first line — {@code PING host (1.2.3.4) ...} —
+ * and does the lookup through the device resolver / NetBird VpnService DNS,
+ * like a user's traffic. Returns that address, or null if it didn't resolve.
+ */
+ String resolve(String host) {
+ String out = shell("ping -c 1 -W 2 " + host);
+ Matcher m = PING_RESOLVED_IP.matcher(out);
+ return m.find() ? m.group(1) : null;
+ }
+
+ private static final Pattern PING_RESOLVED_IP =
+ Pattern.compile("\\(([0-9]{1,3}(?:\\.[0-9]{1,3}){3})\\)");
+
+ /** Retry {@link #resolve(String)} until it returns {@code expectedIp} or the timeout elapses. */
+ boolean waitForResolve(String host, String expectedIp, long timeoutSec) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + (timeoutSec * 1000L);
+ String last = null;
+ while (System.currentTimeMillis() < deadline) {
+ last = resolve(host);
+ if (expectedIp.equals(last)) {
+ Log.i(TAG, "Resolved " + host + " -> " + last);
+ return true;
+ }
+ Thread.sleep(3000);
+ }
+ Log.w(TAG, "Resolve " + host + " did not yield " + expectedIp + " (last: " + last + ")");
+ return false;
+ }
+
+ /**
+ * Issue a real HTTPS GET and return the response body, or null on failure.
+ * Used by the exit-node test to ask {@code https://api.ipify.org} what the
+ * public egress IP is — the Android equivalent of the Robot
+ * {@code GET https://api.ipify.org}. With the tunnel routing through an
+ * exit node, the returned IP is the exit node's, not the device's.
+ */
+ String httpGet(String urlString) {
+ HttpURLConnection conn = null;
+ try {
+ URL url = new URL(urlString);
+ conn = (HttpURLConnection) url.openConnection();
+ conn.setConnectTimeout(10_000);
+ conn.setReadTimeout(10_000);
+ conn.setRequestMethod("GET");
+ int code = conn.getResponseCode();
+ if (code != HttpURLConnection.HTTP_OK) {
+ Log.i(TAG, "GET " + urlString + " -> HTTP " + code);
+ return null;
+ }
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(conn.getInputStream()))) {
+ StringBuilder body = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ body.append(line);
+ }
+ return body.toString();
+ }
+ } catch (IOException e) {
+ Log.i(TAG, "GET " + urlString + " failed: " + e.getMessage());
+ return null;
+ } finally {
+ if (conn != null) {
+ conn.disconnect();
+ }
+ }
+ }
+
+ /**
+ * Poll {@link #httpGet(String)} until the response contains {@code
+ * expectedSubstring} (word-boundaried for dotted-quad IPs) or the timeout
+ * elapses.
+ */
+ boolean waitForHttpBodyContains(String urlString, String expectedSubstring, long timeoutSec)
+ throws InterruptedException {
+ Pattern p = Pattern.compile("(^|[^0-9.])" + Pattern.quote(expectedSubstring) + "([^0-9.]|$)");
+ long deadline = System.currentTimeMillis() + (timeoutSec * 1000L);
+ String last = null;
+ while (System.currentTimeMillis() < deadline) {
+ last = httpGet(urlString);
+ if (last != null && p.matcher(last).find()) {
+ Log.i(TAG, "GET " + urlString + " body matched " + expectedSubstring);
+ return true;
+ }
+ Thread.sleep(3000);
+ }
+ Log.w(TAG, "GET " + urlString + " never matched " + expectedSubstring + " (last: " + last + ")");
+ return false;
+ }
+
+ /** Run a shell command via the instrumentation UiAutomation and return stdout. */
+ String shell(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.w(TAG, "Shell command failed: " + command + " - " + e.getMessage());
+ return "";
+ }
+ }
+
+ /** Per-attempt ping timeout, in seconds. */
+ private static final int PING_W_SEC = 5;
+}
+ *
+ *
+ *
+ * ./gradlew connectedDebugAndroidTest \
+ * -Pandroid.testInstrumentationRunnerArguments.setupKey=<UUID>
+ *
+ */
+@RunWith(AndroidJUnit4.class)
+public class DnsResolutionTest {
+
+ private static final String TAG = "NBDnsTest";
+
+ /** dnstest peer's internal hostname and its expected private address. */
+ private static final String PEER_FQDN = "ip-172-20-3-158.eu-central-1.compute.internal";
+ private static final String PEER_UNQUALIFIED = "ip-172-20-3-158";
+ private static final String EXPECTED_IP = "172.20.3.158";
+
+ /** Matches the Robot suite's peer-connected window (3 min). */
+ private static final long CONNECT_TIMEOUT_SEC = 20;
+ /** Time budget for DNS to start resolving once the engine is connected. */
+ private static final long RESOLVE_TIMEOUT_SEC = 20;
+ private VpnTestHarness harness;
+ private String profileName;
+
+ @Before
+
+ public void skipIfPreviousFailed() {
+
+ FailFast.skipIfAborted();
+
+ }
+
+
+ @After
+ public void tearDown() throws Exception {
+ if (profileName != null && harness != null) {
+ harness.disableTouchVisualization();
+ LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName);
+ }
+ }
+
+ @Test
+ public void resolvesPeerNameThroughTunnel() throws Exception {
+ Bundle args = InstrumentationRegistry.getArguments();
+ String setupKey = args.getString("setupKey");
+
+ assertNotNull("setupKey instrumentation argument is required", setupKey);
+ assertTrue("setupKey must not be blank", !setupKey.trim().isEmpty());
+
+ MainActivity activity = E2eAppRule.activity();
+ assertNotNull("MainActivity must be available", activity);
+ harness = new VpnTestHarness(activity);
+ harness.enableTouchVisualization();
+
+ harness.grantVpnConsent();
+
+ profileName = LoginFlow.createAndSwitchToFreshProfile(activity, harness.device(), "dns");
+ LoginFlow.loginWithSetupKey(activity, harness.device(), setupKey);
+
+ boolean connected = harness.connectAndAwait(CONNECT_TIMEOUT_SEC);
+ if (!connected) {
+ LoginFlow.dumpScreenshot(harness.device(), "vpn-connect-timeout");
+ }
+ assertTrue("VPN did not reach connected state within " + CONNECT_TIMEOUT_SEC + "s",
+ connected);
+
+ // FQDN resolves to the peer's private address through the tunnel DNS.
+ boolean fqdnResolved = harness.waitForResolve(PEER_FQDN, EXPECTED_IP, RESOLVE_TIMEOUT_SEC);
+ if (!fqdnResolved) {
+ LoginFlow.dumpScreenshot(harness.device(), "dns-fqdn-unresolved");
+ }
+ assertTrue(PEER_FQDN + " did not resolve to " + EXPECTED_IP + " within "
+ + RESOLVE_TIMEOUT_SEC + "s", fqdnResolved);
+
+ // Search-domain: the unqualified name resolves to the same address.
+ boolean searchResolved =
+ harness.waitForResolve(PEER_UNQUALIFIED, EXPECTED_IP, RESOLVE_TIMEOUT_SEC);
+ if (!searchResolved) {
+ LoginFlow.dumpScreenshot(harness.device(), "dns-search-unresolved");
+ }
+ assertTrue(PEER_UNQUALIFIED + " (search domain) did not resolve to " + EXPECTED_IP
+ + " within " + RESOLVE_TIMEOUT_SEC + "s", searchResolved);
+
+ Log.i(TAG, "DNS resolution verified: " + PEER_FQDN + " and " + PEER_UNQUALIFIED
+ + " -> " + EXPECTED_IP);
+ }
+}
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/E2eAppRule.java b/app/src/androidTest/java/io/netbird/client/e2e/E2eAppRule.java
new file mode 100644
index 00000000..3c09cd2a
--- /dev/null
+++ b/app/src/androidTest/java/io/netbird/client/e2e/E2eAppRule.java
@@ -0,0 +1,75 @@
+package io.netbird.client.e2e;
+
+import io.netbird.client.MainActivity;
+
+import android.app.Activity;
+import android.app.Instrumentation;
+import android.content.Intent;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
+import androidx.test.runner.lifecycle.Stage;
+
+import java.util.Collection;
+
+/**
+ * Provides the single shared {@link MainActivity} for the e2e suite WITHOUT a
+ * JUnit rule: {@link #activity()} returns the currently-resumed MainActivity,
+ * launching one if none is up. This survives the test framework finishing
+ * activities between test classes (which made a suite-level ActivityTestRule /
+ * ActivityScenario hand back a destroyed activity). Because the launch reuses
+ * the existing task, the app is not torn down and recreated between cases.
+ */
+final class E2eAppRule {
+
+ private static final long LAUNCH_TIMEOUT_MS = 10_000;
+
+ private E2eAppRule() {
+ }
+
+ /** The running MainActivity, launched on demand if not already resumed. */
+ static MainActivity activity() {
+ MainActivity existing = resumedMainActivity();
+ if (existing != null) {
+ return existing;
+ }
+
+ Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
+ Intent intent = new Intent(instrumentation.getTargetContext(), MainActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ instrumentation.startActivitySync(intent);
+ instrumentation.waitForIdleSync();
+
+ long deadline = System.currentTimeMillis() + LAUNCH_TIMEOUT_MS;
+ MainActivity activity = resumedMainActivity();
+ while (activity == null && System.currentTimeMillis() < deadline) {
+ try {
+ Thread.sleep(100);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ activity = resumedMainActivity();
+ }
+ if (activity == null) {
+ throw new IllegalStateException("MainActivity did not reach RESUMED within "
+ + (LAUNCH_TIMEOUT_MS / 1000) + "s");
+ }
+ return activity;
+ }
+
+ private static MainActivity resumedMainActivity() {
+ MainActivity[] found = new MainActivity[1];
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ Collection
+ * -Pandroid.testInstrumentationRunnerArguments.class=io.netbird.client.e2e.E2eSuite
+ *
+ */
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+ SetupKeyAuthTest.class,
+ PeerConnectivityTest.class,
+ PortAclTest.class,
+ DnsResolutionTest.class,
+ ExitNodeRouteTest.class,
+})
+public class E2eSuite {
+
+ /**
+ * Force relay is a global setting that defaults ON and would stop the
+ * relay-less peer connecting. Turn it off ONCE, before any test runs.
+ */
+ @BeforeClass
+ public static void disableForceRelay() throws Exception {
+ UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
+ LoginFlow.setForceRelay(E2eAppRule.activity(), device, false);
+ }
+}
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/ExitNodeRouteTest.java b/app/src/androidTest/java/io/netbird/client/e2e/ExitNodeRouteTest.java
new file mode 100644
index 00000000..191cabed
--- /dev/null
+++ b/app/src/androidTest/java/io/netbird/client/e2e/ExitNodeRouteTest.java
@@ -0,0 +1,116 @@
+package io.netbird.client.e2e;
+
+import io.netbird.client.MainActivity;
+
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Exit-node route test — the Android port of the Robot
+ * {@code client-tests.robot} case "Should use exit node route".
+ *
+ *
+ * ./gradlew connectedDebugAndroidTest \
+ * -Pandroid.testInstrumentationRunnerArguments.exitNodeSetupKey=<UUID>
+ *
+ *
+ *
+ * ./gradlew connectedDebugAndroidTest \
+ * -Pandroid.testInstrumentationRunnerArguments.setupKey=<UUID>
+ *
+ */
+final class LoginFlow {
+
+ private static final String TAG = "NBLoginFlow";
+ static final String PACKAGE = "io.netbird.client";
+ private static final String DEFAULT_PROFILE = "default";
+
+ private static final long UI_TIMEOUT_MS = 5_000;
+ private static final long LOGIN_TIMEOUT_MS = 15_000;
+
+ private LoginFlow() {
+ }
+
+ /**
+ * Drives the "Change server" UI to log in with {@code setupKey} against the
+ * app's default (production) management server, then dismisses the success
+ * dialog so the caller lands back on the Home screen.
+ *
+ * @param activity the running MainActivity (from the test's ActivityTestRule)
+ * @param device the shared UiDevice
+ * @param setupKey the NetBird setup key (already trimmed/validated by caller)
+ * @throws Exception if any expected view never appears or login does not
+ * complete within {@link #LOGIN_TIMEOUT_MS}
+ */
+ static void loginWithSetupKey(MainActivity activity, UiDevice device, String setupKey)
+ throws Exception {
+ device.waitForIdle();
+
+ // Try the navigation up to 2 times — on first launch the MainActivity
+ // pushes firstInstallFragment after onCreate, which can race with our
+ // navigate() call from the test thread.
+ UiObject2 setupKeyLabel = null;
+ for (int attempt = 1; attempt <= 2 && setupKeyLabel == null; attempt++) {
+ Log.i(TAG, "navigateToChangeServer attempt " + attempt);
+ navigateToChangeServer(activity);
+ dismissConfirmChangeServerDialog(device);
+ setupKeyLabel = device.wait(
+ Until.findObject(By.res(PACKAGE, "text_setup_key_label")), UI_TIMEOUT_MS);
+ }
+ if (setupKeyLabel == null) {
+ dumpScreenshot(device, "navigation-failed");
+ fail("text_setup_key_label not found after 2 navigation attempts");
+ }
+ setupKeyLabel.click();
+
+ UiObject2 setupKeyField = device.wait(
+ Until.findObject(By.res(PACKAGE, "edit_text_setup_key")), UI_TIMEOUT_MS);
+ assertNotNull("edit_text_setup_key must be present", setupKeyField);
+ setupKeyField.setText(setupKey.trim());
+
+ // "Use NetBird" submits with the app's default management URL.
+ UiObject2 submit = device.wait(
+ Until.findObject(By.res(PACKAGE, "btn_use_netbird")), UI_TIMEOUT_MS);
+ assertNotNull("btn_use_netbird must be present", submit);
+ submit.click();
+
+ // Either the success dialog ("btn_close") shows up, or the form
+ // re-enables itself with an error.
+ long deadline = System.currentTimeMillis() + LOGIN_TIMEOUT_MS;
+ while (System.currentTimeMillis() < deadline) {
+ UiObject2 closeBtn = device.findObject(By.res(PACKAGE, "btn_close"));
+ if (closeBtn != null) {
+ Log.i(TAG, "Setup-key login succeeded");
+ closeBtn.click();
+ device.waitForIdle();
+ return;
+ }
+ // If the "Change server" button is enabled again, the request came
+ // back with an error.
+ UiObject2 submitAgain = device.findObject(By.res(PACKAGE, "btn_change_server"));
+ if (submitAgain != null && submitAgain.isEnabled()) {
+ fail("Login failed: submit button re-enabled without success dialog");
+ }
+ Thread.sleep(200);
+ }
+ dumpScreenshot(device, "login-timeout");
+ fail("Login did not complete within " + (LOGIN_TIMEOUT_MS / 1000) + "s");
+ }
+
+ /**
+ * Skip the first-install teaser and jump straight to the "Change server"
+ * screen. {@code hideAlert=true} suppresses the "are you sure?" warning
+ * dialog so this is non-interactive.
+ */
+ private static void navigateToChangeServer(MainActivity activity) throws InterruptedException {
+ assertNotNull("MainActivity must be available", activity);
+
+ activity.runOnUiThread(() -> {
+ NavController nav = Navigation.findNavController(activity, R.id.nav_host_fragment_content_main);
+ Bundle bundle = new Bundle();
+ bundle.putBoolean(ChangeServerFragment.HideAlertBundleArg, true);
+ // Same nav options the FirstInstallFragment uses when the user taps
+ // its "change_server" link, so we land in the same place.
+ NavOptions opts = new NavOptions.Builder()
+ .setPopUpTo(R.id.firstInstallFragment, true)
+ .build();
+ nav.navigate(R.id.nav_change_server, bundle, opts);
+ });
+ // Let the fragment transaction commit before UiAutomator looks for views.
+ Thread.sleep(1500);
+ }
+
+ /**
+ * The Change Server fragment shows a "this will erase the local config"
+ * confirmation dialog whenever it opens — even when the caller passed
+ * {@code hideAlert=true}, because that arg is not currently honoured by the
+ * fragment. Tap Yes to dismiss so we can interact with the form.
+ */
+ private static void dismissConfirmChangeServerDialog(UiDevice device) {
+ UiObject2 yes = device.wait(
+ Until.findObject(By.res(PACKAGE, "btn_yes")), UI_TIMEOUT_MS);
+ if (yes != null) {
+ Log.i(TAG, "Dismissing change-server confirmation dialog");
+ yes.click();
+ device.waitForIdle();
+ }
+ }
+
+ /**
+ * Create a fresh, isolated profile through the Profiles UI and switch to
+ * it, mirroring the Robot suite's {@code netbird profile add test-Run with
*
* ./gradlew connectedAndroidTest \
- * -Pandroid.testInstrumentationRunnerArguments.class=io.netbird.client.NetworkConnectivityStressTest
+ * -Pandroid.testInstrumentationRunnerArguments.class=io.netbird.client.e2e.NetworkConnectivityStressTest
*
*/
@RunWith(AndroidJUnit4.class)
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/PeerConnectivityTest.java b/app/src/androidTest/java/io/netbird/client/e2e/PeerConnectivityTest.java
new file mode 100644
index 00000000..11cd3f96
--- /dev/null
+++ b/app/src/androidTest/java/io/netbird/client/e2e/PeerConnectivityTest.java
@@ -0,0 +1,140 @@
+package io.netbird.client.e2e;
+
+import io.netbird.client.MainActivity;
+
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * End-to-end connectivity test — the Android port of the Robot
+ * {@code client-tests.robot} "Should be able to connect to peer" cases.
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * ./gradlew connectedDebugAndroidTest \
+ * -Pandroid.testInstrumentationRunnerArguments.setupKey=<UUID>
+ *
+ *
+ *
+ *
+ *
+ *
+ * ./gradlew connectedDebugAndroidTest \
+ * -Pandroid.testInstrumentationRunnerArguments.setupKey=<UUID>
+ *
+ */
+@RunWith(AndroidJUnit4.class)
+public class PortAclTest {
+
+ private static final String TAG = "NBPortAclTest";
+
+ /** Peer with an ICMP-blocking, port-80-allowing ACL. */
+ private static final String PEER_FQDN = "acltest.netbird.cloud";
+ private static final int ALLOWED_PORT = 80;
+
+ /** Matches the Robot suite's peer-connected window (3 min). */
+ private static final long CONNECT_TIMEOUT_SEC = 20;
+ /**
+ * Time budget for the allowed port to become reachable once the engine is
+ * connected (peer + ACL need a moment to settle). Loosely mirrors the
+ * Robot peer-ready/handshake windows.
+ */
+ private static final long PORT_TIMEOUT_SEC = 20;
+ private static final int TCP_CONNECT_TIMEOUT_MS = 3000;
+ /**
+ * How long to keep trying ICMP before concluding it is blocked. The ACL
+ * drops it, so this should always time out — kept short so a genuinely
+ * blocked peer does not slow the test much, but long enough that a slow
+ * first packet is not mistaken for a block (the port check above already
+ * proved the peer is reachable, so a few seconds is plenty).
+ */
+ private static final long PING_BLOCKED_PROBE_SEC = 10;
+ private VpnTestHarness harness;
+ private String profileName;
+
+ @Before
+
+ public void skipIfPreviousFailed() {
+
+ FailFast.skipIfAborted();
+
+ }
+
+
+ @After
+ public void tearDown() throws Exception {
+ if (profileName != null && harness != null) {
+ harness.disableTouchVisualization();
+ LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName);
+ }
+ }
+
+ @Test
+ public void connectsOnlyToAllowedPorts() throws Exception {
+ Bundle args = InstrumentationRegistry.getArguments();
+ String setupKey = args.getString("setupKey");
+
+ assertNotNull("setupKey instrumentation argument is required", setupKey);
+ assertTrue("setupKey must not be blank", !setupKey.trim().isEmpty());
+
+ MainActivity activity = E2eAppRule.activity();
+ assertNotNull("MainActivity must be available", activity);
+ harness = new VpnTestHarness(activity);
+ harness.enableTouchVisualization();
+
+ harness.grantVpnConsent();
+
+ // Fresh profile + login, like the Robot suite's per-test InitNetBird.
+ profileName = LoginFlow.createAndSwitchToFreshProfile(activity, harness.device(), "port-acl");
+ LoginFlow.loginWithSetupKey(activity, harness.device(), setupKey);
+
+ boolean connected = harness.connectAndAwait(CONNECT_TIMEOUT_SEC);
+ if (!connected) {
+ LoginFlow.dumpScreenshot(harness.device(), "vpn-connect-timeout");
+ }
+ assertTrue("VPN did not reach connected state within " + CONNECT_TIMEOUT_SEC + "s",
+ connected);
+
+ // Allowed port: TCP 80 must be reachable. This also serves as the
+ // "peer is ready" signal (the Robot suite uses ping for that, but here
+ // ping is blocked, so we rely on the allowed port instead).
+ boolean portOpen = waitForTcp(PEER_FQDN, ALLOWED_PORT, PORT_TIMEOUT_SEC);
+ if (!portOpen) {
+ LoginFlow.dumpScreenshot(harness.device(), "acl-port80-unreachable");
+ }
+ assertTrue("Allowed port " + ALLOWED_PORT + " on " + PEER_FQDN
+ + " was not reachable within " + PORT_TIMEOUT_SEC + "s", portOpen);
+
+ // Blocked protocol: ICMP must NOT get through (ACL drops it). The peer
+ // is provably reachable (port 80 just connected), so any ping success
+ // here would mean the ACL is not being enforced.
+ boolean pingGotThrough = pingSucceedsWithin(PEER_FQDN, PING_BLOCKED_PROBE_SEC);
+ if (pingGotThrough) {
+ LoginFlow.dumpScreenshot(harness.device(), "acl-icmp-leaked");
+ }
+ assertFalse("ICMP to " + PEER_FQDN + " should be blocked by the ACL, but ping succeeded",
+ pingGotThrough);
+
+ Log.i(TAG, "ACL enforced: port " + ALLOWED_PORT + " open, ICMP blocked on " + PEER_FQDN);
+ }
+
+ /** Retry a TCP connect until it succeeds or the timeout elapses. */
+ private boolean waitForTcp(String host, int port, long timeoutSec) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + (timeoutSec * 1000L);
+ while (System.currentTimeMillis() < deadline) {
+ if (harness.tcpConnects(host, port, TCP_CONNECT_TIMEOUT_MS)) {
+ return true;
+ }
+ Thread.sleep(3000);
+ }
+ return false;
+ }
+
+ /**
+ * Probe ICMP for up to {@code timeoutSec}; returns true as soon as any ping
+ * gets through. Used to assert ICMP is blocked, so a true result is a
+ * failure for the caller.
+ */
+ private boolean pingSucceedsWithin(String host, long timeoutSec) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + (timeoutSec * 1000L);
+ while (System.currentTimeMillis() < deadline) {
+ if (harness.pingOnce(host)) {
+ return true;
+ }
+ Thread.sleep(2000);
+ }
+ return false;
+ }
+}
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/SetupKeyAuthTest.java b/app/src/androidTest/java/io/netbird/client/e2e/SetupKeyAuthTest.java
new file mode 100644
index 00000000..7ab55dea
--- /dev/null
+++ b/app/src/androidTest/java/io/netbird/client/e2e/SetupKeyAuthTest.java
@@ -0,0 +1,51 @@
+package io.netbird.client.e2e;
+
+import android.os.Bundle;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.uiautomator.UiDevice;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Login smoke test: drives the "Change server" UI to authenticate against the
+ * default (production) NetBird management server with a setup key — exactly the
+ * flow a user would use, but automated.
+ *
+ *
+ * ./gradlew connectedDebugAndroidTest \
+ * -Pandroid.testInstrumentationRunnerArguments.setupKey=<UUID>
+ *
+ */
+@RunWith(AndroidJUnit4.class)
+public class SetupKeyAuthTest {
+ @Before
+ public void skipIfPreviousFailed() {
+ FailFast.skipIfAborted();
+ }
+
+ @Test
+ public void loginWithSetupKeyViaUi() throws Exception {
+ Bundle args = InstrumentationRegistry.getArguments();
+ String setupKey = args.getString("setupKey");
+
+ assertNotNull("setupKey instrumentation argument is required", setupKey);
+ assertTrue("setupKey must not be blank", !setupKey.trim().isEmpty());
+
+ UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
+ LoginFlow.loginWithSetupKey(E2eAppRule.activity(), device, setupKey);
+ }
+}
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/VpnTestHarness.java b/app/src/androidTest/java/io/netbird/client/e2e/VpnTestHarness.java
new file mode 100644
index 00000000..77732a42
--- /dev/null
+++ b/app/src/androidTest/java/io/netbird/client/e2e/VpnTestHarness.java
@@ -0,0 +1,279 @@
+package io.netbird.client.e2e;
+
+import io.netbird.client.MainActivity;
+import io.netbird.client.StateListener;
+
+import android.app.Instrumentation;
+import android.app.UiAutomation;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.uiautomator.UiDevice;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.URL;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Shared machinery for the on-device client e2e tests (the Android port of the
+ * Robot {@code client-tests.robot} suite). Each test creates one of these from
+ * its {@link MainActivity}, then composes the building blocks it needs:
+ *
+ *
+ *
+ *
+ *