diff --git a/.github/workflows/build-snapshot.yml b/.github/workflows/build-snapshot.yml
index 15c88be0..5358b967 100644
--- a/.github/workflows/build-snapshot.yml
+++ b/.github/workflows/build-snapshot.yml
@@ -41,7 +41,9 @@ jobs:
run: |
BASE_TAG=$(git describe --tags --abbrev=0)
SHORT_GIT_SHA=$(git rev-parse --short HEAD)
- echo "version_name=${BASE_TAG}-snapshot-${SHORT_GIT_SHA}" >> "$GITHUB_OUTPUT"
+ VERSION_NAME="${BASE_TAG}-snapshot-${SHORT_GIT_SHA}"
+ echo "Version name: $VERSION_NAME"
+ echo "version_name=$VERSION_NAME" >> "$GITHUB_OUTPUT"
# Must stay identical to the Compute version code step in the sibling
# workflow. build-release.yml and build-snapshot.yml feed the same Play
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/E2eSuite.java b/app/src/androidTest/java/io/netbird/client/e2e/E2eSuite.java
index fdcd4384..2bd11c6f 100644
--- a/app/src/androidTest/java/io/netbird/client/e2e/E2eSuite.java
+++ b/app/src/androidTest/java/io/netbird/client/e2e/E2eSuite.java
@@ -27,6 +27,11 @@
PortAclTest.class,
DnsResolutionTest.class,
ExitNodeRouteTest.class,
+ ExitNodeNetworkTransitionTest.class,
+ // Last on purpose: its final case (B2, cellular->WiFi handover speed)
+ // is expected to fail until PR #243 merges, and the FailFast listener
+ // would skip everything scheduled after that failure.
+ NetworkTransitionTest.class,
})
public class E2eSuite {
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/ExitNodeNetworkTransitionTest.java b/app/src/androidTest/java/io/netbird/client/e2e/ExitNodeNetworkTransitionTest.java
new file mode 100644
index 00000000..bf2867cc
--- /dev/null
+++ b/app/src/androidTest/java/io/netbird/client/e2e/ExitNodeNetworkTransitionTest.java
@@ -0,0 +1,145 @@
+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;
+
+/**
+ * Scenario C1 of the network transition matrix (see the table in
+ * {@link NetworkTransitionTest}): a client whose network map routes ALL
+ * traffic through an exit node must re-establish that egress after network
+ * transitions — the exit-node route is only usable once the peer connection
+ * to the exit node itself is back, so this proves real peer recovery, not
+ * just a Connected status.
+ *
+ *
Reuses the {@link ExitNodeRouteTest} profile ({@code exitNodeSetupKey},
+ * exit node egress IP {@code 3.121.38.77}) and its verification: a real HTTPS
+ * GET to {@code https://api.ipify.org} must report the exit node's public IP.
+ * Two transitions are exercised on the emulator's virtual transports:
+ *
+ * - WiFi loss -> cellular fallback (B1-style) — egress must return
+ * through the exit node within {@link #SWITCH_RECOVERY_SEC};
+ * - full blackout and restore (A3-style) — the UI must report
+ * "No network available" while dark, and egress must return through
+ * the exit node within {@link #BLACKOUT_RECOVERY_SEC}.
+ *
+ *
+ * The budgets match the ping-based ones in {@link NetworkTransitionTest}:
+ * even though each probe is a full HTTPS request through the exit node, the
+ * probes run with a short timeout and tight polling so a request hung on a
+ * dead route cannot blur the measurement.
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExitNodeNetworkTransitionTest {
+
+ private static final String TAG = "NBExitNodeNetTest";
+
+ private static final String EGRESS_CHECK_URL = "https://api.ipify.org";
+ private static final String EXIT_NODE_PUBLIC_IP = "3.121.38.77";
+ private static final String STATUS_NO_NETWORK = "No network available";
+
+ private static final long CONNECT_TIMEOUT_SEC = 20;
+ /** Budget for the initial egress check — setup, not an assertion of speed. */
+ private static final long BASELINE_EGRESS_TIMEOUT_SEC = 90;
+ private static final long NO_NETWORK_UI_TIMEOUT_SEC = 15;
+ private static final long SWITCH_RECOVERY_SEC = 5;
+ private static final long BLACKOUT_RECOVERY_SEC = 15;
+ /** Short per-probe timeout + tight polling so a hung request cannot blur the measurement. */
+ private static final int PROBE_TIMEOUT_MS = 2_000;
+ private static final long PROBE_POLL_MS = 1_000;
+
+ private VpnTestHarness harness;
+ private String profileName;
+
+ @Before
+ public void skipIfPreviousFailed() {
+ FailFast.skipIfAborted();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (harness != null) {
+ harness.setWifi(true);
+ harness.setMobileData(true);
+ harness.disableTouchVisualization();
+ }
+ if (profileName != null && harness != null) {
+ LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName);
+ }
+ }
+
+ @Test
+ public void egressSurvivesNetworkTransitions() throws Exception {
+ Bundle args = InstrumentationRegistry.getArguments();
+ 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();
+ harness = new VpnTestHarness(activity);
+ harness.enableTouchVisualization();
+ harness.grantVpnConsent();
+ harness.setWifi(true);
+ harness.setMobileData(true);
+
+ // Force relay ON for the network transition tests: they measure the
+ // relay path's failover; the P2P/ICE failover path stays out of scope
+ // until it is optimized (a stale ICE connection blocks the switch to
+ // the ready relay connection for ~7s). See NetworkTransitionTest.
+ LoginFlow.setForceRelay(activity, harness.device(), true);
+
+ profileName = LoginFlow.createProfileAndLogin(
+ activity, harness.device(), "exit-node-network", setupKey);
+
+ boolean connected = harness.connectAndAwait(CONNECT_TIMEOUT_SEC);
+ if (!connected) {
+ LoginFlow.dumpScreenshot(harness.device(), "exit-node-net-connect-timeout");
+ }
+ assertTrue("VPN did not reach connected state within " + CONNECT_TIMEOUT_SEC + "s",
+ connected);
+
+ assertEgressViaExitNode("baseline", BASELINE_EGRESS_TIMEOUT_SEC);
+
+ harness.setWifi(false);
+ assertEgressViaExitNode("after WiFi loss (cellular fallback)", SWITCH_RECOVERY_SEC);
+ harness.setWifi(true);
+
+ harness.setWifi(false);
+ harness.setMobileData(false);
+ assertTrue("status must show '" + STATUS_NO_NETWORK + "' within "
+ + NO_NETWORK_UI_TIMEOUT_SEC + "s of losing all transports",
+ harness.awaitStatusText(STATUS_NO_NETWORK, NO_NETWORK_UI_TIMEOUT_SEC));
+
+ harness.setWifi(true);
+ harness.setMobileData(true);
+ assertEgressViaExitNode("after blackout restore", BLACKOUT_RECOVERY_SEC);
+ }
+
+ private void assertEgressViaExitNode(String phase, long budgetSec) throws Exception {
+ long start = System.currentTimeMillis();
+ boolean viaExitNode = harness.waitForHttpBodyContains(
+ EGRESS_CHECK_URL, EXIT_NODE_PUBLIC_IP, budgetSec, PROBE_TIMEOUT_MS, PROBE_POLL_MS);
+ long elapsedSec = (System.currentTimeMillis() - start + 999) / 1000;
+ Log.i(TAG, "C1 " + phase + ": exit-node egress "
+ + (viaExitNode ? "verified in " + elapsedSec + "s" : "NOT verified within " + budgetSec + "s"));
+ if (!viaExitNode) {
+ LoginFlow.dumpScreenshot(harness.device(), "exit-node-egress-lost");
+ }
+ assertTrue("C1 " + phase + ": egress IP from " + EGRESS_CHECK_URL + " was not the exit "
+ + "node's " + EXIT_NODE_PUBLIC_IP + " within " + budgetSec + "s — slow recovery "
+ + "means the fallback logic reconnected, not the network-change fast path",
+ viaExitNode);
+ }
+}
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/NetworkTransitionSuite.java b/app/src/androidTest/java/io/netbird/client/e2e/NetworkTransitionSuite.java
new file mode 100644
index 00000000..703b42ff
--- /dev/null
+++ b/app/src/androidTest/java/io/netbird/client/e2e/NetworkTransitionSuite.java
@@ -0,0 +1,28 @@
+package io.netbird.client.e2e;
+
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+/**
+ * Focused group: only the network transition scenarios (the matrix in
+ * {@link NetworkTransitionTest} plus the exit-node variant), for CI runs that
+ * iterate on connection switching without paying for the full {@link E2eSuite}.
+ * The mobile-e2e workflow selects it via its suite dropdown, which maps to
+ *
+ * -Pandroid.testInstrumentationRunnerArguments.class=io.netbird.client.e2e.NetworkTransitionSuite
+ *
+ *
+ * No suite-level setup is needed: both classes configure what they depend
+ * on themselves (force relay ON, transports restored), so the group runs the
+ * same standalone as inside the full suite. Order matters and mirrors
+ * {@link E2eSuite}: {@link NetworkTransitionTest} goes last because its final
+ * case is expected to fail until PR #243 merges, and the FailFast listener
+ * would skip everything scheduled after that failure.
+ */
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+ ExitNodeNetworkTransitionTest.class,
+ NetworkTransitionTest.class,
+})
+public class NetworkTransitionSuite {
+}
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/NetworkTransitionTest.java b/app/src/androidTest/java/io/netbird/client/e2e/NetworkTransitionTest.java
new file mode 100644
index 00000000..533034ce
--- /dev/null
+++ b/app/src/androidTest/java/io/netbird/client/e2e/NetworkTransitionTest.java
@@ -0,0 +1,368 @@
+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.AfterClass;
+import org.junit.Before;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.MethodSorters;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Network transition tests — prove that the engine survives every WiFi /
+ * cellular / no-network combination WITHOUT an engine restart, and that it
+ * recovers fast. Speed is part of the contract: the network-change fast
+ * path (PR #237: suspend retry loops while dark, sweep + re-dial on switch)
+ * recovers in a few seconds, while a recovery that only happens after ICE
+ * disconnect detection and backoff retries — the fallback path — takes tens of
+ * seconds. The tight budgets below exist to tell the two apart: a slow pass IS
+ * a failure, because it means the fallback logic did the work.
+ *
+ *
Every assertion is a data-plane check: a real ping to a live remote peer
+ * ({@code pingtest.netbird.cloud}) through the tunnel. The Connected status
+ * text alone proves nothing about peer connectivity.
+ *
+ *
Scenario matrix (methods run in name order; B2 is named to sort last —
+ * see its javadoc):
+ *
+ * ID | Test method | Scenario | Steps | Expectation
+ * ---+----------------------------------+-----------------------------------+----------------------------------------------+------------------------------------------------
+ * A1 | a1AirplaneToggleWifiOnly | airplane on/off, WiFi-only | wifi-only baseline, blackout, wifi back | "No network available" while dark; ping recovers within BLACKOUT_RECOVERY_SEC
+ * A2 | a2AirplaneToggleCellularOnly | airplane on/off, cellular-only | data-only baseline, blackout, data back | same as A1
+ * A3 | a3AirplaneToggleBothTransports | airplane on/off, WiFi+cellular | both up, blackout, both back | same as A1
+ * A4 | a4LongBlackoutHoldsNoNetwork | long outage (retry loops parked) | blackout, hold 60s, restore both | status stays "No network available" the whole hold (no flapping); then recovers
+ * B1 | b1WifiLossFallsBackToCellular | WiFi -> cellular fallback | both up, wifi off | ping recovers within SWITCH_RECOVERY_SEC
+ * B2 | zB2CellularToWifiHandoverIsFast | cellular -> WiFi handover | data-only, wifi on, probe 45s | max outage <= HANDOVER_MAX_OUTAGE_SEC — EXPECTED TO FAIL until PR #243 merges
+ * B3 | b3CellularUnderWifiIsSeamless | secondary transport appears | wifi-only, data on, probe 15s | no outage (at most 1 failed probe): a non-displacing transport must not reset peers
+ * B4 | b4CellularWifiCellularRoundTrip | cellular -> WiFi -> cellular | data-only, wifi on, settle, wifi off | ping recovers after each leg
+ * C1 | (ExitNodeNetworkTransitionTest) | exit node + transitions | exit-node profile, B1 + A3 style transitions | egress re-establishes through the exit node
+ *
+ *
+ * Runs on the mobile-e2e emulator (API 30, virtual WiFi + virtual cellular,
+ * both NAT-ed by the host). "Airplane mode" is realized as {@code svc wifi
+ * disable} + {@code svc data disable}: the API 30 shell cannot send the
+ * protected AIRPLANE_MODE broadcast, and from the ConnectivityManager's point
+ * of view the effect is identical — every network is lost. Not coverable on
+ * the emulator: captive portals (no shell primitive revokes
+ * NET_CAPABILITY_VALIDATED) and the detector's availability seeding on service
+ * restart (unit-test territory).
+ *
+ *
Uses one shared profile for the whole class (login and VPN-up happen
+ * once); each test starts by restoring both transports and re-verifying the
+ * ping baseline, so a failed scenario cannot poison the next one.
+ */
+@RunWith(AndroidJUnit4.class)
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+public class NetworkTransitionTest {
+
+ private static final String TAG = "NBNetTransitionTest";
+
+ /** Live remote peer, same as PeerConnectivityTest's relay-capable target. */
+ private static final String PEER_FQDN = "pingtest.netbird.cloud";
+ /** The Home status text for the engine's NO_NETWORK state (English locale). */
+ private static final String STATUS_NO_NETWORK = "No network available";
+
+ /** Budget for reaching a scenario's starting state — setup, not an assertion of speed. */
+ private static final long BASELINE_TIMEOUT_SEC = 90;
+ private static final long CONNECT_TIMEOUT_SEC = 20;
+ /** How long the UI may take to show NO_NETWORK after the last transport drops. */
+ private static final long NO_NETWORK_UI_TIMEOUT_SEC = 15;
+ /** Recovery budget after a full blackout: transport re-association + engine unpark + ICE. */
+ private static final long BLACKOUT_RECOVERY_SEC = 15;
+ /** Recovery budget after a transport switch: the sweep + re-dial fast path settles in ~1-2s. */
+ private static final long SWITCH_RECOVERY_SEC = 5;
+ /** Longest tolerated outage during a cellular->WiFi handover (PR #243's claim: ~1-2s fixed, 10-20s broken). */
+ private static final long HANDOVER_MAX_OUTAGE_SEC = 5;
+ private static final long HANDOVER_PROBE_WINDOW_SEC = 45;
+ private static final long SEAMLESS_PROBE_WINDOW_SEC = 15;
+ private static final int SEAMLESS_MAX_FAILED_PROBES = 1;
+ private static final long LONG_BLACKOUT_HOLD_SEC = 60;
+ /** Time given to Android to move the default network onto freshly-enabled WiFi. */
+ private static final long HANDOVER_SETTLE_SEC = 10;
+ /** Per-probe ping timeout; also the outage-measurement granularity. */
+ private static final int PROBE_TIMEOUT_SEC = 2;
+
+ private static VpnTestHarness harness;
+ private static String profileName;
+
+ @Before
+ public void setUp() throws Exception {
+ FailFast.skipIfAborted();
+ ensureProfileAndTunnel();
+ harness.setWifi(true);
+ harness.setMobileData(true);
+ assertTrue("baseline: peer " + PEER_FQDN + " must be reachable before the scenario",
+ harness.waitForPing(PEER_FQDN, BASELINE_TIMEOUT_SEC));
+ }
+
+ @AfterClass
+ public static void tearDownClass() throws Exception {
+ if (harness != null) {
+ harness.setWifi(true);
+ harness.setMobileData(true);
+ harness.disableTouchVisualization();
+ if (profileName != null) {
+ LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName);
+ profileName = null;
+ }
+ }
+ }
+
+ /** Scenario A1: blackout from WiFi-only, recovery lands on WiFi-only. */
+ @Test
+ public void a1AirplaneToggleWifiOnly() throws Exception {
+ harness.setMobileData(false);
+ assertTrue("peer must stay reachable on WiFi-only before the blackout",
+ harness.waitForPing(PEER_FQDN, BASELINE_TIMEOUT_SEC));
+ blackoutAndRecover("A1/wifi-only", () -> harness.setWifi(true));
+ }
+
+ /** Scenario A2: blackout from cellular-only, recovery lands on cellular-only. */
+ @Test
+ public void a2AirplaneToggleCellularOnly() throws Exception {
+ harness.setWifi(false);
+ assertTrue("peer must be reachable on cellular-only before the blackout",
+ harness.waitForPing(PEER_FQDN, BASELINE_TIMEOUT_SEC));
+ blackoutAndRecover("A2/cellular-only", () -> harness.setMobileData(true));
+ }
+
+ /** Scenario A3: blackout from WiFi+cellular, both transports restored. */
+ @Test
+ public void a3AirplaneToggleBothTransports() throws Exception {
+ blackoutAndRecover("A3/both", () -> {
+ harness.setWifi(true);
+ harness.setMobileData(true);
+ });
+ }
+
+ /**
+ * Scenario A4: a 60s blackout must hold a stable NO_NETWORK state — the
+ * parked retry loops must not flap the status — and still recover fast
+ * once a transport returns.
+ */
+ @Test
+ public void a4LongBlackoutHoldsNoNetwork() throws Exception {
+ blackout();
+ assertTrue("status must show '" + STATUS_NO_NETWORK + "' within "
+ + NO_NETWORK_UI_TIMEOUT_SEC + "s of losing all transports",
+ harness.awaitStatusText(STATUS_NO_NETWORK, NO_NETWORK_UI_TIMEOUT_SEC));
+
+ long holdEnd = System.currentTimeMillis() + LONG_BLACKOUT_HOLD_SEC * 1000L;
+ while (System.currentTimeMillis() < holdEnd) {
+ assertTrue("status flapped away from '" + STATUS_NO_NETWORK + "' during the hold",
+ harness.awaitStatusText(STATUS_NO_NETWORK, 2));
+ Thread.sleep(5000);
+ }
+
+ harness.setWifi(true);
+ harness.setMobileData(true);
+ assertRecoveryWithin("A4/long-blackout", BLACKOUT_RECOVERY_SEC);
+ }
+
+ /** Scenario B1: losing WiFi must fail over to cellular via the fast path. */
+ @Test
+ public void b1WifiLossFallsBackToCellular() throws Exception {
+ harness.setWifi(false);
+ assertRecoveryWithin("B1/wifi-loss", SWITCH_RECOVERY_SEC);
+ }
+
+ /**
+ * Scenario B3: cellular data appearing underneath an active WiFi
+ * connection must be a no-op for the tunnel — Android keeps WiFi as the
+ * default network, so nothing may be swept or re-dialed (the PR #243
+ * review settled exactly this: a non-displacing transport must not reset
+ * peers).
+ */
+ @Test
+ public void b3CellularUnderWifiIsSeamless() throws Exception {
+ harness.setMobileData(false);
+ assertTrue("peer must be reachable on WiFi-only before enabling cellular",
+ harness.waitForPing(PEER_FQDN, BASELINE_TIMEOUT_SEC));
+
+ harness.setMobileData(true);
+ int failed = failedProbesOver(SEAMLESS_PROBE_WINDOW_SEC);
+ Log.i(TAG, "B3: " + failed + " failed probes in " + SEAMLESS_PROBE_WINDOW_SEC + "s window");
+ assertTrue("cellular appearing under WiFi disrupted the tunnel: " + failed
+ + " failed probes in " + SEAMLESS_PROBE_WINDOW_SEC + "s (max "
+ + SEAMLESS_MAX_FAILED_PROBES + ")",
+ failed <= SEAMLESS_MAX_FAILED_PROBES);
+ }
+
+ /** Scenario B4: cellular -> WiFi -> cellular round trip, ping recovers after each leg. */
+ @Test
+ public void b4CellularWifiCellularRoundTrip() throws Exception {
+ harness.setWifi(false);
+ assertTrue("peer must be reachable on cellular-only before the round trip",
+ harness.waitForPing(PEER_FQDN, BASELINE_TIMEOUT_SEC));
+
+ harness.setWifi(true);
+ assertTrue("peer unreachable after enabling WiFi",
+ harness.waitForPing(PEER_FQDN, BASELINE_TIMEOUT_SEC));
+ // Let the default network actually move onto WiFi before cutting it;
+ // an instant success above may still have gone over cellular.
+ Thread.sleep(HANDOVER_SETTLE_SEC * 1000L);
+ assertTrue("peer unreachable after the WiFi settle window",
+ harness.waitForPing(PEER_FQDN, BASELINE_TIMEOUT_SEC));
+
+ harness.setWifi(false);
+ assertRecoveryWithin("B4/back-to-cellular", SWITCH_RECOVERY_SEC);
+ }
+
+ /**
+ * Scenario B2 — named to sort LAST under NAME_ASCENDING because the
+ * FailFast listener aborts everything after the first failure, and this
+ * one is EXPECTED TO FAIL on this branch: PR #243 is what makes the
+ * cellular->WiFi handover notify the Go core immediately. Until it merges,
+ * the tunnel only recovers once ICE notices the dead connections, a
+ * 10-20s outage; the fixed fast path takes ~1-2s. The probe window starts
+ * before the outage does (the default network switches a few seconds
+ * after WiFi comes up), so the assertion is on the longest continuous
+ * outage inside the window, not on time-to-first-success.
+ */
+ @Test
+ public void zB2CellularToWifiHandoverIsFast() throws Exception {
+ harness.setWifi(false);
+ assertTrue("peer must be reachable on cellular-only before the handover",
+ harness.waitForPing(PEER_FQDN, BASELINE_TIMEOUT_SEC));
+
+ harness.setWifi(true);
+ long outageSec = maxOutageSecOver(HANDOVER_PROBE_WINDOW_SEC);
+ Log.i(TAG, "B2: max outage during cellular->WiFi handover: " + outageSec + "s");
+ assertTrue("cellular->WiFi handover outage was " + outageSec + "s, budget "
+ + HANDOVER_MAX_OUTAGE_SEC + "s — the fallback (ICE timeout) path did "
+ + "the recovery instead of the network-change fast path (see PR #243)",
+ outageSec <= HANDOVER_MAX_OUTAGE_SEC);
+ }
+
+ private static void ensureProfileAndTunnel() throws Exception {
+ if (profileName != null) {
+ return;
+ }
+ 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();
+ harness.setWifi(true);
+ harness.setMobileData(true);
+
+ // The suite-level default turns force relay OFF (the relay-less peer
+ // case needs that); these tests measure the relay path's failover, so
+ // turn it back ON before connecting. The P2P/ICE failover path is
+ // deliberately out of scope until it is optimized: a stale ICE
+ // connection keeps PriorityICEP2P and blocks the switch to the ready
+ // relay connection for ~7s (ICE disconnect detection).
+ LoginFlow.setForceRelay(activity, harness.device(), true);
+
+ profileName = LoginFlow.createProfileAndLogin(
+ activity, harness.device(), "network", setupKey);
+
+ boolean connected = harness.connectAndAwait(CONNECT_TIMEOUT_SEC);
+ if (!connected) {
+ LoginFlow.dumpScreenshot(harness.device(), "network-vpn-connect-timeout");
+ }
+ assertTrue("VPN did not reach connected state within " + CONNECT_TIMEOUT_SEC + "s",
+ connected);
+ }
+
+ /** Drop every transport — the emulator equivalent of airplane mode ON. */
+ private void blackout() {
+ harness.setWifi(false);
+ harness.setMobileData(false);
+ }
+
+ /**
+ * Shared A-scenario body: blackout, assert the engine reports NO_NETWORK
+ * on screen, run {@code restore}, assert the ping recovers in budget.
+ */
+ private void blackoutAndRecover(String scenario, Runnable restore) throws Exception {
+ blackout();
+ assertTrue(scenario + ": status must show '" + STATUS_NO_NETWORK + "' within "
+ + NO_NETWORK_UI_TIMEOUT_SEC + "s of losing all transports",
+ harness.awaitStatusText(STATUS_NO_NETWORK, NO_NETWORK_UI_TIMEOUT_SEC));
+
+ restore.run();
+ assertRecoveryWithin(scenario, BLACKOUT_RECOVERY_SEC);
+ }
+
+ /** Assert the data plane recovers within {@code budgetSec}, logging the measured time. */
+ private void assertRecoveryWithin(String scenario, long budgetSec) throws InterruptedException {
+ long recoverySec = timeToPingRecoverySec(budgetSec);
+ Log.i(TAG, scenario + ": recovery took "
+ + (recoverySec < 0 ? ">" + budgetSec : String.valueOf(recoverySec)) + "s");
+ if (recoverySec < 0) {
+ LoginFlow.dumpScreenshot(harness.device(), "recovery-timeout");
+ }
+ assertTrue(scenario + ": peer " + PEER_FQDN + " did not recover within " + budgetSec
+ + "s — slow recovery means the fallback logic reconnected, not the "
+ + "network-change fast path", recoverySec >= 0);
+ }
+
+ /** Seconds until the first successful probe, or -1 if the budget elapsed. */
+ private long timeToPingRecoverySec(long budgetSec) throws InterruptedException {
+ long start = System.currentTimeMillis();
+ long deadline = start + budgetSec * 1000L;
+ while (System.currentTimeMillis() < deadline) {
+ if (harness.pingOnce(PEER_FQDN, PROBE_TIMEOUT_SEC)) {
+ return (System.currentTimeMillis() - start + 999) / 1000;
+ }
+ Thread.sleep(1000);
+ }
+ return -1;
+ }
+
+ /**
+ * Probe continuously for {@code windowSec} and return the longest
+ * continuous outage in seconds (granularity ~{@link #PROBE_TIMEOUT_SEC}s).
+ * An outage still open when the window closes counts up to the window end.
+ */
+ private long maxOutageSecOver(long windowSec) throws InterruptedException {
+ long windowEnd = System.currentTimeMillis() + windowSec * 1000L;
+ long outageStartMs = -1;
+ long maxOutageMs = 0;
+ while (System.currentTimeMillis() < windowEnd) {
+ long probeStart = System.currentTimeMillis();
+ if (harness.pingOnce(PEER_FQDN, PROBE_TIMEOUT_SEC)) {
+ if (outageStartMs >= 0) {
+ maxOutageMs = Math.max(maxOutageMs, probeStart - outageStartMs);
+ outageStartMs = -1;
+ }
+ Thread.sleep(700);
+ } else if (outageStartMs < 0) {
+ outageStartMs = probeStart;
+ }
+ }
+ if (outageStartMs >= 0) {
+ maxOutageMs = Math.max(maxOutageMs, System.currentTimeMillis() - outageStartMs);
+ }
+ return (maxOutageMs + 999) / 1000;
+ }
+
+ /** Probe continuously for {@code windowSec} and count the failed probes. */
+ private int failedProbesOver(long windowSec) throws InterruptedException {
+ long windowEnd = System.currentTimeMillis() + windowSec * 1000L;
+ int failed = 0;
+ while (System.currentTimeMillis() < windowEnd) {
+ if (!harness.pingOnce(PEER_FQDN, PROBE_TIMEOUT_SEC)) {
+ failed++;
+ } else {
+ Thread.sleep(700);
+ }
+ }
+ return failed;
+ }
+}
diff --git a/app/src/androidTest/java/io/netbird/client/e2e/VpnTestHarness.java b/app/src/androidTest/java/io/netbird/client/e2e/VpnTestHarness.java
index 819af840..a5d06216 100644
--- a/app/src/androidTest/java/io/netbird/client/e2e/VpnTestHarness.java
+++ b/app/src/androidTest/java/io/netbird/client/e2e/VpnTestHarness.java
@@ -121,6 +121,36 @@ boolean connectAndAwait(long timeoutSec) throws InterruptedException {
return connected;
}
+ /**
+ * Toggle the emulator's virtual WiFi transport. {@code svc wifi} works on
+ * the API 30 image the e2e workflow runs on (removed in API 31+, where
+ * {@code cmd wifi set-wifi-enabled} replaces it).
+ */
+ void setWifi(boolean enabled) {
+ String out = shell("svc wifi " + (enabled ? "enable" : "disable"));
+ Log.i(TAG, "svc wifi " + (enabled ? "enable" : "disable") + " -> " + out.trim());
+ }
+
+ /** Toggle the emulator's virtual cellular data transport. */
+ void setMobileData(boolean enabled) {
+ String out = shell("svc data " + (enabled ? "enable" : "disable"));
+ Log.i(TAG, "svc data " + (enabled ? "enable" : "disable") + " -> " + out.trim());
+ }
+
+ /**
+ * Wait until the Home screen's status text shows exactly {@code expected}
+ * (English locale, like {@link #connectAndAwait(long)}).
+ */
+ boolean awaitStatusText(String expected, long timeoutSec) {
+ boolean shown = device.wait(
+ Until.hasObject(By.res(LoginFlow.PACKAGE, "text_connection_status")
+ .text(expected)),
+ timeoutSec * 1000L);
+ Log.i(TAG, "Status '" + expected + "' " + (shown ? "shown" : "NOT shown")
+ + " within " + timeoutSec + "s");
+ return shown;
+ }
+
/** 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);
@@ -156,7 +186,16 @@ private void logJavaResolution(String target) {
/** 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));
+ return pingOnce(target, PING_W_SEC);
+ }
+
+ /**
+ * Ping a host once with a caller-chosen per-attempt timeout. The network
+ * transition tests probe on a ~1s cadence to measure outage windows, so
+ * they need a tighter timeout than the default {@link #PING_W_SEC}.
+ */
+ boolean pingOnce(String target, int timeoutSec) {
+ String output = shell(String.format("ping -c 1 -W %d %s", timeoutSec, target));
// The full output goes to logcat — the CI artifact — so a failure shows
// exactly what happened: the resolved address in the "PING x (a.b.c.d)"
// header, an unknown-host error, or 100% loss to a resolved peer.
@@ -225,12 +264,21 @@ boolean waitForResolve(String host, String expectedIp, long timeoutSec) throws I
* exit node, the returned IP is the exit node's, not the device's.
*/
String httpGet(String urlString) {
+ return httpGet(urlString, 10_000);
+ }
+
+ /**
+ * {@link #httpGet(String)} with a caller-chosen timeout. The network
+ * transition tests probe with short timeouts so a request hung on a dead
+ * route cannot blur the recovery-time measurement.
+ */
+ String httpGet(String urlString, int timeoutMs) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlString);
conn = (HttpURLConnection) url.openConnection();
- conn.setConnectTimeout(10_000);
- conn.setReadTimeout(10_000);
+ conn.setConnectTimeout(timeoutMs);
+ conn.setReadTimeout(timeoutMs);
conn.setRequestMethod("GET");
int code = conn.getResponseCode();
if (code != HttpURLConnection.HTTP_OK) {
@@ -263,16 +311,26 @@ String httpGet(String urlString) {
*/
boolean waitForHttpBodyContains(String urlString, String expectedSubstring, long timeoutSec)
throws InterruptedException {
+ return waitForHttpBodyContains(urlString, expectedSubstring, timeoutSec, 10_000, 3000);
+ }
+
+ /**
+ * {@link #waitForHttpBodyContains(String, String, long)} with caller-chosen
+ * per-probe timeout and poll interval, for recovery-time measurements that
+ * need finer granularity than the 10s/3s defaults.
+ */
+ boolean waitForHttpBodyContains(String urlString, String expectedSubstring, long timeoutSec,
+ int probeTimeoutMs, long pollMs) 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);
+ last = httpGet(urlString, probeTimeoutMs);
if (last != null && p.matcher(last).find()) {
Log.i(TAG, "GET " + urlString + " body matched " + expectedSubstring);
return true;
}
- Thread.sleep(3000);
+ Thread.sleep(pollMs);
}
Log.w(TAG, "GET " + urlString + " never matched " + expectedSubstring + " (last: " + last + ")");
return false;
diff --git a/app/src/main/java/io/netbird/client/MainActivity.java b/app/src/main/java/io/netbird/client/MainActivity.java
index da03e843..59210f3e 100644
--- a/app/src/main/java/io/netbird/client/MainActivity.java
+++ b/app/src/main/java/io/netbird/client/MainActivity.java
@@ -54,6 +54,7 @@
import io.netbird.client.tool.VPNService;
import io.netbird.client.ui.PreferenceUI;
import io.netbird.client.ui.ssh.SshSessionManager;
+import io.netbird.gomobile.android.Android;
import io.netbird.gomobile.android.ConnectionListener;
import io.netbird.gomobile.android.ErrListener;
import io.netbird.gomobile.android.NetworkArray;
@@ -71,7 +72,8 @@ private enum ConnectionState {
CONNECTED,
CONNECTING,
DISCONNECTING,
- DISCONNECTED
+ DISCONNECTED,
+ NO_NETWORK
}
private final static String LOGTAG = "NBMainActivity";
private VPNService.MyLocalBinder mBinder;
@@ -675,6 +677,9 @@ public void registerServiceStateListener(StateListener listener) {
case DISCONNECTED:
listener.onDisconnected();
break;
+ case NO_NETWORK:
+ listener.onNoNetwork();
+ break;
}
if (lastFqdn != null && lastIp != null) {
@@ -775,7 +780,30 @@ private void showAlwaysOnDialog(Runnable onDismissAction) {
alertDialog.show();
}
+ /** Maps a gomobile ClientState value to a readable name for logging. */
+ private static String stateName(long state) {
+ if (state == Android.ClientStateDisconnected) return "Disconnected";
+ if (state == Android.ClientStateConnected) return "Connected";
+ if (state == Android.ClientStateConnecting) return "Connecting";
+ if (state == Android.ClientStateDisconnecting) return "Disconnecting";
+ if (state == Android.ClientStateNoNetwork) return "NoNetwork";
+ return "Unknown";
+ }
+
ConnectionListener connectionListener = new ConnectionListener() {
+ @Override
+ public void onStateChanged(long state) {
+ Log.d(LOGTAG, "GO CALLBACK onStateChanged(" + state + " = " + stateName(state) + ")");
+ // Legacy per-state callbacks drive the existing states; only the
+ // states delivered exclusively through this callback are handled.
+ if (state == Android.ClientStateNoNetwork) {
+ lastKnownState = ConnectionState.NO_NETWORK;
+ for (StateListener listener : serviceStateListeners) {
+ listener.onNoNetwork();
+ }
+ }
+ }
+
@Override
public synchronized void onAddressChanged(String fqdn, String ip) {
lastFqdn = fqdn;
@@ -787,6 +815,7 @@ public synchronized void onAddressChanged(String fqdn, String ip) {
}
public void onConnected() {
+ Log.d(LOGTAG, "GO CALLBACK onConnected()");
lastKnownState = ConnectionState.CONNECTED;
isSSOFinishedWell = true;
@@ -796,6 +825,7 @@ public void onConnected() {
}
public void onConnecting() {
+ Log.d(LOGTAG, "GO CALLBACK onConnecting()");
lastKnownState = ConnectionState.CONNECTING;
isSSOFinishedWell = true;
@@ -805,6 +835,7 @@ public void onConnecting() {
}
public void onDisconnecting() {
+ Log.d(LOGTAG, "GO CALLBACK onDisconnecting()");
lastKnownState = ConnectionState.DISCONNECTING;
for (StateListener listener : serviceStateListeners) {
@@ -813,6 +844,7 @@ public void onDisconnecting() {
}
public void onDisconnected() {
+ Log.d(LOGTAG, "GO CALLBACK onDisconnected()");
lastKnownState = ConnectionState.DISCONNECTED;
isSSOFinishedWell = false;
diff --git a/app/src/main/java/io/netbird/client/StateListener.java b/app/src/main/java/io/netbird/client/StateListener.java
index 27bf7f3e..1601e798 100644
--- a/app/src/main/java/io/netbird/client/StateListener.java
+++ b/app/src/main/java/io/netbird/client/StateListener.java
@@ -9,6 +9,14 @@ public interface StateListener {
void onConnecting();
+ /**
+ * Connection attempts are suspended because the OS reports no usable
+ * network; shown instead of "Connecting". Default is a no-op so only
+ * listeners that display state need to implement it.
+ */
+ default void onNoNetwork() {
+ }
+
void onDisconnected();
void onDisconnecting();
diff --git a/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java b/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java
index 82b2cbe0..dc7a0bf0 100644
--- a/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/home/HomeFragment.java
@@ -37,6 +37,8 @@
public class HomeFragment extends Fragment implements StateListener, RouteChangeListener, ProfilePickerSheet.OnProfileSwitchedListener {
+ private static final String LOGTAG = "HomeFragment";
+
private FragmentHomeBinding binding;
// Set only while the addresses are floating; see toggleInfoRows.
private PopupWindow addressPopup;
@@ -57,7 +59,7 @@ public class HomeFragment extends Fragment implements StateListener, RouteChange
*/
private ObjectAnimator disabledPulse;
- private enum EngineState { CONNECTING, CONNECTED, DISCONNECTING, DISCONNECTED }
+ private enum EngineState { CONNECTING, CONNECTED, DISCONNECTING, DISCONNECTED, NO_NETWORK }
private static final long PENDING_ACTION_TIMEOUT_MS = 7_000;
@@ -267,7 +269,7 @@ private void updateProfileChip() {
Profile activeProfile = profileManager.getActiveProfile();
binding.profileChipText.setText(activeProfile != null ? activeProfile.getName() : "");
} catch (Exception e) {
- Log.e("HomeFragment", "Failed to read active profile", e);
+ Log.e(LOGTAG, "Failed to read active profile", e);
binding.profileChipText.setText("");
}
}
@@ -392,6 +394,8 @@ private void copyToClipboard(CharSequence value) {
}
private void setToggle(boolean checked, boolean enabled, int statusResId) {
+ Log.d(LOGTAG, "UI paint requested: status=" + statusResName(statusResId)
+ + " toggle=" + checked + " enabled=" + enabled);
runOnUi(() -> {
if (buttonConnect != null) {
// setChecked animates the thumb; on a freshly inflated view that reads as the
@@ -409,6 +413,7 @@ private void setToggle(boolean checked, boolean enabled, int statusResId) {
}
if (textConnStatus != null) {
textConnStatus.setText(statusResId);
+ Log.d(LOGTAG, "UI painted: status=\"" + textConnStatus.getText() + "\"");
}
});
}
@@ -449,10 +454,25 @@ private void stopDisabledPulse() {
}
}
+ /**
+ * Resource entry name for the status label, so the log names the string
+ * without touching the fragment's context off the UI thread.
+ */
+ private String statusResName(int statusResId) {
+ try {
+ return getResources().getResourceEntryName(statusResId);
+ } catch (Exception e) {
+ return String.valueOf(statusResId);
+ }
+ }
+
private void onEngineState(EngineState state) {
+ Log.d(LOGTAG, "UI state received: " + state + " (previous=" + lastEngineState
+ + ", pendingTarget=" + pendingTarget + ")");
lastEngineState = state;
isConnected = state == EngineState.CONNECTED;
if (shouldSuppressPaint(state)) {
+ Log.d(LOGTAG, "UI paint SUPPRESSED for " + state + " (pendingTarget=" + pendingTarget + ")");
return;
}
applyEngineState(state);
@@ -477,7 +497,7 @@ private boolean shouldSuppressPaint(EngineState state) {
return state != EngineState.DISCONNECTING;
}
// target == CONNECTED
- if (state == EngineState.CONNECTING) {
+ if (state == EngineState.CONNECTING || state == EngineState.NO_NETWORK) {
// Same-direction progress: let it paint.
return false;
}
@@ -495,6 +515,7 @@ private boolean shouldSuppressPaint(EngineState state) {
private void applyEngineState(EngineState state) {
switch (state) {
case CONNECTING:
+ case NO_NETWORK:
case DISCONNECTING:
paintTransition(state);
break;
@@ -514,6 +535,7 @@ private void applyEngineState(EngineState state) {
private boolean isTransitioning() {
return pendingTarget != null
|| lastEngineState == EngineState.CONNECTING
+ || lastEngineState == EngineState.NO_NETWORK
|| lastEngineState == EngineState.DISCONNECTING;
}
@@ -525,6 +547,8 @@ private boolean isTransitioning() {
private void paintTransition(EngineState state) {
if (state == EngineState.CONNECTING) {
setToggle(true, canForceCancel, R.string.main_status_connecting);
+ } else if (state == EngineState.NO_NETWORK) {
+ setToggle(true, canForceCancel, R.string.main_status_no_network);
} else {
setToggle(false, canForceCancel, R.string.main_status_disconnecting);
}
@@ -783,6 +807,11 @@ public void onConnecting() {
onEngineState(EngineState.CONNECTING);
}
+ @Override
+ public void onNoNetwork() {
+ onEngineState(EngineState.NO_NETWORK);
+ }
+
@Override
public void onDisconnected() {
onEngineState(EngineState.DISCONNECTED);
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index d58eec44..5308d9fc 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -26,6 +26,7 @@
Nicht verbunden
Wird verbunden…
+ Kein Netzwerk verfügbar
Verbunden
Wird getrennt…
Verbinden/Trennen
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 78132283..3353a819 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -26,6 +26,7 @@
Desconectado
Conectando…
+ No hay red disponible
Conectado
Desconectando…
conectar/desconectar
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 2f6b394d..af9b2046 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -26,6 +26,7 @@
Déconnecté
Connexion…
+ Aucun réseau disponible
Connecté
Déconnexion…
connexion/déconnexion
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index 93fa78f9..c6be16ed 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -26,6 +26,7 @@
Lecsatlakozva
Csatlakozás…
+ Nincs elérhető hálózat
Csatlakozva
Lecsatlakozás…
csatlakozás-leválasztás
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index e5c6de29..4ed223e3 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -26,6 +26,7 @@
Disconnesso
Connessione…
+ Nessuna rete disponibile
Connesso
Disconnessione…
connetti/disconnetti
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 0cef72ee..f82e45ee 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -26,6 +26,7 @@
未接続
接続中…
+ ネットワークがありません
接続済み
切断中…
接続/切断
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 9de69a1e..96528fe5 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -26,6 +26,7 @@
Desconectado
Conectando…
+ Nenhuma rede disponível
Conectado
Desconectando…
conectar/desconectar
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 47e67bb9..7229d7a3 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -26,6 +26,7 @@
Отключено
Подключение…
+ Сеть недоступна
Подключено
Отключение…
подключение/отключение
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 445d9be9..5e64b8de 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -26,6 +26,7 @@
已断开连接
正在连接…
+ 无可用网络
已连接
正在断开连接…
连接/断开
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c724ed7e..6f0c01d7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -26,6 +26,7 @@
Disconnected
Connecting…
+ No network available
Connected
Disconnecting…
Login required
diff --git a/netbird b/netbird
index ecfbd686..310f9cca 160000
--- a/netbird
+++ b/netbird
@@ -1 +1 @@
-Subproject commit ecfbd686b807f2e47295e1dfad3cd86b47af3c84
+Subproject commit 310f9cca3f6314dd1b35f8f88b3054af8dc839e7
diff --git a/tests.md b/tests.md
new file mode 100644
index 00000000..873bf07c
--- /dev/null
+++ b/tests.md
@@ -0,0 +1,31 @@
+# Instrumented tests
+
+On-device e2e tests for the Android client, run on an emulator or a real
+device. The test group is selected with the instrumentation runner's
+`class` argument. The optional `FailFast` listener
+(`listener=io.netbird.client.e2e.FailFast`) skips everything after the
+first failure.
+
+## Groups (suite classes)
+
+| `class` value | What it runs |
+|---|---|
+| `io.netbird.client.e2e.E2eSuite` | The full suite, in order: auth, peer connectivity, ACL, DNS, exit node, network transitions. Turns force relay OFF once at start. |
+| `io.netbird.client.e2e.NetworkTransitionSuite` | Only the network transition scenarios (exit-node variant + the transition matrix). The member tests turn force relay ON themselves. |
+
+Any single test class below also works as a `class` value.
+
+## Test classes
+
+| Class | What it verifies |
+|---|---|
+| `SetupKeyAuthTest` | Login through the profile editor UI. |
+| `PeerConnectivityTest` | Ping to a live peer over the tunnel, with and without relay support. |
+| `PortAclTest` | ACL enforcement: TCP 80 reachable, ICMP blocked on the same peer. |
+| `DnsResolutionTest` | Tunnel DNS: peer FQDN and search-domain name resolve to the private IP. |
+| `ExitNodeRouteTest` | Public egress goes through the exit node. |
+| `ExitNodeNetworkTransitionTest` | Exit-node egress re-establishes after WiFi loss and a full blackout, within tight time budgets. |
+| `NetworkTransitionTest` | Network transition scenarios (airplane mode on/off from WiFi/cellular/both, transport switches, handover); recovery must complete within tight time budgets. |
+
+Not part of any suite: `NetworkConnectivityStressTest` (manual, random
+disruption stress cycles) and the `ExampleInstrumentedTest` scaffolding.
diff --git a/tool/src/main/java/io/netbird/client/tool/EngineRestarter.java b/tool/src/main/java/io/netbird/client/tool/EngineRestarter.java
deleted file mode 100644
index ecebc474..00000000
--- a/tool/src/main/java/io/netbird/client/tool/EngineRestarter.java
+++ /dev/null
@@ -1,355 +0,0 @@
-package io.netbird.client.tool;
-
-import android.content.Context;
-import android.os.Handler;
-import android.os.Looper;
-import android.util.Log;
-
-import java.util.List;
-import java.util.concurrent.atomic.AtomicReference;
-
-import io.netbird.client.tool.networks.NetworkToggleListener;
-import io.netbird.gomobile.android.ConnectionListener;
-
-/**
- *
EngineRestarter restarts the Go engine.
- * It implements {@link NetworkToggleListener} to restart the engine when the available network type changes.
- */
-class EngineRestarter implements NetworkToggleListener {
- private static final String LOGTAG = EngineRestarter.class.getSimpleName();
- private static final long DEBOUNCE_DELAY_MS = 2000; // 2 seconds delay
- private static final long RESTART_TIMEOUT_MS = 30000; // 30 seconds
- private final EngineRunner engineRunner;
- private final Handler handler;
- private final Runnable restartRunnable;
- private Runnable timeoutCallback;
- private ServiceStateListener currentListener;
-
- private volatile boolean isRestartInProgress = false;
- private boolean restartScheduled = false;
- private final Object restartLock = new Object();
- private final AtomicReference> suppressedHolder = new AtomicReference<>();
- private final Runnable connectedObserver = this::onEngineReconnected;
-
- public EngineRestarter(EngineRunner engineRunner) {
- this.engineRunner = engineRunner;
- this.handler = new Handler(Looper.getMainLooper());
- this.restartRunnable = this::restartEngine;
- this.engineRunner.addOnConnectedObserver(connectedObserver);
- }
-
- private void onEngineReconnected() {
- // The Go core reconnected on its own; the pending restart is no
- // longer needed. Cancel the debounced restart so we do not tear
- // down a working connection.
- synchronized (restartLock) {
- if (restartScheduled) {
- Log.d(LOGTAG, "engine reconnected on its own, cancelling pending restart");
- restartScheduled = false;
- handler.removeCallbacks(restartRunnable);
- }
- }
- }
-
- /**
- * Restarts the Go engine currently running.
- * It registers an anonymous implementation of {@link ServiceStateListener}
- * in order to know for sure when the engine actually stops in order to restart it.
- * If the engine isn't running, this method does nothing.
- */
- private void restartEngine() {
- synchronized (restartLock) {
- restartScheduled = false;
- }
-
- // Prevent concurrent restarts
- if (isRestartInProgress) {
- Log.d(LOGTAG, "restart already in progress, ignoring duplicate request");
- return;
- }
-
- if (!engineRunner.isRunning()) {
- Log.d(LOGTAG, "engine not running, skipping restart");
- return;
- }
-
- isRestartInProgress = true;
-
- // Snapshot the current listener and wrap it so disconnect events from
- // the old engine teardown — and the synthetic Disconnected the new
- // engine emits before its first ClientStart() — do not reach the UI.
- // Unwrap any leftover FilteringConnectionListener from a previous
- // restart so wrappers do not stack on each cycle.
- ConnectionListener savedListener = unwrapFilter(engineRunner.getConnectionListener());
- FilteringConnectionListener filteringListener =
- savedListener != null ? new FilteringConnectionListener(savedListener) : null;
- if (filteringListener != null) {
- engineRunner.setConnectionListener(filteringListener);
- }
-
- timeoutCallback = () -> {
- if (isRestartInProgress) {
- Log.e(LOGTAG, "engine restart timeout - forcing flag reset");
- isRestartInProgress = false;
- if (filteringListener != null) {
- filteringListener.allowAll();
- }
- unsuppressAll(suppressedHolder.getAndSet(null));
- // Unregister so a late onStopped can no longer trigger
- // runWithoutAuth against this stale listener.
- if (currentListener != null) {
- engineRunner.removeServiceStateListener(currentListener);
- currentListener = null;
- }
- notifyDisconnected(savedListener);
- }
- };
-
- // Safety timeout in case restart never completes
- handler.postDelayed(timeoutCallback, RESTART_TIMEOUT_MS);
-
- Log.d(LOGTAG, "initiating engine restart due to network change");
-
- var serviceStateListener = new ServiceStateListener() {
- @Override
- public void onStarted() {
- Log.d(LOGTAG, "engine restarted successfully");
- isRestartInProgress = false; // Reset flag on success
- handler.removeCallbacks(timeoutCallback); // Cancel timeout
- engineRunner.removeServiceStateListener(this);
- currentListener = null;
- // Restore the original listener so the FilteringConnectionListener
- // wrapper does not accumulate across restart cycles.
- if (filteringListener != null && savedListener != null) {
- engineRunner.setConnectionListener(savedListener);
- }
- unsuppressAll(suppressedHolder.getAndSet(null));
- }
-
- @Override
- public void onStopped() {
- Log.d(LOGTAG, "engine is stopped, restarting...");
- engineRunner.runWithoutAuth();
- }
-
- @Override
- public void onError(String msg) {
- Log.e(LOGTAG, "restart failed: " + msg);
- isRestartInProgress = false; // Resetting flag on error as well
- handler.removeCallbacks(timeoutCallback); // Cancel timeout
- engineRunner.removeServiceStateListener(this);
- currentListener = null;
- if (filteringListener != null) {
- filteringListener.allowAll();
- }
- unsuppressAll(suppressedHolder.getAndSet(null));
- notifyDisconnected(savedListener);
- }
- };
- currentListener = serviceStateListener;
-
- // Atomically check and register to avoid race condition
- if (!engineRunner.addServiceStateListenerForRestart(serviceStateListener)) {
- Log.d(LOGTAG, "engine stopped before restart could begin - aborting");
- handler.removeCallbacks(timeoutCallback);
- isRestartInProgress = false;
- if (filteringListener != null) {
- engineRunner.setConnectionListener(savedListener);
- }
- return;
- }
-
- // Suppress external service-state listeners so the old engine's
- // onStopped (and the new engine's onStarted) do not reach the UI;
- // we drive UI state through ConnectionListener exclusively during
- // the restart window.
- List suppressed =
- engineRunner.snapshotExternalListeners(serviceStateListener);
- for (ServiceStateListener s : suppressed) {
- engineRunner.suppressServiceStateListener(s);
- }
- suppressedHolder.set(suppressed);
-
- Log.d(LOGTAG, "engine is running, stopping due to network change");
- notifyConnecting(savedListener);
- engineRunner.stop();
- }
-
- private void unsuppressAll(List suppressed) {
- if (suppressed == null) return;
- for (ServiceStateListener s : suppressed) {
- engineRunner.unsuppressServiceStateListener(s);
- }
- }
-
- private static ConnectionListener unwrapFilter(ConnectionListener listener) {
- ConnectionListener current = listener;
- while (current instanceof FilteringConnectionListener) {
- current = ((FilteringConnectionListener) current).delegate;
- }
- return current;
- }
-
- private void notifyConnecting(ConnectionListener listener) {
- if (listener == null) {
- return;
- }
- try {
- listener.onConnecting();
- } catch (Exception e) {
- Log.w(LOGTAG, "onConnecting notification failed: " + e.getMessage());
- }
- }
-
- private void notifyDisconnected(ConnectionListener listener) {
- if (listener == null) {
- return;
- }
- try {
- listener.onDisconnected();
- } catch (Exception e) {
- Log.w(LOGTAG, "onDisconnected notification failed: " + e.getMessage());
- }
- }
-
- /**
- * Wraps a ConnectionListener and drops Disconnecting/Disconnected events
- * during a restart. Disconnects from the old engine's teardown — and the
- * default-state replay the Go notifier sends to a listener attached
- * before the new engine's ClientStart() — would otherwise flash the UI
- * to Disconnected. The wrapper is replaced with the original listener on
- * successful restart (or has its filter disabled via allowAll on error /
- * timeout), so it never lives past a single restart cycle.
- */
- private static final class FilteringConnectionListener implements ConnectionListener {
- final ConnectionListener delegate;
- private volatile boolean dropDisconnects = true;
-
- FilteringConnectionListener(ConnectionListener delegate) {
- this.delegate = delegate;
- }
-
- void allowAll() {
- dropDisconnects = false;
- }
-
- @Override
- public void onConnecting() {
- try {
- delegate.onConnecting();
- } catch (Exception e) {
- Log.w(LOGTAG, "delegate onConnecting failed: " + e.getMessage());
- }
- }
-
- @Override
- public void onConnected() {
- try {
- delegate.onConnected();
- } catch (Exception e) {
- Log.w(LOGTAG, "delegate onConnected failed: " + e.getMessage());
- }
- }
-
- @Override
- public void onDisconnecting() {
- if (dropDisconnects) {
- Log.d(LOGTAG, "filtered onDisconnecting during restart");
- return;
- }
- try {
- delegate.onDisconnecting();
- } catch (Exception e) {
- Log.w(LOGTAG, "delegate onDisconnecting failed: " + e.getMessage());
- }
- }
-
- @Override
- public void onDisconnected() {
- if (dropDisconnects) {
- Log.d(LOGTAG, "filtered onDisconnected during restart");
- return;
- }
- try {
- delegate.onDisconnected();
- } catch (Exception e) {
- Log.w(LOGTAG, "delegate onDisconnected failed: " + e.getMessage());
- }
- }
-
- @Override
- public void onAddressChanged(String fqdn, String ip) {
- try {
- delegate.onAddressChanged(fqdn, ip);
- } catch (Exception e) {
- Log.w(LOGTAG, "delegate onAddressChanged failed: " + e.getMessage());
- }
- }
-
- @Override
- public void onPeersListChanged(long numberOfPeers) {
- try {
- delegate.onPeersListChanged(numberOfPeers);
- } catch (Exception e) {
- Log.w(LOGTAG, "delegate onPeersListChanged failed: " + e.getMessage());
- }
- }
- }
-
- @Override
- public void onNetworkTypeChanged() {
- Log.d(LOGTAG, "network type changed, scheduling restart with "
- + DEBOUNCE_DELAY_MS + "ms debounce.");
-
- synchronized (restartLock) {
- restartScheduled = true;
- handler.removeCallbacks(restartRunnable);
- handler.postDelayed(restartRunnable, DEBOUNCE_DELAY_MS);
- }
- }
-
- /**
- * Cancels any pending debounced restart. Called whenever an external
- * actor (typically a user-driven Connect/Disconnect) takes over the
- * engine lifecycle, so the network-change-driven restart does not
- * interfere with that explicit action.
- */
- public void cancelPendingRestart() {
- synchronized (restartLock) {
- if (restartScheduled) {
- Log.d(LOGTAG, "external action took over engine lifecycle; cancelling pending restart");
- handler.removeCallbacks(restartRunnable);
- restartScheduled = false;
- }
- }
- }
-
- /**
- * Cleans up resources, like the restart runnable and timeout callback.
- * Call this when the EngineRestarter is no longer needed to prevent memory leaks.
- */
- public void cleanup() {
- synchronized (restartLock) {
- handler.removeCallbacks(restartRunnable);
- restartScheduled = false;
- }
-
- if (timeoutCallback != null) {
- handler.removeCallbacks(timeoutCallback);
- }
-
- if (currentListener != null) {
- engineRunner.removeServiceStateListener(currentListener);
- currentListener = null;
- }
-
- // Restore visibility for any external listeners that were suppressed
- // by an in-flight restart, so the upcoming engine stop is delivered
- // to them rather than swallowed by the suppression set.
- unsuppressAll(suppressedHolder.getAndSet(null));
-
- engineRunner.removeOnConnectedObserver(connectedObserver);
-
- isRestartInProgress = false;
- }
-}
diff --git a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
index 66fd2323..4bbb24d5 100644
--- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
+++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java
@@ -32,8 +32,8 @@ class EngineRunner {
private final ProfileManagerWrapper profileManager;
private boolean engineIsRunning = false;
Set serviceStateListeners = ConcurrentHashMap.newKeySet();
- private final Set suppressedServiceStateListeners = ConcurrentHashMap.newKeySet();
private final Set connectedObservers = ConcurrentHashMap.newKeySet();
+ private final Set connectionObservers = ConcurrentHashMap.newKeySet();
private volatile SessionMonitor sessionMonitor;
private final Client goClient;
private ConnectionListener connectionListener;
@@ -108,6 +108,24 @@ public void cancelExtendAuthSession() {
goClient.cancelExtendAuthSession();
}
+ // setNetworkAvailable forwards OS connectivity state to the Go client,
+ // which suspends its reconnect loops while no network is available. The
+ // Go-side state is process-global, so it may be called regardless of
+ // whether the engine is running.
+ public void setNetworkAvailable(boolean available) {
+ goClient.setNetworkAvailable(available);
+ }
+
+ // notifyNetworkChange tells the Go client the OS switched networks (e.g.
+ // cellular to WiFi). The Go side cuts the management, signal and relay
+ // connections, whose sockets are bound to the old network, so their
+ // reconnect loops redial immediately on the new one. Unlike an engine
+ // restart this keeps the TUN device, the WireGuard config and the peer
+ // state untouched.
+ public void notifyNetworkChange() {
+ goClient.notifyNetworkChange();
+ }
+
public void run(@NotNull URLOpener urlOpener, boolean isAndroidTV) {
runClient(urlOpener, isAndroidTV);
}
@@ -185,16 +203,46 @@ public synchronized boolean isRunning() {
}
public synchronized void setConnectionListener(ConnectionListener listener) {
- // Unwrap any previous ObservingConnectionListener to avoid stacking
- // wrappers across repeated set/get cycles (e.g. EngineRestarter snapshots
- // the current listener and re-installs it after wrapping its own filter
- // around it).
+ // Unwrap first: a null listener still gets wrapped (around a no-op) so
+ // the service's own observers keep receiving events, which means the
+ // wrapper can otherwise be handed back to us and stack on itself.
ConnectionListener raw = unwrap(listener);
- ConnectionListener wrapped = raw == null ? null : new ObservingConnectionListener(raw, connectedObservers);
+ ConnectionListener wrapped = raw == null
+ ? new ObservingConnectionListener(NO_OP_CONNECTION_LISTENER, connectedObservers, connectionObservers)
+ : new ObservingConnectionListener(raw, connectedObservers, connectionObservers);
this.connectionListener = wrapped;
goClient.setConnectionListener(wrapped);
}
+ /**
+ * Keeps the connection callbacks flowing while no UI is bound, so the
+ * service's own observers (the status-bar icon) still track the tunnel
+ * during always-on / boot starts.
+ */
+ private static final ConnectionListener NO_OP_CONNECTION_LISTENER = new ConnectionListener() {
+ @Override public void onStateChanged(long state) {}
+ @Override public void onConnecting() {}
+ @Override public void onConnected() {}
+ @Override public void onDisconnecting() {}
+ @Override public void onDisconnected() {}
+ @Override public void onAddressChanged(String f, String i) {}
+ @Override public void onPeersListChanged(long n) {}
+ };
+
+ /**
+ * Registers a service-owned observer of the tunnel's connection phase.
+ * Unlike the app's ConnectionListener this survives the UI unbinding, so
+ * it stays accurate for always-on VPN and boot starts.
+ */
+ public synchronized void addConnectionObserver(ConnectionListener observer) {
+ connectionObservers.add(observer);
+ // Make sure the Go core has a listener installed even before any UI
+ // binds, otherwise the observer would never be called.
+ if (connectionListener == null) {
+ setConnectionListener(null);
+ }
+ }
+
private static ConnectionListener unwrap(ConnectionListener listener) {
ConnectionListener current = listener;
while (current instanceof ObservingConnectionListener) {
@@ -204,49 +252,75 @@ private static ConnectionListener unwrap(ConnectionListener listener) {
}
private static final class ObservingConnectionListener implements ConnectionListener {
- final ConnectionListener delegate;
+ private final ConnectionListener delegate;
private final java.util.Set connectedObservers;
+ private final java.util.Set connectionObservers;
- ObservingConnectionListener(ConnectionListener delegate, java.util.Set connectedObservers) {
+ ObservingConnectionListener(ConnectionListener delegate, java.util.Set connectedObservers,
+ java.util.Set connectionObservers) {
this.delegate = delegate;
this.connectedObservers = connectedObservers;
+ this.connectionObservers = connectionObservers;
}
- @Override public void onConnecting() { delegate.onConnecting(); }
+ private void fanOut(java.util.function.Consumer call) {
+ for (ConnectionListener obs : connectionObservers) {
+ try { call.accept(obs); } catch (Exception e) { Log.w(LOGTAG, "connection observer failed", e); }
+ }
+ }
+
+ @Override public void onStateChanged(long state) {
+ Log.d(LOGTAG, "FROM GO: onStateChanged(" + state + ")");
+ delegate.onStateChanged(state);
+ fanOut(obs -> obs.onStateChanged(state));
+ }
+ @Override public void onConnecting() {
+ Log.d(LOGTAG, "FROM GO: onConnecting()");
+ delegate.onConnecting();
+ fanOut(ConnectionListener::onConnecting);
+ }
@Override public void onConnected() {
+ Log.d(LOGTAG, "FROM GO: onConnected()");
delegate.onConnected();
for (Runnable obs : connectedObservers) {
try { obs.run(); } catch (Exception e) { Log.w(LOGTAG, "connected observer failed", e); }
}
+ fanOut(ConnectionListener::onConnected);
+ }
+ @Override public void onDisconnecting() {
+ Log.d(LOGTAG, "FROM GO: onDisconnecting()");
+ delegate.onDisconnecting();
+ fanOut(ConnectionListener::onDisconnecting);
+ }
+ @Override public void onDisconnected() {
+ Log.d(LOGTAG, "FROM GO: onDisconnected()");
+ delegate.onDisconnected();
+ fanOut(ConnectionListener::onDisconnected);
}
- @Override public void onDisconnecting() { delegate.onDisconnecting(); }
- @Override public void onDisconnected() { delegate.onDisconnected(); }
@Override public void onAddressChanged(String f, String i) { delegate.onAddressChanged(f, i); }
@Override public void onPeersListChanged(long n) { delegate.onPeersListChanged(n); }
}
+ /**
+ * Detaches the UI's connection listener. The service's own observers are
+ * kept subscribed: dropping the Go-side listener entirely would freeze the
+ * status-bar icon for as long as no activity is bound, which is exactly
+ * when the notification is the only status the user can see.
+ */
public synchronized void removeStatusListener() {
- this.connectionListener = null;
- goClient.removeConnectionListener();
- }
-
- synchronized ConnectionListener getConnectionListener() {
- return connectionListener;
+ if (connectionObservers.isEmpty()) {
+ this.connectionListener = null;
+ goClient.removeConnectionListener();
+ return;
+ }
+ setConnectionListener(null);
}
- /**
- * Registers a callback that fires every time the engine reports
- * OnConnected. EngineRestarter uses this to cancel a pending restart
- * when the Go core has already reconnected on its own.
- */
+ /** Registers a callback that fires every time the engine reports OnConnected. */
public void addOnConnectedObserver(Runnable observer) {
connectedObservers.add(observer);
}
- public void removeOnConnectedObserver(Runnable observer) {
- connectedObservers.remove(observer);
- }
-
public synchronized void addServiceStateListener(ServiceStateListener serviceStateListener) {
if (engineIsRunning) {
serviceStateListener.onStarted();
@@ -256,46 +330,8 @@ public synchronized void addServiceStateListener(ServiceStateListener serviceSta
serviceStateListeners.add(serviceStateListener);
}
- /**
- * Atomically adds a listener if and only if the engine is currently running.
- * Does NOT fire immediate callbacks like addServiceStateListener does.
- *
- * @return true if listener was registered (engine was running), false otherwise
- */
- public synchronized boolean addServiceStateListenerForRestart(ServiceStateListener listener) {
- if (!engineIsRunning) {
- return false; // Engine not running, can't restart
- }
- // Add listener without firing immediate callback
- serviceStateListeners.add(listener);
- return true;
- }
-
public synchronized void removeServiceStateListener(ServiceStateListener serviceStateListener) {
serviceStateListeners.remove(serviceStateListener);
- suppressedServiceStateListeners.remove(serviceStateListener);
- }
-
- /**
- * Marks a listener as suppressed: it will not receive onStarted / onStopped
- * notifications until {@link #unsuppressServiceStateListener} is called.
- * Used by EngineRestarter to hide the engine teardown from external UI
- * listeners during a restart.
- */
- public synchronized void suppressServiceStateListener(ServiceStateListener listener) {
- suppressedServiceStateListeners.add(listener);
- }
-
- public synchronized void unsuppressServiceStateListener(ServiceStateListener listener) {
- suppressedServiceStateListeners.remove(listener);
- }
-
- public synchronized java.util.List snapshotExternalListeners(ServiceStateListener exclude) {
- java.util.List out = new java.util.ArrayList<>();
- for (ServiceStateListener s : serviceStateListeners) {
- if (s != exclude) out.add(s);
- }
- return out;
}
public synchronized void stop() {
@@ -323,9 +359,6 @@ private synchronized void notifyError(Exception e) {
private synchronized void notifyServiceStateListeners(boolean engineIsRunning) {
for (ServiceStateListener s : serviceStateListeners) {
- if (suppressedServiceStateListeners.contains(s)) {
- continue;
- }
if (engineIsRunning) {
s.onStarted();
} else {
diff --git a/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java b/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java
index fb2c7b54..a185c334 100644
--- a/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java
+++ b/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java
@@ -17,35 +17,84 @@
class ForegroundNotification {
private static final int NOTIFICATION_ID = 102;
+ /**
+ * Connection states the status-bar icon distinguishes, mirroring the
+ * desktop tray's iconForState(). The glyphs are the macOS template icons
+ * from client/ui/assets: Android tints the small icon itself and reads
+ * only its alpha, so the colored Windows/Linux variants are unusable and
+ * the desktop's needs-login glyph is byte-identical to the error one as a
+ * template (the two differ by color alone, which a tinted small icon
+ * cannot carry), so NEEDS_LOGIN shares the icon and is told apart by its
+ * text. NO_NETWORK has no desktop tray glyph of its own either, so it
+ * reuses the disconnected one on the same terms.
+ */
+ enum State {
+ CONNECTING(R.drawable.notification_icon_connecting),
+ CONNECTED(R.drawable.notification_icon_connected),
+ DISCONNECTED(R.drawable.notification_icon_disconnected),
+ NO_NETWORK(R.drawable.notification_icon_disconnected),
+ NEEDS_LOGIN(R.drawable.notification_icon_error),
+ ERROR(R.drawable.notification_icon_error);
+
+ final int icon;
+
+ State(int icon) {
+ this.icon = icon;
+ }
+ }
+
private final VpnService service;
private final Handler refreshHandler = new Handler(Looper.getMainLooper());
private final Runnable refreshRunnable = this::refreshSessionText;
private boolean foregroundActive;
private long sessionExpiresAtUnixSeconds;
+ private State state = State.CONNECTING;
public ForegroundNotification(android.net.VpnService vpnService) {
this.service = vpnService;
}
- public void startForeground() {
+ // The methods below are synchronized: they are called from the main
+ // thread and from the Go engine's callback threads, and each one reads
+ // and writes the state fields around a notify.
+
+ public synchronized void startForeground() {
foregroundActive = true;
service.startForeground(NOTIFICATION_ID, buildNotification());
scheduleSessionTextRefresh();
}
- public void stopForeground() {
+ public synchronized void stopForeground() {
foregroundActive = false;
refreshHandler.removeCallbacks(refreshRunnable);
service.stopForeground(true);
}
+ /**
+ * Swaps the status-bar icon and text for the new connection state. The
+ * notification is only re-posted while the service is in the foreground;
+ * otherwise the state is remembered for the next {@link #startForeground}.
+ */
+ public synchronized void setState(State newState) {
+ if (state == newState) {
+ return;
+ }
+ state = newState;
+ if (!foregroundActive) {
+ return;
+ }
+ NotificationManager manager =
+ (NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE);
+ manager.notify(NOTIFICATION_ID, buildNotification());
+ }
+
/**
* Shows the session deadline (live countdown + "Extend session" action)
- * on the persistent notification, or reverts to the plain "service is
- * running" text when the deadline is cleared (0). No-op while the
- * service is not in the foreground.
+ * on the persistent notification, or reverts to the plain connected text
+ * when the deadline is cleared (0). No-op while the service is not in
+ * the foreground.
*/
- public void updateSessionDeadline(long expiresAtUnixSeconds) {
+ public synchronized void updateSessionDeadline(long expiresAtUnixSeconds) {
this.sessionExpiresAtUnixSeconds = expiresAtUnixSeconds;
if (!foregroundActive) {
return;
@@ -114,13 +163,15 @@ private Notification buildNotification() {
PendingIntent pendingIntent = PendingIntent.getActivity(service, 0, notificationIntent, flags);
NotificationCompat.Builder builder = new NotificationCompat.Builder(service.getApplication(), channelId)
- .setSmallIcon(R.drawable.notification_icon)
+ .setSmallIcon(state.icon)
.setColor(Color.GRAY)
.setContentTitle(service.getResources().getString(R.string.service_name))
.setContentIntent(pendingIntent)
.setAutoCancel(false); // Keep notification after tap
- if (sessionExpiresAtUnixSeconds > 0) {
+ if (state != State.CONNECTED) {
+ builder.setContentText(service.getResources().getString(statusText()));
+ } else if (sessionExpiresAtUnixSeconds > 0) {
long expiresAtMs = sessionExpiresAtUnixSeconds * 1000L;
builder.setContentText(formatSessionExpiry(expiresAtMs - System.currentTimeMillis()))
// The chronometer renders a system-driven live countdown
@@ -131,12 +182,31 @@ private Notification buildNotification() {
.setChronometerCountDown(true)
.addAction(0, service.getString(R.string.session_notification_extend), extendIntent());
} else {
- builder.setContentText(service.getResources().getString(R.string.fg_notification_text));
+ // Connected, but the server published no session deadline (e.g. a
+ // setup-key peer): state the connection rather than the service.
+ builder.setContentText(service.getResources().getString(R.string.fg_notification_connected));
}
return builder.build();
}
+ private int statusText() {
+ switch (state) {
+ case CONNECTING:
+ return R.string.fg_notification_connecting;
+ case DISCONNECTED:
+ return R.string.fg_notification_disconnected;
+ case NO_NETWORK:
+ return R.string.fg_notification_no_network;
+ case NEEDS_LOGIN:
+ return R.string.fg_notification_needs_login;
+ case ERROR:
+ return R.string.fg_notification_error;
+ default:
+ return R.string.fg_notification_connected;
+ }
+ }
+
// formatSessionExpiry renders the time left as a localised sentence,
// matching the desktop tray and the home screen's session banner: the
// largest non-zero unit, rounded up so the label never claims less time
diff --git a/tool/src/main/java/io/netbird/client/tool/NetworkSwitchNotifier.java b/tool/src/main/java/io/netbird/client/tool/NetworkSwitchNotifier.java
new file mode 100644
index 00000000..4eba9e91
--- /dev/null
+++ b/tool/src/main/java/io/netbird/client/tool/NetworkSwitchNotifier.java
@@ -0,0 +1,31 @@
+package io.netbird.client.tool;
+
+import android.util.Log;
+
+import io.netbird.client.tool.networks.NetworkToggleListener;
+
+/**
+ * Forwards network type changes (e.g. cellular to WiFi) to the Go core, which
+ * debounces them and sweeps the service connections still bound to the old
+ * network. Connections that reconnected on their own survive the sweep, so no
+ * cancellation is needed here.
+ */
+class NetworkSwitchNotifier implements NetworkToggleListener {
+ private static final String LOGTAG = NetworkSwitchNotifier.class.getSimpleName();
+
+ private final EngineRunner engineRunner;
+
+ NetworkSwitchNotifier(EngineRunner engineRunner) {
+ this.engineRunner = engineRunner;
+ }
+
+ @Override
+ public void onNetworkTypeChanged() {
+ if (!engineRunner.isRunning()) {
+ Log.d(LOGTAG, "engine not running, skipping network change notification");
+ return;
+ }
+ Log.d(LOGTAG, "network type changed, notifying Go core");
+ engineRunner.notifyNetworkChange();
+ }
+}
diff --git a/tool/src/main/java/io/netbird/client/tool/SessionNotification.java b/tool/src/main/java/io/netbird/client/tool/SessionNotification.java
index 5667d22c..0310ca9c 100644
--- a/tool/src/main/java/io/netbird/client/tool/SessionNotification.java
+++ b/tool/src/main/java/io/netbird/client/tool/SessionNotification.java
@@ -56,7 +56,7 @@ private void show(String title, String text) {
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE);
Notification notification = new NotificationCompat.Builder(context, CHANNEL_ID)
- .setSmallIcon(R.drawable.notification_icon)
+ .setSmallIcon(R.drawable.notification_icon_error)
.setContentTitle(title)
.setContentText(text)
.setStyle(new NotificationCompat.BigTextStyle().bigText(text))
diff --git a/tool/src/main/java/io/netbird/client/tool/VPNService.java b/tool/src/main/java/io/netbird/client/tool/VPNService.java
index 239bb5c3..ea780bfa 100644
--- a/tool/src/main/java/io/netbird/client/tool/VPNService.java
+++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java
@@ -16,6 +16,7 @@
import io.netbird.client.tool.networks.ConcreteNetworkAvailabilityListener;
import io.netbird.client.tool.networks.NetworkChangeDetector;
+import io.netbird.gomobile.android.Android;
import io.netbird.gomobile.android.ConnectionListener;
import io.netbird.gomobile.android.ErrListener;
import io.netbird.gomobile.android.NetworkArray;
@@ -33,6 +34,13 @@ public class VPNService extends android.net.VpnService {
// on the persistent notification's "Extend session" action.
public static final String ACTION_EXTEND_SESSION = "io.netbird.client.intent.action.EXTEND_SESSION";
private static final String INTENT_ALWAYS_ON_START = "android.net.VpnService";
+ // Run-loop status labels, as returned by EngineRunner.status(); they come
+ // from internal.StatusType on the Go side.
+ private static final String STATUS_CONNECTED = "Connected";
+ private static final String STATUS_CONNECTING = "Connecting";
+ private static final String STATUS_NEEDS_LOGIN = "NeedsLogin";
+ private static final String STATUS_SESSION_EXPIRED = "SessionExpired";
+ private static final String STATUS_LOGIN_FAILED = "LoginFailed";
private final IBinder myBinder = new MyLocalBinder();
private EngineRunner engineRunner;
private ForegroundNotification fgNotification;
@@ -45,7 +53,7 @@ public class VPNService extends android.net.VpnService {
private NetworkChangeDetector networkChangeDetector;
private ConcreteNetworkAvailabilityListener networkAvailabilityListener;
- private EngineRestarter engineRestarter;
+ private NetworkSwitchNotifier networkSwitchNotifier;
private android.content.BroadcastReceiver stopEngineReceiver;
@Override
@@ -84,20 +92,30 @@ public void onCreate() {
sessionMonitor.addListener(sessionEventListener);
engineRunner.addOnConnectedObserver(() -> sessionNotification.cancel());
+ // Drive the status-bar icon from the tunnel's own phase rather than
+ // the engine start/stop edges, so "connecting" is visible while the
+ // core is still bringing the tunnel up.
+ engineRunner.addConnectionObserver(connectionObserver);
+
engineRunner.addServiceStateListener(serviceStateListener);
// Create network availability listener after the engine runner so we
// can gate notifications on the engine actually being up; this avoids
// acting on Android's initial onAvailable burst during cold start.
- networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(engineRunner::isRunning);
+ networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(
+ engineRunner::isRunning, engineRunner::setNetworkAvailable);
- engineRestarter = new EngineRestarter(engineRunner);
- networkAvailabilityListener.subscribe(engineRestarter);
+ networkSwitchNotifier = new NetworkSwitchNotifier(engineRunner);
+ networkAvailabilityListener.subscribe(networkSwitchNotifier);
networkChangeDetector = new NetworkChangeDetector(
(ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE));
networkChangeDetector.subscribe(networkAvailabilityListener);
networkChangeDetector.registerNetworkCallback();
+ // Push the initial connectivity state into the Go client: transition
+ // events alone would leave it stuck at the online default when the
+ // service starts while the device has no network (e.g. airplane mode).
+ engineRunner.setNetworkAvailable(networkChangeDetector.hasInternetConnectivity());
// Register broadcast receiver for stopping engine (e.g., during profile switch)
stopEngineReceiver = new android.content.BroadcastReceiver() {
@@ -105,7 +123,6 @@ public void onCreate() {
public void onReceive(Context context, Intent intent) {
if (ACTION_STOP_ENGINE.equals(intent.getAction())) {
Log.d(LOGTAG, "Received stop engine broadcast");
- engineRestarter.cancelPendingRestart();
if (engineRunner != null) {
engineRunner.stop();
}
@@ -129,11 +146,21 @@ public int onStartCommand(@Nullable final Intent intent, final int flags, final
}
if (INTENT_ALWAYS_ON_START.equals(intent.getAction())) {
+ // CONNECTING is only a safe assumption when the run below actually
+ // starts the engine; on a re-delivery over a running engine no
+ // event would follow to correct it, so read the state instead.
+ fgNotification.setState(engineRunner.isRunning()
+ ? currentState()
+ : ForegroundNotification.State.CONNECTING);
fgNotification.startForeground();
- engineRestarter.cancelPendingRestart();
engineRunner.runWithoutAuth();
}
if (INTENT_ACTION_START.equals(intent.getAction())) {
+ // MainActivity.onStart fires this on every return to the
+ // foreground, not just when connecting, so take the state from the
+ // engine: the Go core only re-emits onConnected on an actual
+ // change, and assuming CONNECTING here would stick until then.
+ fgNotification.setState(currentState());
fgNotification.startForeground();
}
return super.onStartCommand(intent, flags, startId);
@@ -171,7 +198,6 @@ public void onDestroy() {
networkAvailabilityListener.unsubscribe();
networkChangeDetector.unsubscribe();
networkChangeDetector.unregisterNetworkCallback();
- engineRestarter.cleanup();
engineRunner.stop();
stopForeground(true);
@@ -189,7 +215,6 @@ public void onDestroy() {
@Override
public void onRevoke() {
Log.d(LOGTAG, "VPN permission on revoke");
- engineRestarter.cancelPendingRestart();
if (engineRunner != null) {
engineRunner.stop();
stopForeground(true);
@@ -215,14 +240,13 @@ public Intent prepareVpnIntent(Activity context) {
}
public void runEngine(URLOpener urlOpener, boolean isAndroidTV) {
+ fgNotification.setState(ForegroundNotification.State.CONNECTING);
fgNotification.startForeground();
sessionNotification.cancel();
- engineRestarter.cancelPendingRestart();
engineRunner.run(urlOpener, isAndroidTV);
}
public void stopEngine() {
- engineRestarter.cancelPendingRestart();
engineRunner.stop();
}
@@ -349,6 +373,74 @@ public void onSessionDeadlineChanged(long expiresAtUnixSeconds) {
}
};
+ /**
+ * The icon state implied by the engine's current status label, for the
+ * moments we have to paint the notification without an event to react to
+ * (re-entering the foreground). Mirrors the desktop tray's iconForState()
+ * priority: login trouble first, then the connection phase.
+ */
+ private ForegroundNotification.State currentState() {
+ String status = engineRunner.status();
+ if (STATUS_NEEDS_LOGIN.equals(status)
+ || STATUS_SESSION_EXPIRED.equals(status)
+ || STATUS_LOGIN_FAILED.equals(status)) {
+ return ForegroundNotification.State.NEEDS_LOGIN;
+ }
+ if (STATUS_CONNECTED.equals(status)) {
+ return ForegroundNotification.State.CONNECTED;
+ }
+ if (STATUS_CONNECTING.equals(status)) {
+ return ForegroundNotification.State.CONNECTING;
+ }
+ // Idle — the run loop is not running (never started, or stopped) —
+ // and anything the Go side may add later.
+ return ForegroundNotification.State.DISCONNECTED;
+ }
+
+ /**
+ * Mirrors the tunnel's connection phase onto the status-bar icon. The
+ * engine start/stop edges below are too coarse for this: the engine is
+ * "started" long before the tunnel is actually up.
+ */
+ private final ConnectionListener connectionObserver = new ConnectionListener() {
+ @Override
+ public void onStateChanged(long state) {
+ // Same split as MainActivity's listener: the legacy per-state
+ // callbacks below drive the ordinary states, and only NoNetwork —
+ // which arrives exclusively here — is handled from the state code.
+ if (state == Android.ClientStateNoNetwork) {
+ fgNotification.setState(ForegroundNotification.State.NO_NETWORK);
+ }
+ }
+
+ @Override
+ public void onConnecting() {
+ fgNotification.setState(ForegroundNotification.State.CONNECTING);
+ }
+
+ @Override
+ public void onConnected() {
+ fgNotification.setState(ForegroundNotification.State.CONNECTED);
+ }
+
+ @Override
+ public void onDisconnecting() {
+ }
+
+ @Override
+ public void onDisconnected() {
+ fgNotification.setState(ForegroundNotification.State.DISCONNECTED);
+ }
+
+ @Override
+ public void onAddressChanged(String fqdn, String ip) {
+ }
+
+ @Override
+ public void onPeersListChanged(long count) {
+ }
+ };
+
public ServiceStateListener serviceStateListener = new ServiceStateListener() {
@Override
public void onStarted() {
@@ -357,17 +449,35 @@ public void onStarted() {
@Override
public void onStopped() {
+ // Set before tearing the notification down: stopForeground can
+ // leave the notification on screen briefly (and does leave it when
+ // the service keeps running for a rebind), so it must not linger
+ // showing the connected icon.
+ //
+ // An expired session stops the engine right after onError, so keep
+ // the login prompt instead of overwriting it with a plain
+ // "Disconnected" — the Go side latches NeedsLogin until an actual
+ // login or extend clears it, so this stays true across the stop.
+ fgNotification.setState(sessionMonitor.isLoginRequired()
+ ? ForegroundNotification.State.NEEDS_LOGIN
+ : ForegroundNotification.State.DISCONNECTED);
fgNotification.stopForeground();
sessionMonitor.onStateChanged();
}
@Override
public void onError(String msg) {
- fgNotification.stopForeground();
// An expired session surfaces here first (the run loop gives up
// with PermissionDenied), so sample the status right away instead
// of waiting for the monitor's next tick.
sessionMonitor.onStateChanged();
+ // Same split the desktop tray makes: the NeedsLogin status label
+ // means the user has to sign in, which is worth saying outright.
+ // Anything else is a generic engine failure.
+ fgNotification.setState(sessionMonitor.isLoginRequired()
+ ? ForegroundNotification.State.NEEDS_LOGIN
+ : ForegroundNotification.State.ERROR);
+ fgNotification.stopForeground();
}
};
diff --git a/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java b/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java
index b09ff638..47932ed9 100644
--- a/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java
+++ b/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java
@@ -3,25 +3,37 @@
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BooleanSupplier;
+import java.util.function.Consumer;
public class ConcreteNetworkAvailabilityListener implements NetworkAvailabilityListener {
private static final int UNKNOWN_NETWORK_TYPE = -1;
private final Map availableNetworkTypes;
private final BooleanSupplier shouldNotify;
+ private final Consumer internetAvailabilityConsumer;
private NetworkToggleListener listener;
private volatile int lastDefaultType = UNKNOWN_NETWORK_TYPE;
public ConcreteNetworkAvailabilityListener() {
- this(() -> true);
+ this(() -> true, available -> {});
+ }
+
+ public ConcreteNetworkAvailabilityListener(BooleanSupplier shouldNotify) {
+ this(shouldNotify, available -> {});
}
// shouldNotify is consulted before each listener notification. Pass
// engineRunner::isRunning to swallow the initial onAvailable burst that
// fires right after registerNetworkCallback; until the engine is actually
// running there is nothing to restart.
- public ConcreteNetworkAvailabilityListener(BooleanSupplier shouldNotify) {
+ //
+ // internetAvailabilityConsumer receives transitions between "some
+ // internet-capable network exists" and "none at all" (e.g. airplane mode).
+ // It is invoked unconditionally so the Go client's network gate stays
+ // correct regardless of engine state.
+ public ConcreteNetworkAvailabilityListener(BooleanSupplier shouldNotify, Consumer internetAvailabilityConsumer) {
this.availableNetworkTypes = new ConcurrentHashMap<>();
this.shouldNotify = shouldNotify;
+ this.internetAvailabilityConsumer = internetAvailabilityConsumer;
}
@Override
@@ -34,6 +46,11 @@ public void onNetworkLost(@Constants.NetworkType int networkType) {
availableNetworkTypes.remove(networkType);
}
+ @Override
+ public void onInternetAvailabilityChanged(boolean available) {
+ internetAvailabilityConsumer.accept(available);
+ }
+
@Override
public void onDefaultNetworkTypeChanged(@Constants.NetworkType int networkType) {
if (networkType == lastDefaultType) {
diff --git a/tool/src/main/java/io/netbird/client/tool/networks/NetworkAvailabilityListener.java b/tool/src/main/java/io/netbird/client/tool/networks/NetworkAvailabilityListener.java
index 64ed57a3..cfd6348b 100644
--- a/tool/src/main/java/io/netbird/client/tool/networks/NetworkAvailabilityListener.java
+++ b/tool/src/main/java/io/netbird/client/tool/networks/NetworkAvailabilityListener.java
@@ -4,4 +4,8 @@ public interface NetworkAvailabilityListener {
void onNetworkAvailable(@Constants.NetworkType int networkType);
void onNetworkLost(@Constants.NetworkType int networkType);
void onDefaultNetworkTypeChanged(@Constants.NetworkType int networkType);
+
+ // Fired when the device transitions between "has at least one
+ // internet-capable network" and "has none at all" (e.g. airplane mode).
+ default void onInternetAvailabilityChanged(boolean available) {}
}
diff --git a/tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java b/tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java
index 7590aa72..adb875ff 100644
--- a/tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java
+++ b/tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java
@@ -7,10 +7,16 @@
import android.util.Log;
import androidx.annotation.NonNull;
-import androidx.core.util.Consumer;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
public class NetworkChangeDetector {
private static final String LOGTAG = NetworkChangeDetector.class.getSimpleName();
+ // Transport we do not classify (e.g. ethernet, bluetooth tethering); such
+ // networks still count as internet connectivity.
+ private static final int TYPE_UNCLASSIFIED = -1;
+
private final ConnectivityManager connectivityManager;
private ConnectivityManager.NetworkCallback networkCallback;
private ConnectivityManager.NetworkCallback defaultNetworkCallback;
@@ -18,39 +24,57 @@ public class NetworkChangeDetector {
private boolean defaultNetworkCallbackActive = false;
private final Object networkCallbackLock = new Object();
+ // Networks currently matching the registered request (internet-capable,
+ // non-VPN), keyed by the Network object so onLost can be resolved even
+ // though the lost network's capabilities are no longer queryable.
+ private final Map availableNetworks = new ConcurrentHashMap<>();
+ private final Object internetStateLock = new Object();
+ private boolean internetAvailable = true;
+
public NetworkChangeDetector(ConnectivityManager connectivityManager) {
this.connectivityManager = connectivityManager;
initNetworkCallback();
initDefaultNetworkCallback();
}
- private void checkNetworkCapabilities(Network network, Consumer operation) {
+ private int classifyTransport(Network network) {
var capabilities = connectivityManager.getNetworkCapabilities(network);
- if (capabilities == null) return;
+ if (capabilities == null) return TYPE_UNCLASSIFIED;
Log.d(LOGTAG, String.format("Network %s has capabilities: %s", network, capabilities));
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
- operation.accept(Constants.NetworkType.WIFI);
- } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
- operation.accept(Constants.NetworkType.MOBILE);
+ return Constants.NetworkType.WIFI;
}
+ if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
+ return Constants.NetworkType.MOBILE;
+ }
+ return TYPE_UNCLASSIFIED;
}
private void initNetworkCallback() {
networkCallback = new ConnectivityManager.NetworkCallback() {
@Override
public void onAvailable(@NonNull Network network) {
+ int type = classifyTransport(network);
+ availableNetworks.put(network, type);
+
NetworkAvailabilityListener localListener = listener;
- if (localListener == null) return;
- checkNetworkCapabilities(network, localListener::onNetworkAvailable);
+ if (localListener != null && type != TYPE_UNCLASSIFIED) {
+ localListener.onNetworkAvailable(type);
+ }
+ updateInternetAvailability();
}
@Override
public void onLost(@NonNull Network network) {
+ Integer type = availableNetworks.remove(network);
+
NetworkAvailabilityListener localListener = listener;
- if (localListener == null) return;
- checkNetworkCapabilities(network, localListener::onNetworkLost);
+ if (localListener != null && type != null && type != TYPE_UNCLASSIFIED) {
+ localListener.onNetworkLost(type);
+ }
+ updateInternetAvailability();
}
@Override
@@ -62,6 +86,29 @@ public void onCapabilitiesChanged(@NonNull Network network, @NonNull NetworkCapa
};
}
+ // updateInternetAvailability notifies the listener when the device
+ // transitions between having some internet-capable network and none.
+ private void updateInternetAvailability() {
+ boolean available = !availableNetworks.isEmpty();
+ synchronized (internetStateLock) {
+ if (available == internetAvailable) {
+ return;
+ }
+ internetAvailable = available;
+ }
+ Log.i(LOGTAG, "internet availability changed: " + available);
+ NetworkAvailabilityListener localListener = listener;
+ if (localListener != null) {
+ localListener.onInternetAvailabilityChanged(available);
+ }
+ }
+
+ public boolean hasInternetConnectivity() {
+ synchronized (internetStateLock) {
+ return internetAvailable;
+ }
+ }
+
private void initDefaultNetworkCallback() {
defaultNetworkCallback = new ConnectivityManager.NetworkCallback() {
@Override
@@ -102,6 +149,13 @@ public void onAvailable(@NonNull Network network) {
}
public void registerNetworkCallback() {
+ // Seed the availability state before callbacks arrive: when the device
+ // starts with no connectivity at all (e.g. airplane mode), no
+ // onAvailable ever fires, so the initial value must already be correct.
+ synchronized (internetStateLock) {
+ internetAvailable = connectivityManager.getActiveNetwork() != null;
+ }
+
NetworkRequest.Builder builder = new NetworkRequest.Builder();
builder.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
connectivityManager.registerNetworkCallback(builder.build(), networkCallback);
@@ -125,6 +179,7 @@ public void unregisterNetworkCallback() {
Log.e(LOGTAG, "failed to unregister default network callback", e);
}
}
+ availableNetworks.clear();
}
public void subscribe(NetworkAvailabilityListener listener) {
diff --git a/tool/src/main/res/drawable-hdpi/notification_icon_connected.png b/tool/src/main/res/drawable-hdpi/notification_icon_connected.png
new file mode 100644
index 00000000..0ca14535
Binary files /dev/null and b/tool/src/main/res/drawable-hdpi/notification_icon_connected.png differ
diff --git a/tool/src/main/res/drawable-hdpi/notification_icon_connecting.png b/tool/src/main/res/drawable-hdpi/notification_icon_connecting.png
new file mode 100644
index 00000000..a440d98d
Binary files /dev/null and b/tool/src/main/res/drawable-hdpi/notification_icon_connecting.png differ
diff --git a/tool/src/main/res/drawable-hdpi/notification_icon_disconnected.png b/tool/src/main/res/drawable-hdpi/notification_icon_disconnected.png
new file mode 100644
index 00000000..b1cce74e
Binary files /dev/null and b/tool/src/main/res/drawable-hdpi/notification_icon_disconnected.png differ
diff --git a/tool/src/main/res/drawable-hdpi/notification_icon_error.png b/tool/src/main/res/drawable-hdpi/notification_icon_error.png
new file mode 100644
index 00000000..3bd7fd80
Binary files /dev/null and b/tool/src/main/res/drawable-hdpi/notification_icon_error.png differ
diff --git a/tool/src/main/res/drawable-mdpi/notification_icon_connected.png b/tool/src/main/res/drawable-mdpi/notification_icon_connected.png
new file mode 100644
index 00000000..7eda2423
Binary files /dev/null and b/tool/src/main/res/drawable-mdpi/notification_icon_connected.png differ
diff --git a/tool/src/main/res/drawable-mdpi/notification_icon_connecting.png b/tool/src/main/res/drawable-mdpi/notification_icon_connecting.png
new file mode 100644
index 00000000..934796bf
Binary files /dev/null and b/tool/src/main/res/drawable-mdpi/notification_icon_connecting.png differ
diff --git a/tool/src/main/res/drawable-mdpi/notification_icon_disconnected.png b/tool/src/main/res/drawable-mdpi/notification_icon_disconnected.png
new file mode 100644
index 00000000..a6c13149
Binary files /dev/null and b/tool/src/main/res/drawable-mdpi/notification_icon_disconnected.png differ
diff --git a/tool/src/main/res/drawable-mdpi/notification_icon_error.png b/tool/src/main/res/drawable-mdpi/notification_icon_error.png
new file mode 100644
index 00000000..c1f9c6b2
Binary files /dev/null and b/tool/src/main/res/drawable-mdpi/notification_icon_error.png differ
diff --git a/tool/src/main/res/drawable-xhdpi/notification_icon_connected.png b/tool/src/main/res/drawable-xhdpi/notification_icon_connected.png
new file mode 100644
index 00000000..bc09b893
Binary files /dev/null and b/tool/src/main/res/drawable-xhdpi/notification_icon_connected.png differ
diff --git a/tool/src/main/res/drawable-xhdpi/notification_icon_connecting.png b/tool/src/main/res/drawable-xhdpi/notification_icon_connecting.png
new file mode 100644
index 00000000..52da99fb
Binary files /dev/null and b/tool/src/main/res/drawable-xhdpi/notification_icon_connecting.png differ
diff --git a/tool/src/main/res/drawable-xhdpi/notification_icon_disconnected.png b/tool/src/main/res/drawable-xhdpi/notification_icon_disconnected.png
new file mode 100644
index 00000000..8517b00c
Binary files /dev/null and b/tool/src/main/res/drawable-xhdpi/notification_icon_disconnected.png differ
diff --git a/tool/src/main/res/drawable-xhdpi/notification_icon_error.png b/tool/src/main/res/drawable-xhdpi/notification_icon_error.png
new file mode 100644
index 00000000..71e4f6d4
Binary files /dev/null and b/tool/src/main/res/drawable-xhdpi/notification_icon_error.png differ
diff --git a/tool/src/main/res/drawable-xxhdpi/notification_icon_connected.png b/tool/src/main/res/drawable-xxhdpi/notification_icon_connected.png
new file mode 100644
index 00000000..8c9b2853
Binary files /dev/null and b/tool/src/main/res/drawable-xxhdpi/notification_icon_connected.png differ
diff --git a/tool/src/main/res/drawable-xxhdpi/notification_icon_connecting.png b/tool/src/main/res/drawable-xxhdpi/notification_icon_connecting.png
new file mode 100644
index 00000000..9417e313
Binary files /dev/null and b/tool/src/main/res/drawable-xxhdpi/notification_icon_connecting.png differ
diff --git a/tool/src/main/res/drawable-xxhdpi/notification_icon_disconnected.png b/tool/src/main/res/drawable-xxhdpi/notification_icon_disconnected.png
new file mode 100644
index 00000000..7dd32e08
Binary files /dev/null and b/tool/src/main/res/drawable-xxhdpi/notification_icon_disconnected.png differ
diff --git a/tool/src/main/res/drawable-xxhdpi/notification_icon_error.png b/tool/src/main/res/drawable-xxhdpi/notification_icon_error.png
new file mode 100644
index 00000000..da8f9853
Binary files /dev/null and b/tool/src/main/res/drawable-xxhdpi/notification_icon_error.png differ
diff --git a/tool/src/main/res/drawable-xxxhdpi/notification_icon_connected.png b/tool/src/main/res/drawable-xxxhdpi/notification_icon_connected.png
new file mode 100644
index 00000000..0e310820
Binary files /dev/null and b/tool/src/main/res/drawable-xxxhdpi/notification_icon_connected.png differ
diff --git a/tool/src/main/res/drawable-xxxhdpi/notification_icon_connecting.png b/tool/src/main/res/drawable-xxxhdpi/notification_icon_connecting.png
new file mode 100644
index 00000000..e456507c
Binary files /dev/null and b/tool/src/main/res/drawable-xxxhdpi/notification_icon_connecting.png differ
diff --git a/tool/src/main/res/drawable-xxxhdpi/notification_icon_disconnected.png b/tool/src/main/res/drawable-xxxhdpi/notification_icon_disconnected.png
new file mode 100644
index 00000000..7ef12b84
Binary files /dev/null and b/tool/src/main/res/drawable-xxxhdpi/notification_icon_disconnected.png differ
diff --git a/tool/src/main/res/drawable-xxxhdpi/notification_icon_error.png b/tool/src/main/res/drawable-xxxhdpi/notification_icon_error.png
new file mode 100644
index 00000000..206e4f34
Binary files /dev/null and b/tool/src/main/res/drawable-xxxhdpi/notification_icon_error.png differ
diff --git a/tool/src/main/res/drawable/notification_icon.png b/tool/src/main/res/drawable/notification_icon.png
deleted file mode 100644
index bbb8c366..00000000
Binary files a/tool/src/main/res/drawable/notification_icon.png and /dev/null differ
diff --git a/tool/src/main/res/values-de/strings.xml b/tool/src/main/res/values-de/strings.xml
index 72aac41e..61053eac 100644
--- a/tool/src/main/res/values-de/strings.xml
+++ b/tool/src/main/res/values-de/strings.xml
@@ -1,12 +1,18 @@
NetBird-Dienst
- Dienst läuft
+ Verbunden
+ Wird verbunden…
+ Nicht verbunden
+ Kein Netzwerk verfügbar
+ Anmeldung erforderlich
+ Fehler
Sitzungsablauf
NetBird-Sitzung läuft ab
Deine Anmeldesitzung läuft in etwa %1$d Minuten ab. Öffne die App, um sie zu verlängern.
NetBird-Sitzung abgelaufen
Deine Anmeldesitzung ist abgelaufen. Öffne die App, um sich erneut anzumelden.
Sitzung verlängern
+
Sitzung abgelaufen
Sitzung läuft in weniger als einer Minute ab
diff --git a/tool/src/main/res/values-es/strings.xml b/tool/src/main/res/values-es/strings.xml
index 33150d67..a8bab8c2 100644
--- a/tool/src/main/res/values-es/strings.xml
+++ b/tool/src/main/res/values-es/strings.xml
@@ -1,12 +1,18 @@
Servicio de NetBird
- El servicio está en ejecución
+ Conectado
+ Conectando…
+ Desconectado
+ No hay red disponible
+ Inicio de sesión requerido
+ Error
Expiración de sesión
La sesión de NetBird está por expirar
Tu sesión expira en unos %1$d minutos. Abre la app para extenderla.
La sesión de NetBird expiró
Tu sesión ha expirado. Abre la app para iniciar sesión de nuevo.
Extender sesión
+
Sesión expirada
La sesión expira en menos de un minuto
diff --git a/tool/src/main/res/values-fr/strings.xml b/tool/src/main/res/values-fr/strings.xml
index 0f6c4668..8801d260 100644
--- a/tool/src/main/res/values-fr/strings.xml
+++ b/tool/src/main/res/values-fr/strings.xml
@@ -1,12 +1,18 @@
Service NetBird
- Le service est actif
+ Connecté
+ Connexion…
+ Déconnecté
+ Aucun réseau disponible
+ Connexion requise
+ Erreur
Expiration de session
La session NetBird va expirer
Votre session expire dans environ %1$d minutes. Ouvrez l\'application pour la prolonger.
Session NetBird expirée
Votre session a expiré. Ouvrez l\'application pour vous reconnecter.
Prolonger la session
+
Session expirée
La session expire dans moins d\'une minute
diff --git a/tool/src/main/res/values-hu/strings.xml b/tool/src/main/res/values-hu/strings.xml
index 2757ef85..727cf97c 100644
--- a/tool/src/main/res/values-hu/strings.xml
+++ b/tool/src/main/res/values-hu/strings.xml
@@ -1,6 +1,11 @@
NetBird szolgáltatás
- A szolgáltatás fut
+ Csatlakozva
+ Csatlakozás…
+ Lecsatlakozva
+ Nincs elérhető hálózat
+ Bejelentkezés szükséges
+ Hiba
Munkamenet lejárata
A NetBird munkamenet hamarosan lejár
A munkamenet körülbelül %1$d perc múlva lejár. Nyissa meg az alkalmazást a meghosszabbításhoz.
diff --git a/tool/src/main/res/values-it/strings.xml b/tool/src/main/res/values-it/strings.xml
index 882bfe79..9c9c81c0 100644
--- a/tool/src/main/res/values-it/strings.xml
+++ b/tool/src/main/res/values-it/strings.xml
@@ -1,12 +1,18 @@
Servizio NetBird
- Il servizio è in esecuzione
+ Connesso
+ Connessione…
+ Disconnesso
+ Nessuna rete disponibile
+ Accesso richiesto
+ Errore
Scadenza sessione
La sessione NetBird sta scadendo
La tua sessione scade tra circa %1$d minuti. Apri l\'app per estenderla.
Sessione NetBird scaduta
La tua sessione è scaduta. Apri l\'app per accedere di nuovo.
Estendi sessione
+
Sessione scaduta
La sessione scade in meno di un minuto
diff --git a/tool/src/main/res/values-ja/strings.xml b/tool/src/main/res/values-ja/strings.xml
index 106268ac..6275db05 100644
--- a/tool/src/main/res/values-ja/strings.xml
+++ b/tool/src/main/res/values-ja/strings.xml
@@ -1,6 +1,11 @@
NetBird サービス
- サービスは実行中です
+ 接続済み
+ 接続中…
+ 未接続
+ ネットワークがありません
+ ログインが必要
+ エラー
セッションの有効期限
NetBird セッションの有効期限が近づいています
セッションは約 %1$d 分後に期限切れになります。アプリを開いて延長してください。
diff --git a/tool/src/main/res/values-pt/strings.xml b/tool/src/main/res/values-pt/strings.xml
index d87bbb58..70e2fb9d 100644
--- a/tool/src/main/res/values-pt/strings.xml
+++ b/tool/src/main/res/values-pt/strings.xml
@@ -1,12 +1,18 @@
Serviço NetBird
- O serviço está em execução
+ Conectado
+ Conectando…
+ Desconectado
+ Nenhuma rede disponível
+ Login necessário
+ Erro
Expiração da sessão
A sessão do NetBird está expirando
Sua sessão expira em cerca de %1$d minutos. Abra o app para estendê-la.
Sessão do NetBird expirada
Sua sessão expirou. Abra o app para entrar novamente.
Estender sessão
+
Sessão expirada
A sessão expira em menos de um minuto
diff --git a/tool/src/main/res/values-ru/strings.xml b/tool/src/main/res/values-ru/strings.xml
index 773b15f6..b55cc071 100644
--- a/tool/src/main/res/values-ru/strings.xml
+++ b/tool/src/main/res/values-ru/strings.xml
@@ -1,12 +1,18 @@
Служба NetBird
- Служба запущена
+ Подключено
+ Подключение…
+ Отключено
+ Сеть недоступна
+ Требуется вход
+ Ошибка
Истечение сеанса
Сеанс NetBird скоро истечёт
Ваш сеанс истекает примерно через %1$d минут. Откройте приложение, чтобы продлить его.
Сеанс NetBird истёк
Ваш сеанс истёк. Откройте приложение, чтобы войти снова.
Продлить сеанс
+
Сеанс истёк
Сеанс истекает менее чем через минуту
diff --git a/tool/src/main/res/values-zh-rCN/strings.xml b/tool/src/main/res/values-zh-rCN/strings.xml
index c4374bef..3f52ad40 100644
--- a/tool/src/main/res/values-zh-rCN/strings.xml
+++ b/tool/src/main/res/values-zh-rCN/strings.xml
@@ -1,6 +1,11 @@
NetBird 服务
- 服务正在运行
+ 已连接
+ 正在连接…
+ 已断开连接
+ 无可用网络
+ 需要登录
+ 错误
会话过期
NetBird 会话即将过期
您的会话将在约 %1$d 分钟后过期。打开应用以延长会话。
diff --git a/tool/src/main/res/values/strings.xml b/tool/src/main/res/values/strings.xml
index 8b06f35c..ef7fba4d 100644
--- a/tool/src/main/res/values/strings.xml
+++ b/tool/src/main/res/values/strings.xml
@@ -1,13 +1,18 @@
NetBird
NetBird service
- Service is running
+ Connected
Session expiry
NetBird session expiring
Your login session expires in about %1$d minutes. Open the app to extend it.
NetBird session expired
Your login session has expired. Open the app to sign in again.
Extend session
+ Connecting…
+ Disconnected
+ No network available
+ Login required
+ Error
Session expired
Session expires in less than a minute