From a5a9e212fb5c5def5f2624a0c368356fb6561af9 Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:15:47 +0000 Subject: [PATCH 1/5] fix: notify Go engine on network change for posture check re-evaluation Add direct callback from Android NetworkChangeDetector to Go client's OnUnderlyingNetworkChanged(), triggering immediate re-sync of NetworkAddresses with the management server. This ensures posture checks evaluate the current network state after WiFi/cellular switches. Also improve ConcreteNetworkAvailabilityListener to notify on all network transitions, not just specific WiFi<->Mobile patterns. --- .../io/netbird/client/tool/EngineRunner.java | 13 ++++++++++ .../io/netbird/client/tool/VPNService.java | 6 +++++ .../ConcreteNetworkAvailabilityListener.java | 20 +++++++------- .../tool/networks/NetworkChangeDetector.java | 26 ++++++++++++++++--- 4 files changed, 50 insertions(+), 15 deletions(-) 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..7eebb2dd 100644 --- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java +++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java @@ -163,6 +163,19 @@ public synchronized void stop() { goClient.stop(); } + /** + * Notify the Go client that the underlying network changed (WiFi/cellular). + * This triggers an immediate re-sync of NetworkAddresses with the management + * server for posture check re-evaluation. + */ + public void notifyNetworkChanged() { + try { + goClient.onUnderlyingNetworkChanged(); + } catch (Exception e) { + Log.d(LOGTAG, "notifyNetworkChanged: " + e.getMessage()); + } + } + public PeerInfoArray peersInfo() { return goClient.peersList(); } 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..cf29834c 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -76,6 +76,12 @@ public void onCreate() { networkChangeDetector = new NetworkChangeDetector( (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE)); networkChangeDetector.subscribe(networkAvailabilityListener); + // Notify Go layer on any network change for posture check re-evaluation + networkChangeDetector.setNetworkChangedCallback(() -> { + if (engineRunner != null) { + engineRunner.notifyNetworkChanged(); + } + }); networkChangeDetector.registerNetworkCallback(); // Register broadcast receiver for stopping engine (e.g., during profile switch) 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..4f6fefc3 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 @@ -13,26 +13,24 @@ public ConcreteNetworkAvailabilityListener() { @Override public void onNetworkAvailable(@Constants.NetworkType int networkType) { - boolean isWifiAvailable = Boolean.TRUE.equals(availableNetworkTypes.get(Constants.NetworkType.WIFI)); + Boolean wasAvailable = availableNetworkTypes.put(networkType, true); - availableNetworkTypes.put(networkType, true); - - // if wifi is available and wasn't before, notifies listener. - // Android prioritizes wifi over mobile data network by default. - if (!isWifiAvailable && networkType == Constants.NetworkType.WIFI) { + // Always notify on any network change so the engine re-syncs + // NetworkAddresses with the management server. This ensures + // posture checks see the current network (e.g. WiFi subnet) + // immediately after a network switch. + if (!Boolean.TRUE.equals(wasAvailable)) { notifyListener(); } } @Override public void onNetworkLost(@Constants.NetworkType int networkType) { - boolean isMobileAvailable = Boolean.TRUE.equals(availableNetworkTypes.get(Constants.NetworkType.MOBILE)); - availableNetworkTypes.remove(networkType); - // if wifi is lost and mobile data is available, notifies listener. - // No use to notify it if there's no other type of network available. - if (isMobileAvailable && networkType == Constants.NetworkType.WIFI) { + // Notify on any network loss if another network is still available, + // so the engine re-syncs with updated NetworkAddresses. + if (!availableNetworkTypes.isEmpty()) { notifyListener(); } } 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..7606de2d 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 @@ -14,12 +14,22 @@ public class NetworkChangeDetector { private final ConnectivityManager connectivityManager; private ConnectivityManager.NetworkCallback networkCallback; private volatile NetworkAvailabilityListener listener; + private volatile Runnable networkChangedCallback; public NetworkChangeDetector(ConnectivityManager connectivityManager) { this.connectivityManager = connectivityManager; initNetworkCallback(); } + /** + * Set a callback that fires on every network availability/loss event, + * regardless of type. Used to notify the Go layer about underlying + * network changes for posture check re-evaluation. + */ + public void setNetworkChangedCallback(Runnable callback) { + this.networkChangedCallback = callback; + } + private void checkNetworkCapabilities(Network network, Consumer operation) { var capabilities = connectivityManager.getNetworkCapabilities(network); if (capabilities == null) return; @@ -37,16 +47,24 @@ private void initNetworkCallback() { networkCallback = new ConnectivityManager.NetworkCallback() { @Override public void onAvailable(@NonNull Network network) { + Log.d(LOGTAG, "onAvailable: " + network); NetworkAvailabilityListener localListener = listener; - if (localListener == null) return; - checkNetworkCapabilities(network, localListener::onNetworkAvailable); + if (localListener != null) { + checkNetworkCapabilities(network, localListener::onNetworkAvailable); + } + Runnable cb = networkChangedCallback; + if (cb != null) cb.run(); } @Override public void onLost(@NonNull Network network) { + Log.d(LOGTAG, "onLost: " + network); NetworkAvailabilityListener localListener = listener; - if (localListener == null) return; - checkNetworkCapabilities(network, localListener::onNetworkLost); + if (localListener != null) { + checkNetworkCapabilities(network, localListener::onNetworkLost); + } + Runnable cb = networkChangedCallback; + if (cb != null) cb.run(); } @Override From b32434231f03a3e412e39f1deb88568d61a71997 Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:17:22 +0000 Subject: [PATCH 2/5] fix: skip initial onAvailable callback to prevent login cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When VPNService.onCreate() registers the NetworkChangeDetector callback, Android immediately fires onAvailable() for each already-connected network (e.g., WiFi). This is not a real network change — it's the initial state report. The EngineRestarter's 2-second debounce timer, triggered by this initial callback, expires right in the middle of the Go engine's login flow and cancels the context, causing "login failed: context canceled" on the first connect attempt. Fix: track whether the initial callback has been received and skip it. Subsequent onAvailable calls (real network changes) are processed normally. --- .../client/tool/networks/NetworkChangeDetector.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 7606de2d..360ed892 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 @@ -15,6 +15,7 @@ public class NetworkChangeDetector { private ConnectivityManager.NetworkCallback networkCallback; private volatile NetworkAvailabilityListener listener; private volatile Runnable networkChangedCallback; + private volatile boolean initialCallbackReceived; public NetworkChangeDetector(ConnectivityManager connectivityManager) { this.connectivityManager = connectivityManager; @@ -47,6 +48,14 @@ private void initNetworkCallback() { networkCallback = new ConnectivityManager.NetworkCallback() { @Override public void onAvailable(@NonNull Network network) { + // Skip the very first onAvailable after registerNetworkCallback(). + // Android fires this immediately for each already-connected network — + // it is an initial state report, not an actual network change. + if (!initialCallbackReceived) { + initialCallbackReceived = true; + Log.d(LOGTAG, "ignoring initial onAvailable (not a real network change)"); + return; + } Log.d(LOGTAG, "onAvailable: " + network); NetworkAvailabilityListener localListener = listener; if (localListener != null) { @@ -77,6 +86,7 @@ public void onCapabilitiesChanged(@NonNull Network network, @NonNull NetworkCapa } public void registerNetworkCallback() { + initialCallbackReceived = false; NetworkRequest.Builder builder = new NetworkRequest.Builder(); builder.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); connectivityManager.registerNetworkCallback(builder.build(), networkCallback); From 489bf1695cfb64be3f53ea7e6664a3a97d3579d1 Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:25:04 +0000 Subject: [PATCH 3/5] fix: update test assertions to match new any-network-change notification behavior The ConcreteNetworkAvailabilityListener now notifies on every network type change (not just WiFi-priority upgrades). Update test expectations: - shouldNotifyListenerNetworkUpgraded: 1 -> 2 (both mobile+wifi notify) - shouldNotifyListenerNetworkDowngraded: 2 -> 3 (mobile+wifi+lost) - Renamed shouldNotNotifyListenerNetworkDidNotUpgrade to shouldNotifyListenerOnAnyNetworkChange: 0 -> 2 (mobile add+remove) - shouldNotNotifyListenerNoNetworksAvailable: stays 0 (no fallback) Co-Authored-By: Claude Opus 4.6 (1M context) --- ...teNetworkAvailabilityListenerUnitTest.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) 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..b1dc0176 100644 --- a/tool/src/test/java/io/netbird/client/tool/ConcreteNetworkAvailabilityListenerUnitTest.java +++ b/tool/src/test/java/io/netbird/client/tool/ConcreteNetworkAvailabilityListenerUnitTest.java @@ -53,11 +53,11 @@ public void shouldNotifyListenerNetworkUpgraded() { var networkChangeDetector = new MockNetworkChangeDetector(networkAvailabilityListener); // Act: - networkChangeDetector.activateMobile(); - networkChangeDetector.activateWifi(); + networkChangeDetector.activateMobile(); // new network type -> notify + networkChangeDetector.activateWifi(); // new network type -> notify - // Assert: - assertEquals(1, networkToggleListener.totalTimesNetworkTypeChanged); + // Assert: both mobile and wifi becoming available trigger notifications + assertEquals(2, networkToggleListener.totalTimesNetworkTypeChanged); } @Test @@ -70,16 +70,16 @@ public void shouldNotifyListenerNetworkDowngraded() { var networkChangeDetector = new MockNetworkChangeDetector(networkAvailabilityListener); // Act: - networkChangeDetector.activateMobile(); - networkChangeDetector.activateWifi(); // upgraded, network changes. - networkChangeDetector.deactivateWifi(); // downgraded, network changes. + networkChangeDetector.activateMobile(); // new network type -> notify + networkChangeDetector.activateWifi(); // new network type -> notify + networkChangeDetector.deactivateWifi(); // lost, mobile still available -> notify - // Assert: - assertEquals(2, networkToggleListener.totalTimesNetworkTypeChanged); + // Assert: each event triggers a notification + assertEquals(3, networkToggleListener.totalTimesNetworkTypeChanged); } @Test - public void shouldNotNotifyListenerNetworkDidNotUpgrade() { + public void shouldNotifyListenerOnAnyNetworkChange() { // Assemble: var networkToggleListener = new MockNetworkToggleListener(); var networkAvailabilityListener = new ConcreteNetworkAvailabilityListener(); @@ -92,11 +92,11 @@ public void shouldNotNotifyListenerNetworkDidNotUpgrade() { networkToggleListener.resetCounter(); - networkChangeDetector.activateMobile(); - networkChangeDetector.deactivateMobile(); + networkChangeDetector.activateMobile(); // new network type -> notify + networkChangeDetector.deactivateMobile(); // lost, wifi still available -> notify - // Assert: - assertEquals(0, networkToggleListener.totalTimesNetworkTypeChanged); + // Assert: every network change notifies for posture check re-evaluation + assertEquals(2, networkToggleListener.totalTimesNetworkTypeChanged); } @Test From 7d64176bcf629af5fe4179dd89eb68e09e62ad22 Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:25:16 +0000 Subject: [PATCH 4/5] fix: use Log.e for exception logging in notifyNetworkChanged Exceptions should be logged at error level, not debug level. Co-Authored-By: Claude Opus 4.6 (1M context) --- tool/src/main/java/io/netbird/client/tool/EngineRunner.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7eebb2dd..bf9a4cd3 100644 --- a/tool/src/main/java/io/netbird/client/tool/EngineRunner.java +++ b/tool/src/main/java/io/netbird/client/tool/EngineRunner.java @@ -172,7 +172,7 @@ public void notifyNetworkChanged() { try { goClient.onUnderlyingNetworkChanged(); } catch (Exception e) { - Log.d(LOGTAG, "notifyNetworkChanged: " + e.getMessage()); + Log.e(LOGTAG, "notifyNetworkChanged: " + e.getMessage()); } } From c07433cee14164f95c7610a582c5af6a4d685d85 Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:25:32 +0000 Subject: [PATCH 5/5] docs: document initialCallbackReceived multi-network limitation When both WiFi and cellular are connected at registration time, only the first onAvailable is skipped. The second fires a spurious notification, which is acceptable since EngineRestarter debounces. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../netbird/client/tool/networks/NetworkChangeDetector.java | 4 ++++ 1 file changed, 4 insertions(+) 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 360ed892..b9edaedf 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 @@ -51,6 +51,10 @@ public void onAvailable(@NonNull Network network) { // Skip the very first onAvailable after registerNetworkCallback(). // Android fires this immediately for each already-connected network — // it is an initial state report, not an actual network change. + // Note: if both WiFi and cellular are connected at registration time, + // Android fires onAvailable for each, but we only skip the first one. + // The second fires a spurious notification, which is acceptable because + // the EngineRestarter debounces rapid network change callbacks anyway. if (!initialCallbackReceived) { initialCallbackReceived = true; Log.d(LOGTAG, "ignoring initial onAvailable (not a real network change)");