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: + *

    + *
  1. WiFi loss -> cellular fallback (B1-style) — egress must return + * through the exit node within {@link #SWITCH_RECOVERY_SEC};
  2. + *
  3. 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}.
  4. + *
+ * + *

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