Skip to content

Android integration - tech demo - #241

Draft
pappz wants to merge 72 commits into
ux/ios-style-redesignfrom
android-integration
Draft

Android integration - tech demo#241
pappz wants to merge 72 commits into
ux/ios-style-redesignfrom
android-integration

Conversation

@pappz

@pappz pappz commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

pappz added 30 commits August 12, 2026 11:32
Track whether any network claims internet connectivity and feed the
result to the Go core, which suspends its reconnection loops instead of
retrying into a dead radio. NetworkChangeDetector now keeps the set of
available networks and reports only the transitions in and out of
"nothing available", seeding its state at registration so the engine
learns the truth even when it starts offline.

Surface the suspension: the engine reports a NoNetwork state over the
new OnStateChanged callback, and the home screen paints "No network
available" rather than a "Connecting…" that is not happening. The
per-state callbacks stay for compatibility.

Two listener defects show up once callbacks are logged.
getConnectionListener handed out the ObservingConnectionListener
wrapper, which EngineRestarter then wrapped again, so every callback was
delivered twice and stacked a further layer on each failed restart; it
now returns the raw listener. And the restart filter released on the
first non-disconnect state, which the old engine also emits while its
management and signal links drop during teardown — the Disconnected
flash it exists to hide went straight through. It now releases only
after onStopped, where the old engine's run has provably returned.
The engine restart that handled network type changes tore down the TUN
device and the peer state to fix what is really a socket-level problem:
connections bound to the old network. The Go core now exposes
NotifyNetworkChange, which cuts exactly those connections so the
reconnect loops redial on the new network — measured recovery is 1.6s
against the restart's 3.2s, with no Disconnected flash and no leak
window while the TUN is gone.

EngineRestarter is replaced by NetworkSwitchNotifier: same trigger and
debounce, and the cancel-when-reconnected guard stays because a cut
after the core already reconnected on its own would sever fresh,
working connections. The debounce drops from 2s to 1s — a cut is cheap
and idempotent, so it does not need the headroom a restart did. The
restart-only machinery goes with it: the filtering listener that hid
teardown callbacks from the UI, and EngineRunner's suppression and
listener-snapshot support, none of which have a purpose when nothing is
torn down.
The NoNetwork state shipped with only the default English string, so
localized devices fell back to English on the home screen.
Picks up the netsweep dial handoff, the relay transport read ordering, the
sweep test fix, and the reconnection resume when the network comes back.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
pappz added 11 commits August 14, 2026 22:32
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.
Peers now serve SSH on the standard port, so the dialog no longer
prefills 22022.
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.
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.
# Conflicts:
#	app/src/main/java/io/netbird/client/MainActivity.java
#	netbird
…tion

# Conflicts:
#	netbird
#	tool/src/main/java/io/netbird/client/tool/VPNService.java
#	tool/src/main/res/values/strings.xml
The connection-state notification work replaced drawable/notification_icon.png
with per-state icons, so the file drop notifications referenced a drawable the
merged tree no longer has.
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d5576b3b-4670-445a-88ee-fb75732323a6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The centered block (logo, connect toggle, status, hostname, address
summary) is a packed chain anchored between the profile chip and the exit
node row, so it moved whenever either end of that span changed height.
Two things changed it:

The hostname was empty and the address summary GONE while disconnected,
making the chain shorter and centering it lower. Both now hold their slot
when empty, so filling them in on connect no longer grows the group. An
INVISIBLE view still takes taps and focus, so the summary's clickable and
focusable track whether there is an address to expand.

The exit node row was anchored to the top of the session expiry row, so
that row appearing pushed the exit node row up and dragged the chain with
it. The exit node row is now pinned to the parent bottom and the session
row stacks above it, growing upwards into free space instead of pushing.
A barrier over both rows gives the expandable info rows a cap that follows
whichever of them is currently topmost.
@pappz
pappz force-pushed the android-integration branch from 9d2d3a4 to b30f5af Compare August 16, 2026 22:34
pappz added 16 commits August 17, 2026 23:29
The bottom navigation was at its five-item limit with Files and SSH each
holding a slot. They now share one "Apps" item, with a segmented control
switching between them — the same pattern Peers already uses for Peers and
Resources, so no ViewPager2 dependency is needed and the two screens look
like the rest of the app.

SSH sits on the first tab. Both fragments are unchanged and hosted as-is;
only the SSH session list had to resolve its NavController from the activity
rather than from its own nav host, since it is no longer a direct child of
one.

The Apps tab hides the toolbar like Home and Peers: the SSH list kept a
toolbar only to anchor the top of the list, and the segmented control now
does that.
The transfer log was a flat list of divider-separated rows while the peer
list next to it uses cards; it now uses the same card treatment — gray
background, 16dp padding, 20dp gap and the shared focus highlight.

Each row is three lines: the file name, "to"/"from" the peer, then the time
and size. All three truncate with an ellipsis, so a long file name or a long
peer FQDN no longer pushes anything out of the row. The outcome moves to the
right of the card on its own, vertically centred along with the direction
arrow.

Now that the outcome stands alone it carries the colour, following the
desktop's rule: a refusal or failure in red, a completed send in green, and
everything else — a received file and a transfer in flight included — left
neutral so the exceptions stand out.
An expired offer read "Expired" and a refused port read "Peer cannot receive
files", where the desktop says "No response" and "Declined". Both describe
the same state to the same user, so they now use the desktop's wording.

"Declined" for the unreachable case is deliberate on the desktop: a peer
whose file drop port refuses the offer is, from the sender's side, the same
outcome as an explicit refusal, and naming the port adds nothing the user
can act on.
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.
The row's tap-to-send label is gone; an untouched row renders empty. Only
the layout preview still said otherwise.
The Cancel button took the navigation bar inset as bottom padding, a
leftover from when it sat at the bottom of the screen. setPadding wiped
the padding the layout gave it, so the label rendered flush against the
truncated file name and at the top of its own box. The inset now goes to
the peer list, where it lets the last card scroll clear of the bar, and
the button keeps its own padding and centres on the title and subtitle.

The peer rows now use the same cards as the main peer list and the Files
tab: gray background, 16dp padding, 20dp gap and the shared focus
highlight. No status stripe — the address line already says whether the
peer is idle, and only until the send starts, after which the label would
be stale.

A row stays two lines while it sends: the percentage shares the first
line with the peer name, the bar the second with the address, both
truncating with an ellipsis well clear of what sits beside them. A row
waiting for the peer to answer is redrawn on every update so it pulses
like a sending one, rather than looking stuck.
A send could be started from the picker but never stopped there: the only
way out was the Files tab's long press, or waiting for a 500 MB upload to
finish. A long press on a waiting or sending row now asks before aborting
it, the same gesture and the same prompt style the Files tab uses. Tap
keeps its single meaning, so a stray finger cannot kill a transfer.

A stopped row goes back to being pickable rather than reporting a failure,
so the same files can be sent again to the same peer. A cancel arriving
from anywhere else lands the row in the same place, instead of the red
"Failed" the terminal-state mapping gave it.

The peer list also follows the main peer list's order now — connected
first, then by name — rather than whatever order the engine hands back,
so the same peer sits in the same place on both screens.
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.
The hostname sized itself, so a long one wrapped onto a second line and
ran out to both edges of the screen. It now takes the width it is given,
inside the same 20dp margin the rows below it keep, and truncates with an
ellipsis on a single line. A short hostname still sits centred.
The address rows opened into whatever gap was left between the summary and
the cards below, and constrainedHeight let that gap win: on this screen the
rows came out squeezed into 42dp of a needed 112, the second one clipped
away entirely. The session expiry card decides it — the same screen fits
the rows when no session is about to expire and crushes them when one is.

Raising the packed block helps but cannot settle it: even pinned to the top
there is less room than the rows need, and how much is missing changes with
what the cards below are showing.

So the rows now open inline when they fit and as a floating panel when they
do not, measured rather than assumed, since the system font size and the
presence of an IPv6 address both move the number. The panel is drawn over
the cards, so nothing on the screen has to move or be taken away to make
room for it.
…oid-integration

# Conflicts:
#	app/src/main/res/values/dimens.xml
The foreground notification formatted the deadline as bare clock time, so
a session expiring after midnight read as today's time. Use
DateUtils.getRelativeDateTimeString for a localized day-plus-time form
(today/tomorrow/date), and switch the notification strings to a colon
format in every locale so the phrase composes with the day wording.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant