Commit Graph
40 Commits
Author SHA1 Message Date
Zoltán Papp 3f6f7d0c87 Merge branch 'ux/ios-style-redesign' into file-share
# Conflicts:
#	app/src/main/java/io/netbird/client/MainActivity.java
#	app/src/main/java/io/netbird/client/ServiceAccessor.java
#	app/src/main/java/io/netbird/client/ui/ssh/SshSessionsFragment.java
#	app/src/main/res/navigation/mobile_navigation.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-fr/strings.xml
#	app/src/main/res/values-hu/strings.xml
#	app/src/main/res/values-it/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-pt/strings.xml
#	app/src/main/res/values-ru/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values/dimens.xml
#	app/src/main/res/values/strings.xml
#	netbird
#	tool/src/main/java/io/netbird/client/tool/EngineRunner.java
#	tool/src/main/java/io/netbird/client/tool/VPNService.java
#	tool/src/main/res/values/strings.xml
2026-08-27 17:35:36 +02:00
Zoltan Pappandhchengting 10a35f9215 Handle network changes without restarting the engine (#237)
Network changes no longer restart the engine; they are handled as events.

No network: the reconnect loops suspend instead of retrying against an unreachable network, and the UI and the notification show "No network available". The loops resume with a fresh backoff when connectivity returns.
Network switch: stale management, signal and relay connections are swept and re-dialed immediately on the new network (cross-transport handover fast path: fix: notify Go core on seamless network handover (cellular → WiFi) while VPN is active #243).
WiFi-to-WiFi handovers are detected by network identity, so a same-transport switch triggers the same reconnect path.
Availability is seeded from non-VPN internet networks, so an up tunnel cannot mask a missing underlying network.
New instrumented e2e suites (NetworkTransitionTest, ExitNodeNetworkTransitionTest) cover blackout, transport-switch and handover scenarios with outage budgets.
---------

Co-authored-by: hchengting <74168694+hchengting@users.noreply.github.com>
2026-08-25 20:24:31 +02:00
Zoltán Papp b42569ae83 Receive dropped files straight into Downloads
A received file landed in app-private storage, where only this app could
read it: the user had no way to reach it from a file manager or gallery,
and the Files list was the only way to open one.

Write payloads into a pending MediaStore entry under Download/NetBird as
they arrive. IS_PENDING keeps the entry invisible until the transfer
completes, so an abandoned partial never surfaces, and no sweep has to
tell the user's files apart from ours. Nothing is staged twice.

Below Android 10 there is no pending flag and no Downloads collection,
and the public folder needs a storage permission this app does not ask
for. Those versions stage into the app's external files directory under
a hidden name and rename on delivery, which behaves the same without a
prompt.
2026-08-25 18:45:56 +02:00
Zoltan Papp 212bca37f7 Fix stale route state and listener races around the resources UI
EngineRunner.networks() turned the engine's null — returned when there is no
connect client, no engine or no route manager — into an empty NetworkArray, so
a caller could not tell an unreadable network map from a genuinely empty one.
NetworksFragmentViewModel.postResources() guards on null precisely to avoid
flashing an empty list, and that guard could never fire while the binder was
alive. Keep the null and let the existing checks in HomeFragment,
ExitNodePickerSheet and NetworksFragmentViewModel do their job.

NetworksFragmentViewModel never dropped its snapshot when the tunnel went down:
onDisconnected and onEngineStopped were empty, so the resources list kept
showing the previous session's routes with a stale "connected" counter. Clear it
in both, and let onPeersListChanged repopulate on the next connect.

MainActivity kept its StateListener and RouteChangeListener registrations in
plain ArrayLists. Fragments add and remove themselves on the main thread while
the engine fans state changes out from Go threads — notifier.notify() runs on
the reporting goroutine and OnPeersListChanged is dispatched on its own — so
navigating between tabs during a state change could hit a
ConcurrentModificationException, and a fresh registration had no happens-before
edge to the reader. Use CopyOnWriteArrayList, matching the
ConcurrentHashMap-backed observer set EngineRunner already uses one layer down.

MainActivity.selectRoute and deselectRoute logged and returned when the binder
was absent, leaving the switch flipped over a route the core never received.
Throw instead, the way debugBundle already does, so NetworksAdapter reverts the
toggle and ExitNodePickerSheet reports the failure.
2026-08-22 22:14:59 +02:00
Zoltan Papp aefe9afbbb Implement onStateChanged on the connection listeners
The submodule pointer has carried netbird#7144 since "Bump netbird version",
which added OnStateChanged to the gomobile ConnectionListener interface. None of
the three implementations here override it, so the branch has not compiled
against its own submodule since then; the stale prebuilt AAR hid it until the
regenerated one caught up.

EngineRunner's ObservingConnectionListener and MainActivity's listener forward
and ignore respectively — this branch has no NoNetwork plumbing to feed, so the
state is not consumed anywhere yet.

EngineRestarter's FilteringConnectionListener drops the disconnect states while
a restart is in flight, matching what it already does for onDisconnecting and
onDisconnected. Forwarding them unconditionally would give the same events a
second route past the filter the class exists to apply.

The android-airplane work replaces all three: it deletes EngineRestarter,
rewrites ObservingConnectionListener and adds the real NoNetwork handling.
2026-08-22 22:12:53 +02:00
6db6293547 Android ssh client (#235)
* Add in-app SSH terminal with WebView and persistent sessions

- WebView-hosted xterm.js (5.5.0 + fit-addon) terminal rendered via
  app/src/main/assets/terminal/. The Go gomobile SSHClient streams PTY
  output to Java which evaluateJavascripts base64 chunks into xterm.
- Auto-detection of server type via NetBird SSH banner: NetBird-JWT
  triggers the existing Custom-Tabs URL opener for the OAuth 2.0
  device-code flow, NetBird-no-JWT uses the NetBird private key, and a
  regular OpenSSH server falls back to NetBird key then optional
  password. One unified Connect() in Go covers all three.
- Persistent sessions: SshSessionManager (application-scoped singleton)
  owns SSHClients with a 256 KB scrollback buffer per session, so
  fragments can detach (e.g. on backgrounding) and re-attach later with
  the scrollback replayed before live output resumes.
- New "SSH" drawer entry → SshSessionsFragment lists active sessions
  with state indicators and a FAB to open the connect dialog for a
  free-form host. Peer long-press → SSH continues to work with the IP
  prefilled.
- Connect dialog asks only for host (when not prefilled), username,
  port, and an optional password used by regular SSH fallback.
- ActionBar auto-hides on the terminal destination for maximum screen
  area; BottomSheetDialogFragments auto-dismiss when navigating away
  from home so the terminal is not covered.

Requires the matching netbird submodule on the android-client-ssh
branch which adds the SSHClient gomobile binding.

* Add an SSH button to each peer row

The long-press menu already offered SSH, but nothing hinted at it, and a
row tap opens the peer detail so it could not carry the action either. A
dedicated button makes it reachable in one tap.

Shown whatever the peer's status: the engine dials on demand, so an idle
peer still accepts a connection. The long-press entry stays, and loses
its connected-only condition for the same reason.

* Drop the password field from the SSH connect dialog

The NetBird auth paths never use a password, and a regular server is
tried with the NetBird key first, so asking up front was wrong more
often than not. The terminal now prompts only once the server has
actually refused everything else.

The default port follows where the connection starts from: a prefilled
host is a NetBird peer on 22022, one typed by hand is an ordinary server
on 22. The nav argument and the parse fallback follow suit.

Enter submits, so the fields carry IME actions. setSingleLine has to
precede setInputType, since it resets the type.

* Prompt for the SSH password in the terminal and allow reconnecting

Adds a NEEDS_PASSWORD state, which is a pause rather than a failure: the
session waits for the terminal to collect a password and retries, as
often as the server keeps refusing, matching what a normal ssh client
allows. Cancelling ends the session instead of parking it with no way
forward.

A finished session can be redialled in place from a bar below the
terminal, reusing the session so its scrollback stays readable. The
screen is cleared only on the very first connect, so the connect chatter
does not sit above the prompt while earlier output survives a reconnect.

CONNECTING now prints a notice: a reconnect does not go through the
create path, so it had none.

* Persist the SSH session list per profile

The list only lived in memory, so it was lost on restart. Connection
details now go to SharedPreferences and come back as closed sessions
that reconnect on demand; a live connection cannot outlive the process.
Passwords are never stored, and a restored entry prompts again.

Keyed by profile, because an overlay IP means a different host under a
different profile, so one list must not leak into another. Switching
closes whatever is live, since the tunnel goes down with the old
profile. Lists belonging to deleted profiles are discarded by comparing
against the live profile IDs, as deletion happens elsewhere and reports
nothing.

* Give the SSH session list disconnect, reconnect and dark-theme fixes

Tapping a finished session reconnects when it left no output behind, and
otherwise just opens it, letting the terminal's own bar offer the redial
once there is something to read. A disconnect button ends a live session
while keeping it listed, distinct from closing it, which also discards
the scrollback and so asks for confirmation first.

The row's text was constrained to the close button rather than the one
beside it, so the label overlapped and hid it.

The night theme inherits a Light parent, leaving colorControlNormal and
the default text colour dark, so the icons and labels were invisible.
Both now use the app's own theme-aware colours, which meant replacing
the framework close icon with a tintable one.

* Bump netbird submodule for the SSH password and error changes

* Allow duplicating an SSH session to the same host

Sessions were already keyed by a unique id rather than by host, so
parallel connections to one target worked; what was missing was a way to
ask for one, and a way to tell the results apart. Long-pressing a row
now offers Duplicate, which opens a second session to the same target
and connects it. The password is not carried over: it belongs to the
session that was asked for it, so a server wanting one prompts again.

Sessions sharing a target are numbered, the number leading the label as
tmux does, since the target is long enough to be truncated on a narrow
row and that would drop the part that disambiguates. A target with a
single session stays unnumbered. The label also gains the ellipsize and
maxLines the peer rows already use, so a long FQDN cannot wrap and make
rows uneven.

* Match the SSH session FAB to the profiles page design

The SSH sessions floating action button used the default Material tint
and the framework ic_input_add icon, so it looked out of place next to
the flat orange FAB on the profiles page. Give it the same drawable,
background tint, white icon and zero elevation, switch the fixed 16dp
margin to fab_margin so it insets on landscape and tablet layouts, and
reuse fab_content_inset for the list's bottom padding.

* Update the terminal to xterm.js 6.0

Replaces the bundled xterm.js 5.5.0 and addon-fit 0.10.0 with 6.0.0 and
0.11.0, taken from the npm tarballs rather than a CDN so the files carry
no third-party minification. This matches the version the iOS client
already ships, so the two platforms no longer drift apart.

Every API index.html relies on is unchanged in 6.0, so the only fix the
upgrade needs is for the scrollbar: 6.0 renders its own scrollbar element
instead of using the native one, which the existing ::-webkit-scrollbar
rule no longer reaches.

* Configure the SSH terminal and fix its keyboard handling

Puts the xterm options that were left at their defaults to use: the full
16 colour ANSI palette, since without one the server's colours fall back
to the WebView defaults and are close to unreadable on black, a contrast
floor for the pairings that stay illegible anyway, and allowProposedApi
so the buffer and parser APIs are reachable. Font size goes down rather
than up: every point costs about four columns, and wrapped lines cost
more than small glyphs. Loads the WebGL renderer as well, dropping it on
context loss so a backgrounded app falls back to the DOM renderer
instead of showing a blank terminal.

Grows the key bar to cover what a phone keyboard makes expensive: ^C, ^D
and ^Z as single keys, because arming Ctrl needs the soft keyboard to
then deliver a letter and it does not always do so; the punctuation that
sits behind a symbol page; and copy and paste, which the terminal had no
way to reach at all. Sticky Ctrl and Alt stay for every other
combination.

The keyboard used to cover the terminal outright. The manifest asks for
adjustPan, which slides the window up and carries the key bar off screen,
so the fragment switches to adjustResize while it is visible. From API 35
that mode is ignored and the keyboard simply draws over the window, so
the IME inset is padded instead. Either way the WebView ends up shorter,
which needs .xterm to track its container height, or the row count never
shrinks.

Also gives the password prompt the dialog theme the rest of the app uses.
It was building a bare AlertDialog, so the theme's global text colour
made the title white on white; that theme deliberately leaves the window
transparent and expects the shared rounded layout to supply the body.

* Remember the SSH username instead of defaulting to one

A developer's own login name was baked into four places: the connect
dialog's fallback, the terminal fragment's argument default, a string
resource and the navigation graph. Anyone else got that name silently
substituted whenever the field was left empty, which fails
authentication against a remote account that does not exist.

There is no sensible default to replace it with, since the login name is
the remote account. The dialog now prefills whatever was last connected
with, empty on a fresh install, and stores it again on connect. The key
is not per profile: the name belongs to whoever holds the phone, and the
same account is usually used whichever profile is active.

Connecting with an empty host or username now marks the field and leaves
the dialog open rather than dismissing it, which is what the substituted
default used to paper over.

* Bump netbird submodule to dismiss the SSH auth browser

Picks up the SSH JWT flow calling OnLoginSuccess once it has a token, so
the Custom Tab opened for device-code auth closes itself instead of
staying in front of the terminal.

No app-side change is needed: the SSH URL opener is the same
CustomTabURLOpener the login flow uses, and its onLoginSuccess already
brings the activity forward. MainActivity is singleTask, so that returns
to the existing instance and the terminal fragment is still on the stack.

* Run the SSO Custom Tab calls on the main thread

Bumps the submodule for the SSH JWT flow calling its URL opener in turn
rather than from two racing goroutines, which is what left the browser in
front of the terminal after the token had arrived.

Calling in turn exposed two problems here that the goroutines had been
hiding. launch() and startActivity() drive activity machinery and have to
run on the main thread, so a synchronous call from a Go thread would raise
a wrong-thread error; both are posted now. isOpened is set before that
post rather than inside it, because the caller may report success straight
after and onLoginSuccess does nothing unless the surface is already marked
as opened, and it is volatile since the two threads share it.

onLoginSuccess deliberately leaves isOpened set: MainActivity.onStop reads
it to keep the service bound while the SSO surface is in front, and the
launcher callback clears it when the tab actually goes away.

* Let a stored SSH session be edited

A session saved with the wrong address or login name could only be closed
and recreated from scratch. Long-pressing an entry now offers Edit
alongside Duplicate, prefilled with the session's own details.

The details are final on a session, so the entry is rebuilt rather than
mutated: the old one is closed and replaced under the same id. That keeps
its place in the list, since a LinkedHashMap put on an existing key holds
the original position, and overwrites the stored entry instead of
appending a second one. The scrollback goes with it, having come from a
different host.

Editing leaves the session disconnected on purpose. Redialling here would
connect before the user has seen whether the new details are right, and
the list already offers a reconnect. The host field is always shown in the
editor, including for a peer session where connecting hides it, because
correcting the address is half of what the editor is for.

* Allow rotation while the SSH terminal is open

MainActivity locks portrait on phones, which suits every screen it has.
A terminal is the exception: landscape roughly doubles the column count,
which is what long command lines and full-screen programs need. The
fragment unlocks the orientation while it is on screen and restores the
lock on the way out, so nothing else gains a rotation it was not designed
for.

The session survives the rotation on its own: it belongs to the manager
rather than the fragment, onDestroyView only detaches the listener, and
attaching replays the scrollback into the recreated view.

The arguments needed one fix for this. A fragment opened from the connect
dialog carries host details and no session id, so a recreated view took
the create path and would have dialled a second session to the same target
on every turn of the screen. The id of a session created here is written
back into the arguments, and the password dropped from them now that it
has been handed to the session.

* Prompt to trust an unknown SSH host key

Regular SSH servers previously connected without any host-key check. Show
the presented fingerprint for an untrusted host and, once the user
confirms it, reconnect with the key trusted; the Go side then stores it in
a per-profile known-hosts file and verifies against it thereafter.

The store is per profile, since an overlay IP is a different host under a
different profile, and a profile's file is removed with the profile. A
host's key is also dropped once no session targets it, so deleting the
last session for a host clears its trusted key while a shared host keeps
it.

Bumps the netbird submodule for the host-key verification changes.

* Localize the SSH feature into every supported language

The SSH terminal strings only existed in the base resources, so the whole
feature showed in English under de, es, fr, hu, it, ja, pt, ru and
zh-rCN. Add the 34 strings to each, port numbers and format placeholders
left intact.

* Bump the netbird submodule to the main merge

* Bump netbird submodule for the SSH close and reconnect fixes

* Keep the terminal key bar above the navigation bar

* Give the SSH session list a title bar and room to breathe

* Call deleting a session what it is

* Refuse an SSH redial the stopped engine cannot serve

* Give the settings list a title bar too

* Fix threading and lifecycle faults around SSH sessions

Synchronize the session snapshot, keep view writes on the main thread,
survive an activity recreate, and stop leaking session listeners.

* Reject an out-of-range port in the SSH connect dialog

* Localize the SSH status and state text

* Make the SSH rows reachable and match the peer list

Name the symbol keys for screen readers, grow the peer SSH button to the
48dp touch target, and give the session state bar the peer list's shape.

* Bump netbird submodule to v0.77.0 (#238)

Co-authored-by: netbirddev <dev@netbird.io>

* Update netbird submodule to deduplicated SSH client

* Update netbird submodule to serialized wasm SSH startup

* Post the device-code login opener's UI work to the main thread

The Go flows now invoke URLOpener.open synchronously from a Go thread,
so openers must not do UI work inline. The device-code login opener was
the only one still showing the QR dialog and starting the browser on
the calling thread; post both to the main thread like the extend opener
does, and post onLoginSuccess as well so the dialog field is only
touched from the main thread. Bump netbird for the shared OAuth token
flow.

* Bump netbird for constructor-style login hints

* Bump netbird to the main merge

* Default the peer SSH port to 22

Peers now serve SSH on the standard port, so the dialog no longer
prefills 22022.

* Store SSH sessions and known hosts in the profile's preferences

The session list moves out of Java SharedPreferences and the known-hosts
files out of filesDir, into the per-profile preference store the profile
manager now owns. Deleting a profile deletes both with it, so the sweep
that discarded lists and key files left behind by deleted profiles is
gone, and setProfile no longer needs the set of live profile IDs.

SshSessionStore keeps only the last-used login name, which is
deliberately per device rather than per profile.

Bump netbird to the profile preference store.

* Support ad-hoc snapsot APK from CI (#242)

* Add manual workflow to build a version-code-consistent APK for ad-hoc distribution (#236)

* Add manual workflow to build a version-code-consistent APK for ad-hoc distribution

Adds prepare-publication.yml (workflow_dispatch) that reuses the release
signing key and computes version_code as the combined run count of itself
and build-release.yml, so the two never collide. A shared concurrency
group on both workflows prevents a race on that computation.

* Draw the release version code from the shared run counter

build-release.yml derived its version code from github.run_number, which
counts only its own runs and is blind to prepare-publication.yml. With 25
release runs so far, the first ad-hoc build would take 25+1+40=66 and the
next release would take 26+40=66 as well, then fall behind: 67 after 68 was
already published. The concurrency group cannot fix this, because run_number
is assigned when the run is queued, not when the step executes.

Both workflows now sum the same two counters, so every run of either one
advances the code by exactly one. Reading those counts needs actions: read,
which an explicit permissions block otherwise withholds.

* Name the manual workflow build-snapshot

prepare-publication named a step in a process, while the workflows beside it
name what they produce: build-debug, build-release. What this one produces is
a release-signed build from an arbitrary commit with no tag behind it, which
is what snapshot means.

Not build-rc: release candidates already exist here as published pre-release
tags (v0.6.0-rc.1, v0.3.3-rc.2) and are built by build-release.yml, so the
name would claim a meaning the repository has already given away.

The version code counter is keyed by workflow file name, so the rename is
free only while the workflow has no runs yet.

* Label snapshot builds snapshot- rather than ci-

The version name travels to the management server as the peer's ui_version
and is what the about screen shows, so it is the only thing telling support
which build a peer is running. build-debug.yml already emits ci-<sha> from
the same expression, which left an unsigned PR build and a release-signed
hand-out looking identical in the peer list.

The artifact keeps just the version name; prefixing it again read as
snapshot-artifacts-snapshot-<sha>.

* Resolve the Go version from the submodule's release tags in CI

CI builds used the version only when the submodule sat exactly on a tag and
fell back to ci-<sha> otherwise, which the management server rejects in
NBVersionCheck posture checks: it treats ci- as a development build everywhere
except there. Since the submodule is bumped more often than it is tagged,
release builds effectively always shipped as ci-<sha>.

CI now resolves the version by walking the pinned commit's ancestry back to
the last stable release tag and appending the commit as SemVer build metadata,
e.g. 0.77.0+f06b8c762. The server strips build metadata before every
comparison, so this passes the same gates as a plain 0.77.0 while still naming
the exact commit in the dashboard. Pre-release tags are skipped as a base: a
suffix like -rc.2 lands in SemVer pre-release position, which the server
compares differently from a release.

Local builds now always produce dev-<sha>, which skips every server-side
version gate; a developer who needs a real version passes it as the argument.

The ancestry walk needs full history, but actions/checkout clones submodules
shallow — the tags arrive without the commits between HEAD and the tag, and
the walk would silently come up empty. The composite action therefore
unshallows the submodule before building, guarded because --unshallow on a
complete repository is a hard error.

* Document the three build workflows and their differences

* Fail the build when a run count cannot be fetched

The zero fallback existed for one legitimate case: the runs endpoint returns
404 until a workflow has run or reached the default branch, and treating that
as zero is what lets build-release compute a code before build-snapshot's
first run. But it also swallowed every other failure — a network error or a
revoked token minted a version code far below the published ones, silently
for hand-distributed snapshots.

Keep the 404-means-zero case and abort on everything else, including a
non-numeric response, which bash arithmetic would otherwise fold to zero.

* Pin gobind alongside gomobile instead of running gomobile init (#240)

* Pin gobind alongside gomobile instead of running gomobile init

gomobile bind shells out to gobind, and gobind is the tool that actually
generates the Java bindings and the JNI glue. CI cached and installed only
gomobile and left the build script to call `gomobile init`, which installs
gobind from @latest: the driver was pinned while the generator floated, so
the generated API could change without a commit here.

Take the revision from the go.mod the submodule already carries, so the two
tools cannot drift apart and the cache key follows a submodule bump on its
own, and have the build script check that the pair is present rather than
reaching for gomobile init.

The cache key names both tools now. Entries saved under the old key hold no
gobind, so keeping it would hit the cache, skip the install and leave the
build without a generator.

* Install gomobile and gobind automatically at the go.mod pinned revision

* ci: install the JDK from temurin instead of adopt

setup-java fails to resolve the 'adopt' distribution — AdoptOpenJDK was
renamed to Eclipse Temurin and the old endpoints are going away — so the
build died before installing a JDK. Same JDK, current name.

(cherry picked from commit 8592de3dff)

* Cache the Android NDK and Go modules in the build action

---------

Co-authored-by: DevBot NetBird <91386968+netbirddev@users.noreply.github.com>
Co-authored-by: netbirddev <dev@netbird.io>
2026-08-18 19:44:08 +02:00
Zoltan Papp 6cc1bee912 Show remaining time in the session expiry notification
The foreground notification showed the deadline as a clock time, unlike
the home screen banner and the desktop tray. Reuse the banner's wording:
the largest non-zero unit rounded up ("Session expires in 2 hours"),
with a sub-minute "less than a minute" tail, using the banner's
translations verbatim in every locale. A silent re-post keeps the label
current, ticking faster as the deadline nears, mirroring the desktop
tray's refresh intervals.
2026-08-18 16:27:19 +02:00
Zoltan Papp 594d55081c Show a bar in the Files list, and split stopping from removing
A transfer in flight only said how far along it was in words. It now has
the same bar the share screen's rows have, riding the last line of the
card so a live row is no taller than a finished one.

The outcome column said "Sending" whichever way the bytes were going;
a received file now reads as receiving.

Long press did two things at once: on a running transfer it cancelled and
removed in a single step. It now stops a running transfer and leaves the
row in the list, and only removes one that has already finished. Stopping
something and dropping its record are separate decisions, so the first
press is never destructive and the second one is a plain removal.

The list poll drops to three seconds: Go reports progress as an event
now, so the poll is only there for an update that never arrives.

The header's Cancel button becomes Close. It never cancelled anything —
the sends it leaves behind carry on in the service — it only closes the
screen.
2026-08-18 14:35:04 +02:00
Zoltan Papp 499b2fe442 Poll the transfer list while a transfer is live
Go reports the offer and the outcome as events but never progress, so a
running transfer sat at whatever the last event left behind: the share
screen's row stayed on Waiting for the whole upload, and the Files list
only moved when the tab was reopened.

Chain a one second refresh off publish(), the same interval the desktop
UI polls the same list at. It starts itself when a live transfer appears
and stops once the last one settles or no screen is listening.
2026-08-18 13:11:37 +02:00
Zoltán Papp 0bd6ae0b84 Add file drop to the Android client
Sending starts in the system share sheet, so a file goes out from the
gallery or a file manager without opening the app. The picker is a full
screen with a search field: an account can hold hundreds of peers, and a
dialog list cannot carry that. Tapping a peer is the send, and the row
itself shows what happened to it — waiting, progress, sent, declined —
so the same file can go to several peers from one list.

Shared content is copied into app storage before the transfer starts. A
share grants read access only while the receiving activity lives, while
an upload outlives it by design, so reading the Uri lazily fails with a
permission denial the moment the share screen closes. The copies are
dropped once their transfer reaches an outcome.

Receiving is wired to the VPN service rather than an activity, so an
incoming offer still raises its consent prompt, and the answer still
reaches the engine, with no UI bound.

Files sits in the bottom navigation where Resources used to be, and
Resources moves next to Peers as a segmented control on that screen.
BottomNavigationView rejects a sixth item outright.
2026-08-16 22:05:26 +02:00
Zoltán Papp 5493060354 Refuse an SSH redial the stopped engine cannot serve 2026-08-12 13:40:41 +02:00
Zoltan Papp dd7de892d6 Add in-app SSH terminal with WebView and persistent sessions
- WebView-hosted xterm.js (5.5.0 + fit-addon) terminal rendered via
  app/src/main/assets/terminal/. The Go gomobile SSHClient streams PTY
  output to Java which evaluateJavascripts base64 chunks into xterm.
- Auto-detection of server type via NetBird SSH banner: NetBird-JWT
  triggers the existing Custom-Tabs URL opener for the OAuth 2.0
  device-code flow, NetBird-no-JWT uses the NetBird private key, and a
  regular OpenSSH server falls back to NetBird key then optional
  password. One unified Connect() in Go covers all three.
- Persistent sessions: SshSessionManager (application-scoped singleton)
  owns SSHClients with a 256 KB scrollback buffer per session, so
  fragments can detach (e.g. on backgrounding) and re-attach later with
  the scrollback replayed before live output resumes.
- New "SSH" drawer entry → SshSessionsFragment lists active sessions
  with state indicators and a FAB to open the connect dialog for a
  free-form host. Peer long-press → SSH continues to work with the IP
  prefilled.
- Connect dialog asks only for host (when not prefilled), username,
  port, and an optional password used by regular SSH fallback.
- ActionBar auto-hides on the terminal destination for maximum screen
  area; BottomSheetDialogFragments auto-dismiss when navigating away
  from home so the terminal is not covered.

Requires the matching netbird submodule on the android-client-ssh
branch which adds the SSHClient gomobile binding.
2026-08-12 11:45:03 +02:00
Zoltán Papp a924e290a8 Merge remote-tracking branch 'origin/main' into ux/ios-style-redesign 2026-08-12 11:16:53 +02:00
Viktor Liu f5eb12267c Pass an anonymization level to the debug bundle SDK call (#234) 2026-08-10 19:54:38 +09:00
Zoltán Papp e7ad39ecf1 [client] Show which account each profile is signed in with
The Go binding now records the account a profile logged in with, so surface it
under the profile name in both the profiles list and the home screen picker.
Profiles that have never completed an SSO login, or were logged out, have no
email and keep their original single-line row.

Profile gains the field to carry it across the binding, which changes its
constructor arity — ProfileEditorDialog passes the email through so renaming a
profile does not drop the account it belongs to.
2026-07-30 20:34:49 +02:00
Zoltán Papp aa0a82a595 Pull fresh TUN settings on rebuild instead of consuming the payload
recreateTUN applied the notification payload as the new route list and
reused the search domains cached at the last establish. A search domain
change notified with an empty payload, so the TUN was rebuilt with zero
routes, and updated search domains never reached the TUN at runtime.

The notification is now a pure trigger: recreateTUN pulls a fresh
routes plus search domains snapshot from the engine via the new
getTunSettings binding, deduplicates against the last applied
parameters and rebuilds with the pulled values. The TUN creator thread
takes a Runnable since the trigger carries no state.

Requires the netbird submodule change that exposes GetTunSettings.
2026-07-30 13:59:03 +02:00
Zoltán Papp 670ec9e9b9 [client] Localize the tool module's notification strings
The service module had no localized resources, so the foreground and
session notifications stayed English in every locale — including the
expiry warnings, which are the only thing the user sees when no UI is
open.

Covers the nine desktop languages. service_name is left alone as a brand
name.
2026-07-28 18:05:06 +02:00
Zoltán Papp 2a95aa9c9e [client] Drop the session-expiring dialog
The warning already reaches the user twice: a heads-up notification from
the service, which is the only channel that works with no UI bound, and
the home screen's session row, which states the deadline continuously
with the same extend action. A modal was a third copy of that, and an
interruption an event known ten minutes ahead does not warrant.

Removes the dialog with the now-unused dismiss plumbing and strings.

Bumps the netbird submodule for the binding's concurrency fixes.
2026-07-28 17:44:01 +02:00
Zoltán Papp 3b66975aa8 [client] Surface the SSO session expiry in the Android UI
An expired session used to show up as a raw Go error string in a toast,
with the home screen still reading "Disconnected" — nothing said that
reconnecting needs an interactive login, and nothing warned before the
session ran out.

Consume the binding's new session API from the service, which is where
the desktop keeps this logic too, so it also works with no UI bound
(always-on VPN, boot start):

- SessionMonitor forwards the engine's expiry warnings and edge-detects
  the NeedsLogin status label, replaying both to late-binding listeners.
- The persistent notification gains a live countdown to the deadline and
  an "Extend session" action; a high-importance channel carries the
  expiring and expired warnings.
- The home screen shows a session row below the exit node row, and the
  connect status reads "Login required" instead of "Disconnected"
  once the server has rejected the peer. Tapping either the row or the
  toggle runs the flow that fixes it.

Bumps the netbird submodule for the binding.
2026-07-28 16:52:37 +02:00
Zoltán Papp 6f2acb1844 [client] Unify management-server selection across the Android UI
Port the desktop app's profile dialog to Android and make it the single
place where a profile's management server is chosen. The desktop offers
one Cloud/Self-hosted control in its profile modal, settings page and
welcome window; Android had three unrelated surfaces instead.

Add a profile editor dialog that names the profile and picks the server
in one step, reused for editing so the pencil on a profile card opens
the same window seeded with the profile's current values. Rework the
first-run screen along the same lines, replacing a sentence whose
clickable word was the literal "change_server" resource id.

Delete the Change Server screen: with onboarding, the profile cards and
the settings row all routing to the editor, it had no remaining entry
point. Its setup-key field lives on in both surfaces, since mobile
devices are commonly provisioned that way even though the desktop UI
has no equivalent.

The segmented control, setup-key section and URL handling become shared
classes rather than being copied between the dialog and onboarding,
mirroring how the desktop shares ManagementServerSwitch and
useManagementUrl.

Also:
- Give dialogs a MaterialComponents parent theme so Material widgets
  resolve the NetBird palette instead of the framework's purple accent.
- Convert the wordmark to a vector drawable generated from the
  desktop's netbird-full.svg. The old PNG sat the bird 7px above the
  lettering's centre and padded the canvas unevenly.
- Reuse the desktop's existing translations for every new string.
2026-07-28 00:27:58 +02:00
Zoltán Papp 7f4fa25d29 Let profiles be renamed from the manage screen
Adds an edit affordance next to each profile name, opening a dialog
prefilled with the current name. Renaming only rewrites the "name" field
in the profile config; the ID and its on-disk filename stay put, so the
default profile can be renamed too.

The picker sheet is unaffected: it reads the name fresh and its
most-recently-used ordering is keyed on ID.

Because the default profile is now renameable, the adapter's "can this be
removed" check moves from name to ID, which no longer identifies it.

Renaming and adding also enforce different limits, matching the Go core:
a new profile's name doubles as its filename (64 chars), while a rename
sets a display name only (128 chars).

Requires the submodule bump, which exposes RenameProfile on the Android
gomobile binding.
2026-07-27 17:11:40 +02:00
Theodor Midtlien 4134a18c75 Migrate to profile ids (#189)
* Add profile id migration

* Check if ID is set on Profile

* Bump netbird

* Update profile-id-name branch

* Fix active profile errors, bump netbird

* Bump netbird to v0.74.0
2026-07-02 11:20:51 +02:00
Theodor Midtlien dc40e04afa Migrate to profile ids (#189)
* Add profile id migration

* Check if ID is set on Profile

* Bump netbird

* Update profile-id-name branch

* Fix active profile errors, bump netbird

* Bump netbird to v0.74.0
2026-07-02 11:20:51 +02:00
Viktor Liu 9b0f0fa6d1 Add IPv6 dual-stack support (#148) 2026-05-12 19:37:15 +09:00
Zoltan Papp af289ebf43 Fix UI stuck on “Disconnected” during network-change engine restart (#167)
* Fix UI stuck on Disconnected during network-change engine restart

When EngineRestarter stopped and restarted the Go engine after a
network type change, the UI only saw the engine's onDisconnected
callback and had no visibility into the reconnect attempt. If the
restart stalled (e.g. on a stale management RPC), the UI stayed on
Disconnected for the full stall window, making it look like the
client never reconnected.

Emit onConnecting() from EngineRestarter at stop and at re-launch to
keep the UI in the Connecting state throughout the restart, and emit
onDisconnected() on error or the 30s safety timeout so a truly failed
restart doesn't leave the UI stuck on Connecting.

* Bind process to default network and ignore initial callback burst

Pin the process's outgoing sockets to the current default Android
Network via ConnectivityManager.bindProcessToNetwork so fresh dials
after a WiFi/cellular switch do not stall on TCP SYN retransmits
through the departing interface.

Skip the initial onAvailable burst fired right after registering the
NetworkCallback. That burst reflects current state, not a transition,
and was triggering a spurious EngineRestarter restart that cancelled
the in-flight login on cold start.

* Bump netbird submodule to test branch

* Gate network change notifications on engine running

Replace the time-based grace window with an isEngineRunning predicate.
The initial onAvailable burst that Android fires right after
registerNetworkCallback cannot trigger an EngineRestarter run because
the engine is not up yet at that point.

Tests updated accordingly; adds coverage for the engine-not-running
path.

* Update submodule

* Silence foreground service notification

Use IMPORTANCE_LOW and explicitly clear sound/vibration on the channel
so the persistent VPN notification does not play a sound or vibrate on
creation or each connection state update.

* Guard default network callback against stale events

Track the currently bound default network and an active flag so late
onLost callbacks cannot clear a newer binding and post-unregister
onAvailable callbacks cannot rebind after shutdown.

* Serialize default network callback state changes

Add a dedicated lock and wrap the default network callback's onAvailable
and onLost bodies, plus the unregister teardown, in synchronized blocks
to close the TOCTOU race where a stale callback could re-bind the
process after unregisterNetworkCallback had cleared the binding.

* Warn if default network is a VPN

If registerDefaultNetworkCallback ever delivers our own TUN as the
default network, binding the process to it would create a routing loop.
Log a warning to surface that case if it happens on any device.

* Serialize default network callback registration

Mirror the unregister teardown's locking by wrapping the active flag and
registerDefaultNetworkCallback in the same synchronized block. Closes the
asymmetry between register and unregister so concurrent calls cannot leak
the default callback or leave the active flag inconsistent.

* Suppress old engine state events during restart

Detach the ConnectionListener before stopping the engine so the old
engine's Disconnecting/Disconnected teardown events do not reach the UI
and cause a brief visible Disconnected flash before the restart kicks
in. The listener is re-attached after the new engine starts; the Go
notifier delivers the current state on attach so the UI converges
without our help.

While the engine is detached, the EngineRestarter drives the UI itself
via notifyConnecting on stop and notifyDisconnected on timeout/error.

* Detect network handover from default-network signal

Replace the per-network onAvailable/onLost pairing with a default-network
type observation. Android sometimes skips onLost on seamless WiFi
handovers, leaving the previous mechanism unable to detect the
transition. The default-network callback delivers the authoritative
current transport, so any change of type triggers an engine restart.

* Skip restart when engine reconnects on its own

Two related changes to avoid disrupting a working connection during a
network handover:

- Filter Disconnecting/Disconnected events from the old engine teardown
  via a wrapper around ConnectionListener, and suppress per-listener the
  ServiceStateListener.onStopped/onStarted notifications so the UI does
  not flash through Disconnected during the restart window.

- Subscribe to OnConnected events from the engine. If the Go core
  reconnects autonomously while the 2s restart debounce is still
  pending, cancel the restart instead of tearing down the working
  connection.

* Bump netbird submodule to fix/job-stream-state-leak

Picks up the fix that prevents transient JOB stream errors from being
reported as a management disconnect, which would otherwise stick the
UI on Connecting after the JOB stream silently reconnects.

* Skip bindProcessToNetwork when default network is a VPN

On some devices the default network callback delivers our own TUN as
the default within a VpnService process. Binding the process to that
risks a routing loop. The Android default-network signal is replayed
seconds later with the underlying physical network, so skipping the
bind on a VPN result waits for that follow-up signal instead.

* Skip bindProcessToNetwork when network capabilities are unknown

Treat a null NetworkCapabilities as unsafe and skip the bind. The
previous null-tolerant check would have bypassed the VPN routing-loop
guard if Android happened to return null in a race between the
default-network callback firing and getNetworkCapabilities.

* Cancel pending restart on user-driven engine actions

A debounced restart scheduled in response to a network change can fire
after the user has manually started or stopped the engine, killing the
user's action mid-flight (auth context canceled, restart fails, UI
stays Disconnected).

Cancel any pending restart before the user-facing entry points run:
binder runEngine/stopEngine, broadcast stop, always-on start, and VPN
permission revoke. The EngineRestarter's own internal stop+restart
remains unaffected.

* Remove bindProcessToNetwork from default network callback

Reverts the bindProcessToNetwork side of f0df3f5. Pinning the process
to the current default network helps when the kernel routing table
lags the actual network change, but hurts when Android lingers a
departing network as default for tens of seconds: every fresh socket
gets stuck on a dying interface.

The default-network callback now only feeds the type-change signal
used for engine restart decisions; the kernel decides which interface
new sockets actually use.

* Fix wrapper stacking and stale listener on restart timeout

EngineRunner.setConnectionListener stacked a fresh ObservingConnectionListener
around whatever it received, so EngineRestarter snapshotting the current
listener and re-installing a FilteringConnectionListener around it grew the
chain by one level on every restart cycle. Unwrap any prior
ObservingConnectionListener inside setConnectionListener and any prior
FilteringConnectionListener when EngineRestarter snapshots, so the chain
stays at most two layers deep across repeated restarts.

Also unregister the restart's ServiceStateListener (and clear
currentListener) on the 30s timeout path so a late onStopped cannot fire
runWithoutAuth against a stale listener and silently restart the engine
after the timeout already gave up. Mirror the cleanup in onStarted and
onError for consistency.

* Address CodeRabbit review nits and bump submodule

- Restore the original ConnectionListener on successful restart so
  FilteringConnectionListener wrappers do not accumulate across cycles.
  Drop the now-unused allowAfterFirstConnectingOrConnected hook; only
  one engine ever runs at a time on Android, so there is no source of
  late events that would justify keeping the filter past onStarted.
- NetworkChangeDetector: replace AtomicBoolean with a plain boolean now
  that all access is guarded by networkCallbackLock.
- VPNService: drop the redundant engineRestarter null-checks in the
  stop-broadcast receiver and onRevoke; engineRestarter is initialized
  in onCreate before any of these paths are reachable.
- Bump netbird submodule to current main HEAD (ed828b7a).

* Move peer-list refreshes off the UI thread

PeersFragmentViewModel.onPeersChanged and HomeFragment.onPeersListChanged
called serviceAccessor.getPeersList() synchronously on whatever thread
invoked the listener. When MainActivity.registerServiceStateListener
replays cached state during fragment view creation, that thread is the
UI thread, so the JNI call into the Go engine happened on the main
looper. During engine bootstrap or teardown the call could block long
enough to trigger a 5+ s ANR.

Dispatch the JNI call to a single-thread executor in each consumer and
post results back via LiveData.postValue / View.post, both of which are
already UI-thread safe. The executor lifetime matches the listener's
(ViewModel.onCleared / Fragment.onDestroyView).

* Fix races and listener-suppression leak in EngineRestarter

Schedule/cancel of the debounced restart was non-atomic across the
connectivity callback thread, the Go reconnect notifier, and the main
thread, so a pending runnable could outlive a self-reconnect or be
cancelled while restartScheduled stayed true. Guard restartScheduled
together with handler.postDelayed/removeCallbacks under a single lock.

The suppressed-listener list was a local in restartEngine(), so a
cleanup() during an in-flight restart would not unsuppress external
ServiceStateListeners. The subsequent engineRunner.stop() then filtered
them out of notifyServiceStateListeners(false) and the UI never saw the
final onStopped. Hoist the holder to a field and unsuppress in cleanup()
and on every completion path via getAndSet(null).

* Guard PeersFragmentViewModel against teardown race

onPeersChanged is invoked from a gomobile callback thread while
onCleared runs on the main thread. An in-flight callback that already
passed the listener null-check in PeersStateListenerAdapter could call
refreshExecutor.execute after shutdown and propagate a
RejectedExecutionException up through gomobile.

Set an isCleared flag before clearing the listener and shutting down
the executor, skip submission when set, and swallow
RejectedExecutionException to make a concurrent event a no-op.
2026-05-05 16:13:00 +02:00