* Keep the tunnel resolver when Private DNS is in Automatic mode
The tunnel builder left the NetBird resolver out of the VPN whenever
Android reported Private DNS as active. That flag is also true in
Automatic mode on any network whose own resolver speaks TLS, which on
many carriers and some home networks is the default state. NetBird names
and custom DNS zones then stopped resolving with no visible reason, and
whether they worked at all depended on the network the phone was on at
the moment the tunnel was last rebuilt.
Only a configured Private DNS hostname actually conflicts with a plain
resolver in the tunnel: the OS then sends every query over TLS to that
host and refuses plain DNS, so offering our resolver would make every
lookup fail. In Automatic mode the OS probes a resolver for TLS and
falls back to plain DNS when the probe fails, and with Private DNS off
it never asks. In both cases the tunnel resolver works.
Decide on the hostname instead. DNSWatch now reads the Private DNS
server name next to the active flag, and IFace leaves the resolver out
only when one is set, logging which host it yields to. The hostname
setting is global, so the decision no longer depends on which network
happens to be active during a rebuild.
* Make the Private DNS resolver decision a testable function
The previous commit switched the tunnel resolver decision from the
Private DNS active flag to the configured hostname, which left the flag
with no reader. Drop the field, its getter and both assignments.
The decision itself now lives in a static function on DNSWatch that
takes only the hostname, so it can be unit tested without a
ConnectivityManager. IFace calls it directly and isPrivateDnsStrict goes
away. A null or empty name keeps the resolver; a hostname leaves it out.
The message logged when the resolver is left out drops from info to
debug, matching the level of the line it replaced.
* Adapt the e2e profile flow to the overflow-menu profile rows
The profile list no longer has per-row Switch and Remove buttons. A row
is tapped to switch, the active row shows a badge, and Remove lives in
the row's overflow menu. LoginFlow still looked for btn_switch and
btn_remove, so the login smoke test failed on a profile that was
actually created and enrolled, and fail-fast skipped the rest of the
suite.
* Wait for the network map before the first DNS lookup in the e2e test
The status reads Connected before the first network map is applied, and
the DNS zones arrive with that map. DnsResolutionTest fired its first
lookup in that gap: NetBird DNS forwarded it to the plain upstream, got
NXDOMAIN, and Android negative-cached it for the SOA TTL, so none of the
retries in the 90s budget reached the NetBird DNS again.
The harness now waits for the Peers tab to list a peer before the first
lookup. The engine registers the DNS config before it adds the map's
peers, so a listed peer proves the zones are in place.
* 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
Build.PRODUCT returns the internal AOSP build product name
(e.g. "raven", "sdk_gphone64_arm64"), which is not human-readable.
Build.MODEL returns the user-facing model name (e.g. "Pixel 6"),
which makes for a more recognizable peer hostname in NetBird.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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
* 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.
* Pass app cache directory to Go for debug bundle temp files
Pass context.getCacheDir() through AndroidPlatformFiles so the Go
debug bundle generator can create temporary zip files in a writable
directory instead of /data/local/tmp/.
* Update netbird submodule with logcat debug bundle support
* Add Troubleshoot fragment with debug bundle upload
Move trace log toggle and log sharing from Advanced to a new
Troubleshoot fragment accessible via the drawer menu. Replace
the old logcat share with debug bundle generation and upload
that copies the upload key to clipboard. Add anonymize toggle.
Works with or without a running engine.
* Replace drawer menu PNG icons with Material outlined vectors
Remove density-specific PNG icons and replace with vector drawables
using Material Design outlined style for consistent appearance.
* Add error logging to EngineRunner.debugBundle
Wrap goClient call with try-catch to log errors, matching the
pattern used by selectRoute and deselectRoute.
* Guard TroubleshootFragment UI callbacks against destroyed view
Check binding and isAdded() before accessing UI in background
thread callbacks to prevent NPE if the fragment is destroyed
while the debug bundle upload is in progress.
Replace the low-resolution 42x42 PNG with a vector drawable for the
Quick Settings tile icon. The new drawable uses the NetBird logo SVG
paths, rendering crisply at any screen density.