Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
a6614db
ux: align Android UI with iOS client patterns
pappz May 10, 2026
5e7198c
Hide toolbar on first-install onboarding screen
pappz May 10, 2026
4504927
Persist sanitized profile name when adding from picker
pappz May 10, 2026
3031950
Guard Documentation row against missing browser
pappz May 10, 2026
628b8ae
Use theme attr for fill color on app-local vector icons
pappz May 10, 2026
cca9cd0
Announce theme picker selection to screen readers
pappz May 10, 2026
9055b6f
Override bottom_nav_inset to 0dp on w960dp+
pappz May 10, 2026
e07f430
Constrain profile picker name to a single line
pappz May 10, 2026
b28e21e
Reuse SettingsSectionHeader style in section template layout
pappz May 10, 2026
ab56499
Document why getActiveProfile uses string-match for empty-state
pappz May 10, 2026
268d7dd
Merge profile-id migration (#189) into iOS-style redesign
pappz Jun 19, 2026
1f48811
Redesign home screen in iOS style with toggle connect button
pappz Jun 19, 2026
4134a18
Migrate to profile ids (#189)
theodorsm Jul 2, 2026
eaa84e5
Merge branch 'main' into ux/ios-style-redesign
pappz Jul 3, 2026
5f57866
Polish home connect toggle and bottom nav styling
pappz Jul 3, 2026
70ad497
Make profile chip background white
pappz Jul 3, 2026
f62c109
Add connect thumb shadow and merge IP info rows into one box
pappz Jul 3, 2026
ded33c3
Remove connect thumb drop shadow
pappz Jul 3, 2026
9cbe806
Fix dark theme for bottom nav and profile chip
pappz Jul 3, 2026
03c6cfa
Shrink IP info rows and middle-ellipsize IPv6
pappz Jul 3, 2026
7c2d881
Style bottom sheets with 1B1B1B dark background and B3B3B3 label
pappz Jul 3, 2026
20b3cc3
Make add and check icons full-opacity orange
pappz Jul 3, 2026
5f7a4f8
Square off bottom nav item touch feedback
pappz Jul 27, 2026
ddcd1f7
Stop the connection status flashing Disconnected on re-entry
pappz Jul 27, 2026
59bc235
Scale the profile picker sheet to many profiles
pappz Jul 27, 2026
cc7246a
Bump the about-screen copyright year to 2026
pappz Jul 27, 2026
b8ee6fe
Advance the netbird submodule to 3358138cc
pappz Jul 27, 2026
7f4fa25
Let profiles be renamed from the manage screen
pappz Jul 27, 2026
26e7231
Keep the last profile card clear of the FAB
pappz Jul 27, 2026
75e3c1f
Add a peer detail screen
pappz Jul 27, 2026
9a49fd4
Fix peer detail instantly closing and stale tab state
pappz Jul 27, 2026
16db8b9
Drop the rounded corners from bottom nav touch feedback
pappz Jul 27, 2026
04c710e
Drop the connection indicator bar from resource rows
pappz Jul 27, 2026
d157a6f
List peer routes as rows instead of a copyable count
pappz Jul 27, 2026
ff93621
Sort peer routes and give the Resources header a row-style icon
pappz Jul 27, 2026
6a1b16c
Use the layers icon for the Resources tab
pappz Jul 27, 2026
9bfe7e0
Localize the app into the 9 desktop languages with an in-app picker
pappz Jul 27, 2026
c5b55aa
Fix stale peer/resource lists after activity recreation
pappz Jul 27, 2026
dde220a
Lay out the bottom nav below the content instead of overlaying it
pappz Jul 27, 2026
8a262ef
Add an exit node selector to the Home screen
pappz Jul 27, 2026
6901184
Latch the connect toggle on user actions to stop state flicker
pappz Jul 27, 2026
719046d
Center the Home connection group between the chip and the exit node row
pappz Jul 27, 2026
4c45fab
Lock phones to portrait
pappz Jul 27, 2026
ee220b5
Keep the exit node card on screen, disabled when there is nothing to …
pappz Jul 27, 2026
6f2acb1
[client] Unify management-server selection across the Android UI
pappz Jul 27, 2026
8fd1eb4
[client] Unwind any sub-screen when a bottom-nav tab is tapped
pappz Jul 27, 2026
123a445
[client] Use primary text colour for peer and resource names
pappz Jul 27, 2026
f61a6dd
[client] Share the home connect toggle's switch styling across the app
pappz Jul 27, 2026
3918757
[client] Keep the connect toggle in sync with the engine state
pappz Jul 27, 2026
23dc007
Use integration branch for submodule
pappz Jul 28, 2026
f95f43c
Merge branch 'main' into ux/ios-style-redesign
pappz Jul 28, 2026
81c0262
[client] Label the unreachable-server confirm step explicitly
pappz Jul 28, 2026
3b66975
[client] Surface the SSO session expiry in the Android UI
pappz Jul 28, 2026
2a95aa9
[client] Drop the session-expiring dialog
pappz Jul 28, 2026
4c5431c
[client] Match the desktop's rounding in the session expiry row
pappz Jul 28, 2026
79f17ce
[client] Translate the exit node and session strings
pappz Jul 28, 2026
670ec9e
[client] Localize the tool module's notification strings
pappz Jul 28, 2026
c2e0ff4
[client] Drop the onboarding title and sharpen its sub-text
pappz Jul 29, 2026
260dd1a
[client] Clip long IPv6 addresses in the peers list
pappz Jul 29, 2026
aa0a82a
Pull fresh TUN settings on rebuild instead of consuming the payload
pappz Jul 30, 2026
942f909
Bump netbird submodule: drop the initial GetNetworkMap fetch
pappz Jul 30, 2026
881e5d0
[client] Fade the debug bundle button's fill when it is disabled
pappz Jul 30, 2026
8d13450
[client] Stop the connect toggle restoring a stale checked state
pappz Jul 30, 2026
e7ad39e
[client] Show which account each profile is signed in with
pappz Jul 30, 2026
a31f6b1
Bump netbird submodule: login-hint review fixes
pappz Jul 30, 2026
d44c9c2
[client] Use the shared StateListenerAdapter in the stress test
pappz Aug 3, 2026
bf67e0c
Drop the w960dp dimen overrides left dead by the nav-below-content ch…
pappz Aug 3, 2026
2105a9e
Bump netbird submodule to main
pappz Aug 5, 2026
16abedd
ci: install the JDK from temurin instead of adopt
pappz Aug 5, 2026
35cbf37
Merge origin/main into ux/ios-style-redesign
pappz Aug 6, 2026
3b8f205
Match exit-node routes exactly instead of by substring
pappz Aug 6, 2026
5f0690f
Localize peer status labels and relative timestamps
pappz Aug 6, 2026
7e72975
Merge remote-tracking branch 'origin/main' into ux/ios-style-redesign
pappz Aug 7, 2026
c8102d8
Allow force-cancelling a stuck connect from the main toggle
pappz Aug 7, 2026
5c89309
Target Android 16 (API 36)
pappz Aug 7, 2026
6870f56
Keep showing onboarding until a server is chosen
pappz Aug 7, 2026
606fca3
Recognize comma-joined dual-stack default routes as exit nodes
pappz Aug 10, 2026
18a1b6e
E2e tests (#229)
pappz Aug 10, 2026
348e614
Restore the status bar background so its icons stay visible
pappz Aug 10, 2026
67f639f
Paint the navigation bar strip to match the bottom tabs
pappz Aug 10, 2026
abea4ef
Soften the focus highlight on dialog text fields
pappz Aug 11, 2026
d44e6fd
Expand the address rows without re-centering the home screen
pappz Aug 11, 2026
98f4a34
Cap the address rows against the exit node row
pappz Aug 11, 2026
77f4cc5
Show when the connect toggle cannot be tapped
pappz Aug 11, 2026
a924e29
Merge remote-tracking branch 'origin/main' into ux/ios-style-redesign
pappz Aug 12, 2026
93d0497
Update netbird submodule to main HEAD
pappz Aug 12, 2026
15e4609
Keep the home screen content from shifting on connect
pappz Aug 15, 2026
382da9e
Replace README screenshots with the redesigned UI
pappz Aug 15, 2026
cf4d28d
Add manual workflow to build a version-code-consistent APK for ad-hoc…
pappz Aug 16, 2026
4d10386
Pin gobind alongside gomobile instead of running gomobile init (#240)
pappz Aug 17, 2026
5498ddb
Anchor the snapshot version name to the nearest release tag
pappz Aug 17, 2026
5388621
Bump netbird submodule to main branch
pappz Aug 17, 2026
fa99c6c
Document the tag-anchored snapshot version name
pappz Aug 17, 2026
474a4fc
Keep a long hostname on one line on the home screen
pappz Aug 18, 2026
b28d024
Float the addresses when they have nowhere to open
pappz Aug 18, 2026
6cc1bee
Show remaining time in the session expiry notification
pappz Aug 18, 2026
3bfff5b
Bump submodule
pappz Aug 18, 2026
6db6293
Android ssh client (#235)
pappz Aug 18, 2026
ec17323
Merge remote-tracking branch 'origin/main' into ux/ios-style-redesign
pappz Aug 18, 2026
1b7f067
Bump netbird version
pappz Aug 19, 2026
16d6d1e
Bump the netbird submodule to the gobind toolchain pin
pappz Aug 22, 2026
aefe9af
Implement onStateChanged on the connection listeners
pappz Aug 22, 2026
212bca3
Fix stale route state and listener races around the resources UI
pappz Aug 22, 2026
d63422f
Refresh the exit node row on the transitional engine states
pappz Aug 22, 2026
01b8148
Update submodule to common mobile package
pappz Aug 24, 2026
5a83554
Bump netbird version
pappz Aug 25, 2026
10a35f9
Handle network changes without restarting the engine (#237)
pappz Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/build-debug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ jobs:
disk-size: 4096M
heap-size: 512M
disable-animations: true
script: ./gradlew connectedDebugAndroidTest --no-daemon -Pandroid.testInstrumentationRunnerArguments.notClass=io.netbird.client.NetworkConnectivityStressTest
# The e2e package needs a setup key for a live account, which only the
# private mobile-e2e repo holds, so it is excluded here along with the
# long-running stress test.
script: ./gradlew connectedDebugAndroidTest --no-daemon -Pandroid.testInstrumentationRunnerArguments.notClass=io.netbird.client.NetworkConnectivityStressTest -Pandroid.testInstrumentationRunnerArguments.notPackage=io.netbird.client.e2e

- name: Upload test results
if: always()
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/build-snapshot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,27 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
# git describe below needs the tags and the history down to the
# nearest release tag; the default shallow clone carries neither.
fetch-depth: 0
submodules: recursive

# Reaches the management server as the peer's ui_version and is shown on
# the app's about screen, so it has to say at a glance that this build was
# signed and handed out rather than released. build-debug.yml already
# claims the ci- prefix, and sharing it would make an unsigned PR build
# and a signed hand-out indistinguishable in the peer list.
# The tag is the nearest release tag in the commit's ancestry, so once a
# new release is tagged, later snapshots pick it up on their own. If
# describe finds no tag the step fails instead of minting a bare name.
- name: Get version name
id: version
run: |
BASE_TAG=$(git describe --tags --abbrev=0)
SHORT_GIT_SHA=$(git rev-parse --short HEAD)
echo "version_name=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
Expand Down
6 changes: 3 additions & 3 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ The NetBird Android client allows connections from mobile devices running Androi
## Screenshots

<p align="center">
<img src="https://github.com/netbirdio/android-client/assets/7756831/31fea824-9604-4e6a-a6ed-78cb526b6066" alt="menu" width="250" style="margin-right: 10px;"/>
<img src="https://github.com/netbirdio/android-client/assets/7756831/97b3bf1b-6e70-4f25-b5ab-e62b3337f10d" alt="peer-overview" width="250" style="margin-right: 10px;"/>
<img src="https://github.com/netbirdio/android-client/assets/7756831/d3ce7c74-aa1e-4be0-ba0c-4761432171e4" alt="mainscreen" width="250"/>
<img src="docs/screenshots/mainscreen.png" alt="mainscreen" width="250" style="margin-right: 10px;"/>
<img src="docs/screenshots/peer-overview.png" alt="peer-overview" width="250" style="margin-right: 10px;"/>
<img src="docs/screenshots/peer-detail.png" alt="peer-detail" width="250"/>
</p>

## Install
Expand Down
1 change: 0 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ dependencies {
androidTestImplementation(libs.uiautomator)
androidTestImplementation(libs.test.rules)
implementation(libs.browser) // Added for CustomTabsIntent
implementation(libs.lottie)
implementation(libs.zxing)

if (hasGoogleServicesJson) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -671,15 +671,4 @@ private static class DisruptionType {
this.restore = restore;
}
}

private static class StateListenerAdapter implements StateListener {
@Override public void onEngineStarted() {}
@Override public void onEngineStopped() {}
@Override public void onAddressChanged(String fqdn, String ip) {}
@Override public void onConnected() {}
@Override public void onConnecting() {}
@Override public void onDisconnected() {}
@Override public void onDisconnecting() {}
@Override public void onPeersListChanged(long count) {}
}
}
133 changes: 133 additions & 0 deletions app/src/androidTest/java/io/netbird/client/e2e/DnsResolutionTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package io.netbird.client.e2e;

import io.netbird.client.MainActivity;

import android.os.Bundle;
import android.util.Log;

import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;

/**
* DNS resolution test — the Android port of the Robot
* {@code client-tests.robot} case "Should resolve the domain and hostname".
*
* <p>With the tunnel up, the {@code dnstest} peer's internal name must resolve
* to its private address {@code 172.20.3.158} through the NetBird DNS. The
* original runs {@code dig <name>}; here we run a real {@code nslookup} on the
* device (via the shell, like the ping tests), so it exercises the device
* resolver / VpnService DNS exactly as a user's traffic would.
*
* <p>Mirrors the two original assertions:
* <ul>
* <li>FQDN resolves: {@code nslookup
* ip-172-20-3-158.eu-central-1.compute.internal} → 172.20.3.158;</li>
* <li>search-domain (unqualified) resolves: {@code nslookup ip-172-20-3-158}
* → 172.20.3.158.</li>
* </ul>
*
* <p>The original additionally checks the main system resolver is a NetBird
* {@code 100.x} address via {@code /etc/resolv.conf} / {@code resolvectl}. That
* is Linux-specific and not portable to Android (no {@code resolv.conf} in the
* usual sense; the VpnService owns DNS), so it is omitted — the resolution
* results above already prove tunnel DNS is in effect.
*
* <p>Only the setup key is injected:
* <pre>
* ./gradlew connectedDebugAndroidTest \
* -Pandroid.testInstrumentationRunnerArguments.setupKey=&lt;UUID&gt;
* </pre>
*/
@RunWith(AndroidJUnit4.class)
public class DnsResolutionTest {

private static final String TAG = "NBDnsTest";

/** dnstest peer's internal hostname and its expected private address. */
private static final String PEER_FQDN = "ip-172-20-3-158.eu-central-1.compute.internal";
private static final String PEER_UNQUALIFIED = "ip-172-20-3-158";
private static final String EXPECTED_IP = "172.20.3.158";

/**
* Window for the UI to report Connected. Generous against the observed
* few seconds; the slow part is the data plane settling afterwards, which
* the 90s probe windows below absorb (Robot allows ~3 min for the whole
* peer-ready sequence).
*/
private static final long CONNECT_TIMEOUT_SEC = 20;
/** Time budget for DNS to start resolving once the engine is connected. */
private static final long RESOLVE_TIMEOUT_SEC = 90;
private VpnTestHarness harness;
private String profileName;

@Before

public void skipIfPreviousFailed() {

FailFast.skipIfAborted();

}


@After
public void tearDown() throws Exception {
if (profileName != null && harness != null) {
harness.disableTouchVisualization();
LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName);
}
}

@Test
public void resolvesPeerNameThroughTunnel() throws Exception {
Bundle args = InstrumentationRegistry.getArguments();
String setupKey = args.getString("setupKey");

assertNotNull("setupKey instrumentation argument is required", setupKey);
assertTrue("setupKey must not be blank", !setupKey.trim().isEmpty());

MainActivity activity = E2eAppRule.activity();
assertNotNull("MainActivity must be available", activity);
harness = new VpnTestHarness(activity);
harness.enableTouchVisualization();

harness.grantVpnConsent();

profileName = LoginFlow.createProfileAndLogin(
activity, harness.device(), "dns", setupKey);

boolean connected = harness.connectAndAwait(CONNECT_TIMEOUT_SEC);
if (!connected) {
LoginFlow.dumpScreenshot(harness.device(), "vpn-connect-timeout");
}
assertTrue("VPN did not reach connected state within " + CONNECT_TIMEOUT_SEC + "s",
connected);

// FQDN resolves to the peer's private address through the tunnel DNS.
boolean fqdnResolved = harness.waitForResolve(PEER_FQDN, EXPECTED_IP, RESOLVE_TIMEOUT_SEC);
if (!fqdnResolved) {
LoginFlow.dumpScreenshot(harness.device(), "dns-fqdn-unresolved");
}
assertTrue(PEER_FQDN + " did not resolve to " + EXPECTED_IP + " within "
+ RESOLVE_TIMEOUT_SEC + "s", fqdnResolved);

// Search-domain: the unqualified name resolves to the same address.
boolean searchResolved =
harness.waitForResolve(PEER_UNQUALIFIED, EXPECTED_IP, RESOLVE_TIMEOUT_SEC);
if (!searchResolved) {
LoginFlow.dumpScreenshot(harness.device(), "dns-search-unresolved");
}
assertTrue(PEER_UNQUALIFIED + " (search domain) did not resolve to " + EXPECTED_IP
+ " within " + RESOLVE_TIMEOUT_SEC + "s", searchResolved);

Log.i(TAG, "DNS resolution verified: " + PEER_FQDN + " and " + PEER_UNQUALIFIED
+ " -> " + EXPECTED_IP);
}
}
75 changes: 75 additions & 0 deletions app/src/androidTest/java/io/netbird/client/e2e/E2eAppRule.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package io.netbird.client.e2e;

import io.netbird.client.MainActivity;

import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;

import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
import androidx.test.runner.lifecycle.Stage;

import java.util.Collection;

/**
* Provides the single shared {@link MainActivity} for the e2e suite WITHOUT a
* JUnit rule: {@link #activity()} returns the currently-resumed MainActivity,
* launching one if none is up. This survives the test framework finishing
* activities between test classes (which made a suite-level ActivityTestRule /
* ActivityScenario hand back a destroyed activity). Because the launch reuses
* the existing task, the app is not torn down and recreated between cases.
*/
final class E2eAppRule {

private static final long LAUNCH_TIMEOUT_MS = 10_000;

private E2eAppRule() {
}

/** The running MainActivity, launched on demand if not already resumed. */
static MainActivity activity() {
MainActivity existing = resumedMainActivity();
if (existing != null) {
return existing;
}

Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
Intent intent = new Intent(instrumentation.getTargetContext(), MainActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
instrumentation.startActivitySync(intent);
instrumentation.waitForIdleSync();

long deadline = System.currentTimeMillis() + LAUNCH_TIMEOUT_MS;
MainActivity activity = resumedMainActivity();
while (activity == null && System.currentTimeMillis() < deadline) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
activity = resumedMainActivity();
}
if (activity == null) {
throw new IllegalStateException("MainActivity did not reach RESUMED within "
+ (LAUNCH_TIMEOUT_MS / 1000) + "s");
}
return activity;
}

private static MainActivity resumedMainActivity() {
MainActivity[] found = new MainActivity[1];
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
Collection<Activity> resumed = ActivityLifecycleMonitorRegistry.getInstance()
.getActivitiesInStage(Stage.RESUMED);
for (Activity a : resumed) {
if (a instanceof MainActivity) {
found[0] = (MainActivity) a;
break;
}
}
});
return found[0];
}
}
47 changes: 47 additions & 0 deletions app/src/androidTest/java/io/netbird/client/e2e/E2eSuite.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package io.netbird.client.e2e;

import io.netbird.client.MainActivity;

import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.UiDevice;

import org.junit.BeforeClass;
import org.junit.runner.RunWith;
import org.junit.runners.Suite;

/**
* Runs the on-device client e2e tests (the Android port of the Robot
* {@code client-tests.robot} suite). Tests obtain the shared {@link MainActivity}
* via {@link E2eAppRule#activity()}, which reuses the running activity (launching
* one only if none is up), so the app is not restarted between cases.
*
* <p>Run with:
* <pre>
* -Pandroid.testInstrumentationRunnerArguments.class=io.netbird.client.e2e.E2eSuite
* </pre>
*/
@RunWith(Suite.class)
@Suite.SuiteClasses({
SetupKeyAuthTest.class,
PeerConnectivityTest.class,
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 {

/**
* Force relay is a global setting that defaults ON and would stop the
* relay-less peer connecting. Turn it off ONCE, before any test runs.
*/
@BeforeClass
public static void disableForceRelay() throws Exception {
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
LoginFlow.setForceRelay(E2eAppRule.activity(), device, false);
}
}
Loading
Loading