From 6eb07a34952a5b258cacf2807f0f7322b93592b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 20 Apr 2026 14:43:01 +0200 Subject: [PATCH 1/6] 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. --- .../netbird/client/tool/EngineRestarter.java | 29 +++++++++++++++++++ .../io/netbird/client/tool/EngineRunner.java | 7 +++++ 2 files changed, 36 insertions(+) diff --git a/tool/src/main/java/io/netbird/client/tool/EngineRestarter.java b/tool/src/main/java/io/netbird/client/tool/EngineRestarter.java index 23230adf..f8922a59 100644 --- a/tool/src/main/java/io/netbird/client/tool/EngineRestarter.java +++ b/tool/src/main/java/io/netbird/client/tool/EngineRestarter.java @@ -6,6 +6,7 @@ import android.util.Log; import io.netbird.client.tool.networks.NetworkToggleListener; +import io.netbird.gomobile.android.ConnectionListener; /** *

EngineRestarter restarts the Go engine.

@@ -52,6 +53,7 @@ private void restartEngine() { if (isRestartInProgress) { Log.e(LOGTAG, "engine restart timeout - forcing flag reset"); isRestartInProgress = false; + notifyDisconnected(); } }; @@ -72,6 +74,7 @@ public void onStarted() { @Override public void onStopped() { Log.d(LOGTAG, "engine is stopped, restarting..."); + notifyConnecting(); engineRunner.runWithoutAuth(); } @@ -81,6 +84,7 @@ public void onError(String msg) { isRestartInProgress = false; // Resetting flag on error as well handler.removeCallbacks(timeoutCallback); // Cancel timeout engineRunner.removeServiceStateListener(this); + notifyDisconnected(); } }; currentListener = serviceStateListener; @@ -94,9 +98,34 @@ public void onError(String msg) { } Log.d(LOGTAG, "engine is running, stopping due to network change"); + notifyConnecting(); engineRunner.stop(); } + private void notifyConnecting() { + ConnectionListener listener = engineRunner.getConnectionListener(); + if (listener == null) { + return; + } + try { + listener.onConnecting(); + } catch (Exception e) { + Log.w(LOGTAG, "onConnecting notification failed: " + e.getMessage()); + } + } + + private void notifyDisconnected() { + ConnectionListener listener = engineRunner.getConnectionListener(); + if (listener == null) { + return; + } + try { + listener.onDisconnected(); + } catch (Exception e) { + Log.w(LOGTAG, "onDisconnected notification failed: " + e.getMessage()); + } + } + @Override public void onNetworkTypeChanged() { Log.d(LOGTAG, "network type changed, scheduling restart with " diff --git a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java index 703cbd4d..23346457 100644 --- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java +++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java @@ -29,6 +29,7 @@ class EngineRunner { private boolean engineIsRunning = false; Set serviceStateListeners = ConcurrentHashMap.newKeySet(); private final Client goClient; + private ConnectionListener connectionListener; public EngineRunner(Context context, NetworkChangeListener networkChangeListener, TunAdapter tunAdapter, IFaceDiscover iFaceDiscover, String versionName, boolean isTraceLogEnabled, boolean isDebuggable, @@ -124,13 +125,19 @@ public synchronized boolean isRunning() { } public synchronized void setConnectionListener(ConnectionListener listener) { + this.connectionListener = listener; goClient.setConnectionListener(listener); } public synchronized void removeStatusListener() { + this.connectionListener = null; goClient.removeConnectionListener(); } + synchronized ConnectionListener getConnectionListener() { + return connectionListener; + } + public synchronized void addServiceStateListener(ServiceStateListener serviceStateListener) { if (engineIsRunning) { serviceStateListener.onStarted(); From f0df3f5986f4db452b4a882089d0c428f0c41e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 20 Apr 2026 15:24:41 +0200 Subject: [PATCH 2/6] 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. --- .../ConcreteNetworkAvailabilityListener.java | 20 +++++++++- .../tool/networks/NetworkChangeDetector.java | 39 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java b/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java index 8c8cdbce..1542ad6a 100644 --- a/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java +++ b/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java @@ -4,8 +4,13 @@ import java.util.concurrent.ConcurrentHashMap; public class ConcreteNetworkAvailabilityListener implements NetworkAvailabilityListener { + // Grace window after subscribing a listener during which Android's initial + // onAvailable burst is treated as state seeding, not as a transition. + private static final long INITIAL_BURST_GRACE_MS = 3000; + private final Map availableNetworkTypes; private NetworkToggleListener listener; + private volatile long listenerSubscribedAt = 0; public ConcreteNetworkAvailabilityListener() { this.availableNetworkTypes = new ConcurrentHashMap<>(); @@ -38,16 +43,27 @@ public void onNetworkLost(@Constants.NetworkType int networkType) { } private void notifyListener() { - if (listener != null) { - listener.onNetworkTypeChanged(); + NetworkToggleListener l = listener; + if (l == null) { + return; + } + // Skip Android's initial onAvailable burst that fires right after the + // NetworkCallback is registered; that is the current state, not a + // transition, and must not trigger an engine restart. + long subscribedAt = listenerSubscribedAt; + if (subscribedAt != 0 && System.currentTimeMillis() - subscribedAt < INITIAL_BURST_GRACE_MS) { + return; } + l.onNetworkTypeChanged(); } public void subscribe(NetworkToggleListener listener) { this.listener = listener; + this.listenerSubscribedAt = System.currentTimeMillis(); } public void unsubscribe() { this.listener = null; + this.listenerSubscribedAt = 0; } } diff --git a/tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java b/tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java index f02a09c9..8fdceca3 100644 --- a/tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java +++ b/tool/src/main/java/io/netbird/client/tool/networks/NetworkChangeDetector.java @@ -13,11 +13,13 @@ public class NetworkChangeDetector { private static final String LOGTAG = NetworkChangeDetector.class.getSimpleName(); private final ConnectivityManager connectivityManager; private ConnectivityManager.NetworkCallback networkCallback; + private ConnectivityManager.NetworkCallback defaultNetworkCallback; private volatile NetworkAvailabilityListener listener; public NetworkChangeDetector(ConnectivityManager connectivityManager) { this.connectivityManager = connectivityManager; initNetworkCallback(); + initDefaultNetworkCallback(); } private void checkNetworkCapabilities(Network network, Consumer operation) { @@ -58,10 +60,37 @@ public void onCapabilitiesChanged(@NonNull Network network, @NonNull NetworkCapa }; } + private void initDefaultNetworkCallback() { + defaultNetworkCallback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(@NonNull Network network) { + Log.d(LOGTAG, "default network became " + network + ", binding process to it"); + try { + if (!connectivityManager.bindProcessToNetwork(network)) { + Log.w(LOGTAG, "bindProcessToNetwork returned false for " + network); + } + } catch (Exception e) { + Log.e(LOGTAG, "bindProcessToNetwork failed", e); + } + } + + @Override + public void onLost(@NonNull Network network) { + Log.d(LOGTAG, "default network " + network + " lost, clearing process binding"); + try { + connectivityManager.bindProcessToNetwork(null); + } catch (Exception e) { + Log.e(LOGTAG, "bindProcessToNetwork(null) failed", e); + } + } + }; + } + public void registerNetworkCallback() { NetworkRequest.Builder builder = new NetworkRequest.Builder(); builder.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); connectivityManager.registerNetworkCallback(builder.build(), networkCallback); + connectivityManager.registerDefaultNetworkCallback(defaultNetworkCallback); } public void unregisterNetworkCallback() { @@ -70,6 +99,16 @@ public void unregisterNetworkCallback() { } catch (Exception e) { Log.e(LOGTAG, "failed to unregister network callback", e); } + try { + connectivityManager.unregisterNetworkCallback(defaultNetworkCallback); + } catch (Exception e) { + Log.e(LOGTAG, "failed to unregister default network callback", e); + } + try { + connectivityManager.bindProcessToNetwork(null); + } catch (Exception e) { + Log.e(LOGTAG, "bindProcessToNetwork(null) on unregister failed", e); + } } public void subscribe(NetworkAvailabilityListener listener) { From b52ce5d30e05cb315de1ac3fc12ee80773b6cd13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 20 Apr 2026 15:25:08 +0200 Subject: [PATCH 3/6] Bump netbird submodule to test branch --- netbird | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbird b/netbird index 4eed459f..d2c18fdb 160000 --- a/netbird +++ b/netbird @@ -1 +1 @@ -Subproject commit 4eed459f27bd7e90faa7fe99e1edf4a59dc71265 +Subproject commit d2c18fdb9520a334702feaf582c6411d40a5b20b From 212cf42e533bea8a0f4720857f91346a13902d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 20 Apr 2026 15:40:18 +0200 Subject: [PATCH 4/6] 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. --- .../io/netbird/client/tool/VPNService.java | 9 +++--- .../ConcreteNetworkAvailabilityListener.java | 24 +++++++-------- ...teNetworkAvailabilityListenerUnitTest.java | 29 +++++++++++++++---- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/tool/src/main/java/io/netbird/client/tool/VPNService.java b/tool/src/main/java/io/netbird/client/tool/VPNService.java index 548ae3ce..f80874db 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -62,14 +62,15 @@ public void onCreate() { // Create foreground notification before initializing engine fgNotification = new ForegroundNotification(this); - // Create network availability listener before initializing engine - networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(); - - engineRunner = new EngineRunner(this, notifier, tunAdapter, iFaceDiscover, versionName, preferences.isTraceLogEnabled(), Version.isDebuggable(this), profileManager); engineRunner.addServiceStateListener(serviceStateListener); + // Create network availability listener after the engine runner so we + // can gate notifications on the engine actually being up; this avoids + // acting on Android's initial onAvailable burst during cold start. + networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(engineRunner::isRunning); + engineRestarter = new EngineRestarter(engineRunner); networkAvailabilityListener.subscribe(engineRestarter); diff --git a/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java b/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java index 1542ad6a..2957fb41 100644 --- a/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java +++ b/tool/src/main/java/io/netbird/client/tool/networks/ConcreteNetworkAvailabilityListener.java @@ -2,18 +2,24 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BooleanSupplier; public class ConcreteNetworkAvailabilityListener implements NetworkAvailabilityListener { - // Grace window after subscribing a listener during which Android's initial - // onAvailable burst is treated as state seeding, not as a transition. - private static final long INITIAL_BURST_GRACE_MS = 3000; - private final Map availableNetworkTypes; + private final BooleanSupplier shouldNotify; private NetworkToggleListener listener; - private volatile long listenerSubscribedAt = 0; public ConcreteNetworkAvailabilityListener() { + this(() -> true); + } + + // shouldNotify is consulted before each listener notification. Pass + // engineRunner::isRunning to swallow the initial onAvailable burst that + // fires right after registerNetworkCallback; until the engine is actually + // running there is nothing to restart. + public ConcreteNetworkAvailabilityListener(BooleanSupplier shouldNotify) { this.availableNetworkTypes = new ConcurrentHashMap<>(); + this.shouldNotify = shouldNotify; } @Override @@ -47,11 +53,7 @@ private void notifyListener() { if (l == null) { return; } - // Skip Android's initial onAvailable burst that fires right after the - // NetworkCallback is registered; that is the current state, not a - // transition, and must not trigger an engine restart. - long subscribedAt = listenerSubscribedAt; - if (subscribedAt != 0 && System.currentTimeMillis() - subscribedAt < INITIAL_BURST_GRACE_MS) { + if (!shouldNotify.getAsBoolean()) { return; } l.onNetworkTypeChanged(); @@ -59,11 +61,9 @@ private void notifyListener() { public void subscribe(NetworkToggleListener listener) { this.listener = listener; - this.listenerSubscribedAt = System.currentTimeMillis(); } public void unsubscribe() { this.listener = null; - this.listenerSubscribedAt = 0; } } diff --git a/tool/src/test/java/io/netbird/client/tool/ConcreteNetworkAvailabilityListenerUnitTest.java b/tool/src/test/java/io/netbird/client/tool/ConcreteNetworkAvailabilityListenerUnitTest.java index 809ceb11..e1ea8c95 100644 --- a/tool/src/test/java/io/netbird/client/tool/ConcreteNetworkAvailabilityListenerUnitTest.java +++ b/tool/src/test/java/io/netbird/client/tool/ConcreteNetworkAvailabilityListenerUnitTest.java @@ -29,7 +29,7 @@ public void deactivateMobile() { this.listener.onNetworkLost(Constants.NetworkType.MOBILE); } } - + private static class MockNetworkToggleListener implements NetworkToggleListener { private int totalTimesNetworkTypeChanged = 0; @@ -47,7 +47,7 @@ public void resetCounter() { public void shouldNotifyListenerNetworkUpgraded() { // Assemble: var networkToggleListener = new MockNetworkToggleListener(); - var networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(); + var networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(() -> true); networkAvailabilityListener.subscribe(networkToggleListener); var networkChangeDetector = new MockNetworkChangeDetector(networkAvailabilityListener); @@ -64,7 +64,7 @@ public void shouldNotifyListenerNetworkUpgraded() { public void shouldNotifyListenerNetworkDowngraded() { // Assemble: var networkToggleListener = new MockNetworkToggleListener(); - var networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(); + var networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(() -> true); networkAvailabilityListener.subscribe(networkToggleListener); var networkChangeDetector = new MockNetworkChangeDetector(networkAvailabilityListener); @@ -82,7 +82,7 @@ public void shouldNotifyListenerNetworkDowngraded() { public void shouldNotNotifyListenerNetworkDidNotUpgrade() { // Assemble: var networkToggleListener = new MockNetworkToggleListener(); - var networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(); + var networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(() -> true); networkAvailabilityListener.subscribe(networkToggleListener); var networkChangeDetector = new MockNetworkChangeDetector(networkAvailabilityListener); @@ -103,7 +103,7 @@ public void shouldNotNotifyListenerNetworkDidNotUpgrade() { public void shouldNotNotifyListenerNoNetworksAvailable() { // Assemble: var networkToggleListener = new MockNetworkToggleListener(); - var networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(); + var networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(() -> true); networkAvailabilityListener.subscribe(networkToggleListener); var networkChangeDetector = new MockNetworkChangeDetector(networkAvailabilityListener); @@ -118,4 +118,23 @@ public void shouldNotNotifyListenerNoNetworksAvailable() { // Assert: assertEquals(0, networkToggleListener.totalTimesNetworkTypeChanged); } + + @Test + public void shouldNotNotifyListenerWhenEngineNotRunning() { + // Assemble: engine never running, so initial onAvailable burst from + // Android must not trigger a restart. + var networkToggleListener = new MockNetworkToggleListener(); + var networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(() -> false); + networkAvailabilityListener.subscribe(networkToggleListener); + + var networkChangeDetector = new MockNetworkChangeDetector(networkAvailabilityListener); + + // Act: + networkChangeDetector.activateMobile(); + networkChangeDetector.activateWifi(); + networkChangeDetector.deactivateWifi(); + + // Assert: + assertEquals(0, networkToggleListener.totalTimesNetworkTypeChanged); + } } From bd319538f40b9b0a633d2ff0a8672558bfc34558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 20 Apr 2026 17:46:08 +0200 Subject: [PATCH 5/6] Update submodule --- netbird | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbird b/netbird index d2c18fdb..5b09078d 160000 --- a/netbird +++ b/netbird @@ -1 +1 @@ -Subproject commit d2c18fdb9520a334702feaf582c6411d40a5b20b +Subproject commit 5b09078da2ac14550741ce8731e7cf4b4a62a728 From b2d0f6d64f5ec2b0a9c8ccc3559bd8daac4e704c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 20 Apr 2026 17:52:00 +0200 Subject: [PATCH 6/6] 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. --- .../java/io/netbird/client/tool/ForegroundNotification.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java b/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java index 0161af9c..534dfe61 100644 --- a/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java +++ b/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java @@ -26,7 +26,9 @@ public void startForeground() { NotificationChannel channel = new NotificationChannel( channelId, service.getResources().getString(R.string.fg_notification_channel_name), - NotificationManager.IMPORTANCE_DEFAULT); + NotificationManager.IMPORTANCE_LOW); + channel.setSound(null, null); + channel.enableVibration(false); ((NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE)).createNotificationChannel(channel); Intent notificationIntent = new Intent();