From bb9a690ea3966023e37b9ade0b7b1320cd7ec17a Mon Sep 17 00:00:00 2001 From: NoxelFoxel Date: Sun, 26 Apr 2026 11:19:58 +0300 Subject: [PATCH 1/6] home screen widget --- app/src/main/AndroidManifest.xml | 15 +- .../netbird/client/NetbirdWidgetProvider.java | 23 ++ .../netbird/client/NetbirdWidgetUpdater.java | 101 ++++++++ .../client/ui/home/NetworksFragment.java | 11 + .../main/res/drawable/widget_background.xml | 9 + .../main/res/drawable/widget_switch_off.xml | 20 ++ .../main/res/drawable/widget_switch_on.xml | 20 ++ app/src/main/res/layout/widget_netbird.xml | 102 ++++++++ app/src/main/res/values/strings.xml | 5 + app/src/main/res/xml/netbird_widget_info.xml | 12 + .../io/netbird/client/tool/Preferences.java | 42 ++++ .../io/netbird/client/tool/VPNService.java | 221 +++++++++++++++++- 12 files changed, 577 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/io/netbird/client/NetbirdWidgetProvider.java create mode 100644 app/src/main/java/io/netbird/client/NetbirdWidgetUpdater.java create mode 100644 app/src/main/res/drawable/widget_background.xml create mode 100644 app/src/main/res/drawable/widget_switch_off.xml create mode 100644 app/src/main/res/drawable/widget_switch_on.xml create mode 100644 app/src/main/res/layout/widget_netbird.xml create mode 100644 app/src/main/res/xml/netbird_widget_info.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 897d5fdf..7747207e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,6 +55,19 @@ + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/io/netbird/client/NetbirdWidgetProvider.java b/app/src/main/java/io/netbird/client/NetbirdWidgetProvider.java new file mode 100644 index 00000000..b8c6742c --- /dev/null +++ b/app/src/main/java/io/netbird/client/NetbirdWidgetProvider.java @@ -0,0 +1,23 @@ +package io.netbird.client; + +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.Context; +import android.content.Intent; + +import io.netbird.client.tool.VPNService; + +public class NetbirdWidgetProvider extends AppWidgetProvider { + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + NetbirdWidgetUpdater.updateWidgets(context, appWidgetManager, appWidgetIds); + } + + @Override + public void onReceive(Context context, Intent intent) { + super.onReceive(context, intent); + if (intent != null && VPNService.ACTION_WIDGET_REFRESH.equals(intent.getAction())) { + NetbirdWidgetUpdater.updateAllWidgets(context); + } + } +} diff --git a/app/src/main/java/io/netbird/client/NetbirdWidgetUpdater.java b/app/src/main/java/io/netbird/client/NetbirdWidgetUpdater.java new file mode 100644 index 00000000..07d41850 --- /dev/null +++ b/app/src/main/java/io/netbird/client/NetbirdWidgetUpdater.java @@ -0,0 +1,101 @@ +package io.netbird.client; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.widget.RemoteViews; + +import io.netbird.client.tool.Preferences; +import io.netbird.client.tool.VPNService; + +public class NetbirdWidgetUpdater { + private static final int REQUEST_TOGGLE_CONNECTION = 1001; + private static final int REQUEST_TOGGLE_EXIT_NODE = 1002; + + public static void updateAllWidgets(Context context) { + AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context); + ComponentName componentName = new ComponentName(context, NetbirdWidgetProvider.class); + updateWidgets(context, appWidgetManager, appWidgetManager.getAppWidgetIds(componentName)); + } + + public static void updateWidgets(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { + for (int appWidgetId : appWidgetIds) { + appWidgetManager.updateAppWidget(appWidgetId, createRemoteViews(context)); + } + } + + private static RemoteViews createRemoteViews(Context context) { + Preferences preferences = new Preferences(context); + boolean vpnRunning = preferences.isWidgetVpnRunning(); + boolean exitNodeActive = preferences.isWidgetExitNodeActive(); + String exitNodeName = preferences.getWidgetExitNodeName(); + + if (!vpnRunning && isEmpty(exitNodeName)) { + exitNodeName = preferences.getLastExitNodeRoute(); + } + boolean exitNodeAvailable = !isEmpty(exitNodeName); + + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_netbird); + views.setTextViewText(R.id.widget_connection_status, + context.getString(vpnRunning ? R.string.widget_status_connected : R.string.widget_status_disconnected)); + + int connectionColor = context.getColor(vpnRunning ? R.color.nb_orange : R.color.nb_button_inactive); + views.setTextColor(R.id.widget_connection_status, connectionColor); + views.setInt(R.id.widget_connection_icon, "setColorFilter", + context.getColor(vpnRunning ? R.color.nb_orange : R.color.white)); + views.setInt(R.id.widget_connection_switch, "setBackgroundResource", + vpnRunning ? R.drawable.widget_switch_on : R.drawable.widget_switch_off); + + if (!exitNodeAvailable) { + views.setTextViewText(R.id.widget_exit_status, context.getString(R.string.widget_exit_node_unavailable)); + } else { + views.setTextViewText(R.id.widget_exit_status, exitNodeName); + } + + int exitColor = context.getColor(exitNodeActive ? R.color.nb_orange : R.color.nb_button_inactive); + views.setTextColor(R.id.widget_exit_status, exitColor); + views.setInt(R.id.widget_exit_switch, "setBackgroundResource", + exitNodeActive ? R.drawable.widget_switch_on : R.drawable.widget_switch_off); + + PendingIntent connectionIntent = servicePendingIntent( + context, + VPNService.ACTION_WIDGET_TOGGLE_CONNECTION, + REQUEST_TOGGLE_CONNECTION); + PendingIntent exitNodeIntent = servicePendingIntent( + context, + VPNService.ACTION_WIDGET_TOGGLE_EXIT_NODE, + REQUEST_TOGGLE_EXIT_NODE); + + views.setOnClickPendingIntent(R.id.widget_connection_switch, + connectionIntent); + views.setOnClickPendingIntent(R.id.widget_connection_icon, + connectionIntent); + views.setOnClickPendingIntent(R.id.widget_connection_status, + connectionIntent); + views.setOnClickPendingIntent(R.id.widget_exit_switch, + exitNodeIntent); + views.setOnClickPendingIntent(R.id.widget_exit_icon, + exitNodeIntent); + views.setOnClickPendingIntent(R.id.widget_exit_status, + exitNodeIntent); + + return views; + } + + private static boolean isEmpty(String value) { + return value == null || value.trim().isEmpty(); + } + + private static PendingIntent servicePendingIntent(Context context, String action, int requestCode) { + Intent intent = new Intent(context, VPNService.class); + intent.setAction(action); + return PendingIntent.getForegroundService( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + } +} diff --git a/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java index 892528a5..f8f2b1d5 100644 --- a/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java +++ b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java @@ -27,6 +27,7 @@ import io.netbird.client.ServiceAccessor; import io.netbird.client.StateListenerRegistry; import io.netbird.client.databinding.FragmentNetworksBinding; +import io.netbird.client.tool.Preferences; public class NetworksFragment extends Fragment { @@ -141,8 +142,18 @@ private void updateResourcesCounter(List resources) { private void routeSwitchToggleHandler(String route, boolean isChecked) throws Exception { if (isChecked) { model.selectRoute(route); + rememberExitNodeSelection(route); } else { model.deselectRoute(route); } } + + private void rememberExitNodeSelection(String route) { + for (Resource resource : resources) { + if (route.equals(resource.getName()) && resource.isExitNode()) { + new Preferences(requireContext()).setLastExitNodeRoute(route); + return; + } + } + } } diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 00000000..fa841a4d --- /dev/null +++ b/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_switch_off.xml b/app/src/main/res/drawable/widget_switch_off.xml new file mode 100644 index 00000000..1144e9fe --- /dev/null +++ b/app/src/main/res/drawable/widget_switch_off.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_switch_on.xml b/app/src/main/res/drawable/widget_switch_on.xml new file mode 100644 index 00000000..75ebc95c --- /dev/null +++ b/app/src/main/res/drawable/widget_switch_on.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_netbird.xml b/app/src/main/res/layout/widget_netbird.xml new file mode 100644 index 00000000..9b6f66a3 --- /dev/null +++ b/app/src/main/res/layout/widget_netbird.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d6b1f51b..c2e55d9f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,11 @@ Settings NetBird + Toggle NetBird and the last-used exit node + Connected + Disconnected + No exit node + Advanced About Docs diff --git a/app/src/main/res/xml/netbird_widget_info.xml b/app/src/main/res/xml/netbird_widget_info.xml new file mode 100644 index 00000000..5b70dd84 --- /dev/null +++ b/app/src/main/res/xml/netbird_widget_info.xml @@ -0,0 +1,12 @@ + + diff --git a/tool/src/main/java/io/netbird/client/tool/Preferences.java b/tool/src/main/java/io/netbird/client/tool/Preferences.java index ddf57ca5..5cabe9bb 100644 --- a/tool/src/main/java/io/netbird/client/tool/Preferences.java +++ b/tool/src/main/java/io/netbird/client/tool/Preferences.java @@ -8,6 +8,10 @@ public class Preferences { private final String keyTraceLog = "tracelog"; private final String keyForceRelayConnection = "isConnectionForceRelayed"; + private final String keyLastExitNodeRoute = "lastExitNodeRoute"; + private final String keyWidgetVpnRunning = "widgetVpnRunning"; + private final String keyWidgetExitNodeActive = "widgetExitNodeActive"; + private final String keyWidgetExitNodeName = "widgetExitNodeName"; private final SharedPreferences sharedPref; @@ -38,6 +42,44 @@ public void disableForcedRelayConnection() { sharedPref.edit().putBoolean(keyForceRelayConnection, false).apply(); } + public String getLastExitNodeRoute() { + return sharedPref.getString(keyLastExitNodeRoute, null); + } + + public void setLastExitNodeRoute(String route) { + if (route == null || route.trim().isEmpty()) { + sharedPref.edit().remove(keyLastExitNodeRoute).apply(); + return; + } + sharedPref.edit().putString(keyLastExitNodeRoute, route).apply(); + } + + public boolean isWidgetVpnRunning() { + return sharedPref.getBoolean(keyWidgetVpnRunning, false); + } + + public boolean isWidgetExitNodeActive() { + return sharedPref.getBoolean(keyWidgetExitNodeActive, false); + } + + public String getWidgetExitNodeName() { + return sharedPref.getString(keyWidgetExitNodeName, null); + } + + public void setWidgetState(boolean vpnRunning, boolean exitNodeActive, String exitNodeName) { + SharedPreferences.Editor editor = sharedPref.edit() + .putBoolean(keyWidgetVpnRunning, vpnRunning) + .putBoolean(keyWidgetExitNodeActive, exitNodeActive); + + if (exitNodeName == null || exitNodeName.trim().isEmpty()) { + editor.remove(keyWidgetExitNodeName); + } else { + editor.putString(keyWidgetExitNodeName, exitNodeName); + } + + editor.apply(); + } + public static String defaultServer() { return "https://api.netbird.io"; } 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 8494d48d..b2d563ea 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -14,6 +14,9 @@ import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; + import io.netbird.client.tool.networks.ConcreteNetworkAvailabilityListener; import io.netbird.client.tool.networks.NetworkChangeDetector; import io.netbird.gomobile.android.ConnectionListener; @@ -26,12 +29,21 @@ public class VPNService extends android.net.VpnService { private final static String LOGTAG = "service"; public static final String INTENT_ACTION_START = "io.netbird.client.intent.action.START_SERVICE"; public static final String ACTION_STOP_ENGINE = "io.netbird.client.intent.action.STOP_ENGINE"; + public static final String ACTION_WIDGET_TOGGLE_CONNECTION = "io.netbird.client.widget.action.TOGGLE_CONNECTION"; + public static final String ACTION_WIDGET_TOGGLE_EXIT_NODE = "io.netbird.client.widget.action.TOGGLE_EXIT_NODE"; + public static final String ACTION_WIDGET_REFRESH = "io.netbird.client.widget.action.REFRESH"; private static final String INTENT_ALWAYS_ON_START = "android.net.VpnService"; + private static final String EXIT_NODE_NETWORK = "0.0.0.0/0"; + private static final int EXIT_NODE_RETRY_COUNT = 12; + private static final long EXIT_NODE_RETRY_DELAY_MS = 500; + private static final int WIDGET_STATE_REFRESH_COUNT = 12; + private static final long WIDGET_STATE_REFRESH_DELAY_MS = 500; private final IBinder myBinder = new MyLocalBinder(); private EngineRunner engineRunner; private ForegroundNotification fgNotification; private TUNParameters currentTUNParameters; private NetworkChangeNotifier notifier; + private Preferences preferences; private RouteChangeListener listener; @@ -49,12 +61,15 @@ public void onCreate() { var tunAdapter = new IFace(this); var iFaceDiscover = new IFaceDiscover(); - listener = this::queueTUNRenewal; + listener = routes -> { + queueTUNRenewal(routes); + updateWidgetStateAndBroadcast(); + }; notifier = new NetworkChangeNotifier(this); notifier.addRouteChangeListener(listener); - Preferences preferences = new Preferences(this); + preferences = new Preferences(this); // Create profile manager for managing profiles ProfileManagerWrapper profileManager = new ProfileManagerWrapper(this); @@ -113,6 +128,16 @@ public int onStartCommand(@Nullable final Intent intent, final int flags, final if (INTENT_ACTION_START.equals(intent.getAction())) { fgNotification.startForeground(); } + if (ACTION_WIDGET_TOGGLE_CONNECTION.equals(intent.getAction())) { + fgNotification.startForeground(); + handleWidgetConnectionToggle(); + return START_NOT_STICKY; + } + if (ACTION_WIDGET_TOGGLE_EXIT_NODE.equals(intent.getAction())) { + fgNotification.startForeground(); + handleWidgetExitNodeToggle(); + return START_NOT_STICKY; + } return super.onStartCommand(intent, flags, startId); } @@ -272,20 +297,210 @@ public static boolean isUsingAlwaysOnVPN(Context context) { public ServiceStateListener serviceStateListener = new ServiceStateListener() { @Override public void onStarted() { - + updateWidgetStateAndBroadcast(); + scheduleWidgetStateRefresh(); } @Override public void onStopped() { fgNotification.stopForeground(); + updateWidgetStateAndBroadcast(); } @Override public void onError(String msg) { fgNotification.stopForeground(); + updateWidgetStateAndBroadcast(); } }; + private void handleWidgetConnectionToggle() { + if (engineRunner.isRunning()) { + engineRunner.stop(); + updateWidgetStateAndBroadcast(); + return; + } + + if (!ensureVpnPermissionFromWidget()) { + return; + } + + engineRunner.runWithoutAuth(); + updateWidgetStateAndBroadcast(); + } + + private void handleWidgetExitNodeToggle() { + if (!ensureVpnPermissionFromWidget()) { + return; + } + + if (!engineRunner.isRunning()) { + engineRunner.runWithoutAuth(); + } + + new Thread(() -> { + try { + toggleExitNodeWhenAvailable(); + } catch (Exception e) { + Log.e(LOGTAG, "failed to toggle exit node from widget", e); + } finally { + updateWidgetStateAndBroadcast(); + } + }).start(); + } + + private boolean ensureVpnPermissionFromWidget() { + if (VpnService.prepare(this) == null) { + return true; + } + + openMainActivity(); + if (!engineRunner.isRunning()) { + fgNotification.stopForeground(); + stopSelf(); + } + updateWidgetStateAndBroadcast(); + return false; + } + + private void openMainActivity() { + Intent intent = getPackageManager().getLaunchIntentForPackage(getPackageName()); + if (intent == null) { + return; + } + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + } + + private void toggleExitNodeWhenAvailable() throws Exception { + for (int i = 0; i < EXIT_NODE_RETRY_COUNT; i++) { + var exitNodes = getExitNodes(); + if (!exitNodes.isEmpty()) { + toggleExitNode(exitNodes); + return; + } + + try { + Thread.sleep(EXIT_NODE_RETRY_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private void toggleExitNode(List exitNodes) throws Exception { + ExitNode target = findExitNode(exitNodes, preferences.getLastExitNodeRoute()); + if (target == null) { + target = exitNodes.get(0); + } + + boolean shouldSelectTarget = !target.selected; + for (ExitNode exitNode : exitNodes) { + if (exitNode.selected) { + engineRunner.deselectRoute(exitNode.name); + } + } + if (shouldSelectTarget) { + engineRunner.selectRoute(target.name); + } + + preferences.setLastExitNodeRoute(target.name); + } + + private ExitNode findExitNode(List exitNodes, String name) { + if (name == null) { + return null; + } + + for (ExitNode exitNode : exitNodes) { + if (name.equals(exitNode.name)) { + return exitNode; + } + } + return null; + } + + private List getExitNodes() { + List exitNodes = new ArrayList<>(); + if (!engineRunner.isRunning()) { + return exitNodes; + } + + NetworkArray networks = engineRunner.networks(); + for (int i = 0; i < networks.size(); i++) { + var network = networks.get(i); + if (EXIT_NODE_NETWORK.equals(network.getNetwork())) { + exitNodes.add(new ExitNode(network.getName(), network.getIsSelected())); + } + } + + return exitNodes; + } + + private void updateWidgetStateAndBroadcast() { + if (preferences == null || engineRunner == null) { + return; + } + + boolean isRunning = engineRunner.isRunning(); + boolean hasSelectedExitNode = false; + String exitNodeName = preferences.getLastExitNodeRoute(); + + if (isRunning) { + for (ExitNode exitNode : getExitNodes()) { + if (exitNode.selected) { + hasSelectedExitNode = true; + exitNodeName = exitNode.name; + preferences.setLastExitNodeRoute(exitNodeName); + break; + } + } + } + + preferences.setWidgetState(isRunning, hasSelectedExitNode, exitNodeName); + + Intent refreshIntent = new Intent(ACTION_WIDGET_REFRESH); + refreshIntent.setPackage(getPackageName()); + sendBroadcast(refreshIntent); + } + + private void scheduleWidgetStateRefresh() { + new Thread(() -> { + for (int i = 0; i < WIDGET_STATE_REFRESH_COUNT; i++) { + try { + Thread.sleep(WIDGET_STATE_REFRESH_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + updateWidgetStateAndBroadcast(); + + if (!engineRunner.isRunning()) { + return; + } + + for (ExitNode exitNode : getExitNodes()) { + if (exitNode.selected) { + return; + } + } + } + }).start(); + } + + private static class ExitNode { + private final String name; + private final boolean selected; + + ExitNode(String name, boolean selected) { + this.name = name; + this.selected = selected; + } + } + private TUNCreatorLooperThread tunCreator; private void queueTUNRenewal(String routes) { From 8ced40fb955eae90491d55d3b400b50f0736d918 Mon Sep 17 00:00:00 2001 From: NoxelFoxel Date: Mon, 27 Apr 2026 12:08:55 +0300 Subject: [PATCH 2/6] Fix widget review issues --- app/src/main/AndroidManifest.xml | 1 - .../netbird/client/NetbirdWidgetUpdater.java | 37 +++-- .../client/ui/home/NetworksFragment.java | 13 ++ .../main/res/drawable/widget_switch_on.xml | 2 +- app/src/main/res/layout/widget_netbird.xml | 4 +- app/src/main/res/values/strings.xml | 6 +- .../client/tool/ForegroundNotification.java | 75 ++++++--- .../io/netbird/client/tool/Preferences.java | 23 +++ .../client/tool/ProfileManagerWrapper.java | 16 ++ .../io/netbird/client/tool/VPNService.java | 146 +++++++++++++----- tool/src/main/res/values/strings.xml | 2 + 11 files changed, 249 insertions(+), 76 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7747207e..a8aee97d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -62,7 +62,6 @@ android:label="@string/app_name"> - - + diff --git a/app/src/main/res/layout/widget_netbird.xml b/app/src/main/res/layout/widget_netbird.xml index 9b6f66a3..ea236452 100644 --- a/app/src/main/res/layout/widget_netbird.xml +++ b/app/src/main/res/layout/widget_netbird.xml @@ -25,7 +25,7 @@ android:ellipsize="end" android:gravity="center" android:maxLines="1" - android:text="@string/widget_status_disconnected" + android:text="@string/main_status_disconnected" android:textColor="@color/nb_txt" android:textSize="14sp" android:textStyle="bold" /> @@ -51,6 +51,7 @@ android:layout_height="32dp" android:layout_marginStart="12dp" android:background="@drawable/widget_switch_off" + android:contentDescription="@string/widget_connection_switch_disconnected" android:text="" /> @@ -96,6 +97,7 @@ android:layout_height="32dp" android:layout_marginStart="12dp" android:background="@drawable/widget_switch_off" + android:contentDescription="@string/widget_exit_switch_unavailable" android:text="" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c2e55d9f..f360db97 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,8 +10,12 @@ Toggle NetBird and the last-used exit node Connected - Disconnected No exit node + NetBird connected, tap to disconnect + NetBird disconnected, tap to connect + Exit node enabled, tap to disable + Exit node disabled, tap to enable + Exit node unavailable Advanced About 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..55d23c09 100644 --- a/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java +++ b/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java @@ -8,7 +8,6 @@ import android.content.Intent; import android.graphics.Color; import android.net.VpnService; -import android.os.Build; import androidx.core.app.NotificationCompat; @@ -22,36 +21,68 @@ public ForegroundNotification(android.net.VpnService vpnService) { } public void startForeground() { - String channelId = service.getPackageName(); - NotificationChannel channel = new NotificationChannel( - channelId, - service.getResources().getString(R.string.fg_notification_channel_name), - NotificationManager.IMPORTANCE_DEFAULT); - ((NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE)).createNotificationChannel(channel); + service.startForeground( + NOTIFICATION_ID, + buildNotification( + service.getResources().getString(R.string.fg_notification_text), + true)); + } - Intent notificationIntent = new Intent(); - notificationIntent.setClassName("io.netbird.client", "io.netbird.client.MainActivity"); + public void stopForeground() { + service.stopForeground(true); + } - int flags = 0; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - flags = PendingIntent.FLAG_MUTABLE; - } - PendingIntent pendingIntent = PendingIntent.getActivity(service, 0, notificationIntent, flags); + public void showNotification(String text) { + getNotificationManager().notify(NOTIFICATION_ID, buildNotification(text, false)); + } + private Notification buildNotification(String text, boolean ongoing) { + ensureNotificationChannel(); - Notification notification = new NotificationCompat.Builder(service.getApplication(), channelId) + NotificationCompat.Builder builder = new NotificationCompat.Builder( + service.getApplication(), + service.getPackageName()) .setSmallIcon(R.drawable.notification_icon) .setColor(Color.GRAY) .setContentTitle(service.getResources().getString(R.string.service_name)) - .setContentText(service.getResources().getString(R.string.fg_notification_text)) - .setContentIntent(pendingIntent) - .setAutoCancel(false) // Keep notification after tap - .build(); + .setContentText(text) + .setAutoCancel(!ongoing) + .setOngoing(ongoing); + + PendingIntent pendingIntent = createLaunchAppPendingIntent(); + if (pendingIntent != null) { + builder.setContentIntent(pendingIntent); + } - service.startForeground(NOTIFICATION_ID, notification); + return builder.build(); } - public void stopForeground() { - service.stopForeground(true); + private void ensureNotificationChannel() { + NotificationChannel channel = new NotificationChannel( + service.getPackageName(), + service.getResources().getString(R.string.fg_notification_channel_name), + NotificationManager.IMPORTANCE_DEFAULT); + getNotificationManager().createNotificationChannel(channel); + } + + private NotificationManager getNotificationManager() { + return (NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE); + } + + private PendingIntent createLaunchAppPendingIntent() { + Intent notificationIntent = service.getPackageManager().getLaunchIntentForPackage(service.getPackageName()); + if (notificationIntent == null) { + notificationIntent = new Intent(); + notificationIntent.setClassName( + service.getPackageName(), + service.getPackageName() + ".MainActivity"); + } + + notificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + return PendingIntent.getActivity( + service, + 0, + notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } } diff --git a/tool/src/main/java/io/netbird/client/tool/Preferences.java b/tool/src/main/java/io/netbird/client/tool/Preferences.java index 5cabe9bb..f29ec247 100644 --- a/tool/src/main/java/io/netbird/client/tool/Preferences.java +++ b/tool/src/main/java/io/netbird/client/tool/Preferences.java @@ -80,6 +80,29 @@ public void setWidgetState(boolean vpnRunning, boolean exitNodeActive, String ex editor.apply(); } + public void setWidgetStateAndLastExitNodeRoute(String lastExitNodeRoute, + boolean vpnRunning, + boolean exitNodeActive, + String exitNodeName) { + SharedPreferences.Editor editor = sharedPref.edit() + .putBoolean(keyWidgetVpnRunning, vpnRunning) + .putBoolean(keyWidgetExitNodeActive, exitNodeActive); + + if (lastExitNodeRoute == null || lastExitNodeRoute.trim().isEmpty()) { + editor.remove(keyLastExitNodeRoute); + } else { + editor.putString(keyLastExitNodeRoute, lastExitNodeRoute); + } + + if (exitNodeName == null || exitNodeName.trim().isEmpty()) { + editor.remove(keyWidgetExitNodeName); + } else { + editor.putString(keyWidgetExitNodeName, exitNodeName); + } + + editor.apply(); + } + public static String defaultServer() { return "https://api.netbird.io"; } diff --git a/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java b/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java index abbd4699..d961b04d 100644 --- a/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java +++ b/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java @@ -4,6 +4,7 @@ import android.content.Intent; import android.util.Log; +import java.io.File; import java.util.ArrayList; import java.util.List; @@ -128,6 +129,21 @@ public String getActiveStateFilePath() throws Exception { return profileManager.getActiveStateFilePath(); } + public boolean hasUsableActiveProfile() { + try { + String configPath = profileManager.getActiveConfigPath(); + if (configPath == null || configPath.trim().isEmpty()) { + return false; + } + + File configFile = new File(configPath); + return configFile.isFile() && configFile.length() > 0; + } catch (Exception e) { + Log.w(TAG, "No usable active profile is available", e); + return false; + } + } + /** * Stops the VPN engine (disconnects) without stopping the service */ 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 b2d563ea..3aadfd4f 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -16,6 +16,14 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import io.netbird.client.tool.networks.ConcreteNetworkAvailabilityListener; import io.netbird.client.tool.networks.NetworkChangeDetector; @@ -38,12 +46,20 @@ public class VPNService extends android.net.VpnService { private static final long EXIT_NODE_RETRY_DELAY_MS = 500; private static final int WIDGET_STATE_REFRESH_COUNT = 12; private static final long WIDGET_STATE_REFRESH_DELAY_MS = 500; + private static final String WIDGET_PROVIDER_CLASS_NAME = "io.netbird.client.NetbirdWidgetProvider"; private final IBinder myBinder = new MyLocalBinder(); + private final ExecutorService widgetActionExecutor = Executors.newSingleThreadExecutor(); + private final ScheduledExecutorService widgetRefreshExecutor = Executors.newSingleThreadScheduledExecutor(); + private final AtomicBoolean widgetExitToggleInFlight = new AtomicBoolean(false); + private final AtomicBoolean widgetRefreshInFlight = new AtomicBoolean(false); + private final AtomicInteger widgetRefreshIteration = new AtomicInteger(0); + private final AtomicReference> widgetRefreshTask = new AtomicReference<>(); private EngineRunner engineRunner; private ForegroundNotification fgNotification; private TUNParameters currentTUNParameters; private NetworkChangeNotifier notifier; private Preferences preferences; + private ProfileManagerWrapper profileManager; private RouteChangeListener listener; @@ -72,7 +88,7 @@ public void onCreate() { preferences = new Preferences(this); // Create profile manager for managing profiles - ProfileManagerWrapper profileManager = new ProfileManagerWrapper(this); + profileManager = new ProfileManagerWrapper(this); // Create foreground notification before initializing engine fgNotification = new ForegroundNotification(this); @@ -186,6 +202,10 @@ public void onDestroy() { tunCreator.getHandler().getLooper().quitSafely(); tunCreator = null; } + + stopWidgetStateRefresh(); + widgetActionExecutor.shutdownNow(); + widgetRefreshExecutor.shutdownNow(); } @Override @@ -303,12 +323,14 @@ public void onStarted() { @Override public void onStopped() { + stopWidgetStateRefresh(); fgNotification.stopForeground(); updateWidgetStateAndBroadcast(); } @Override public void onError(String msg) { + stopWidgetStateRefresh(); fgNotification.stopForeground(); updateWidgetStateAndBroadcast(); } @@ -321,6 +343,11 @@ private void handleWidgetConnectionToggle() { return; } + if (!hasUsableActiveProfile()) { + promptUserToOpenApp(R.string.widget_open_app_setup_text); + return; + } + if (!ensureVpnPermissionFromWidget()) { return; } @@ -330,6 +357,11 @@ private void handleWidgetConnectionToggle() { } private void handleWidgetExitNodeToggle() { + if (!engineRunner.isRunning() && !hasUsableActiveProfile()) { + promptUserToOpenApp(R.string.widget_open_app_setup_text); + return; + } + if (!ensureVpnPermissionFromWidget()) { return; } @@ -338,15 +370,20 @@ private void handleWidgetExitNodeToggle() { engineRunner.runWithoutAuth(); } - new Thread(() -> { + if (!widgetExitToggleInFlight.compareAndSet(false, true)) { + return; + } + + widgetActionExecutor.execute(() -> { try { toggleExitNodeWhenAvailable(); } catch (Exception e) { Log.e(LOGTAG, "failed to toggle exit node from widget", e); } finally { + widgetExitToggleInFlight.set(false); updateWidgetStateAndBroadcast(); } - }).start(); + }); } private boolean ensureVpnPermissionFromWidget() { @@ -354,23 +391,22 @@ private boolean ensureVpnPermissionFromWidget() { return true; } - openMainActivity(); - if (!engineRunner.isRunning()) { - fgNotification.stopForeground(); - stopSelf(); - } + promptUserToOpenApp(R.string.widget_open_app_permission_text); updateWidgetStateAndBroadcast(); return false; } - private void openMainActivity() { - Intent intent = getPackageManager().getLaunchIntentForPackage(getPackageName()); - if (intent == null) { - return; - } + private boolean hasUsableActiveProfile() { + return profileManager != null && profileManager.hasUsableActiveProfile(); + } - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); - startActivity(intent); + private void promptUserToOpenApp(int messageResId) { + fgNotification.stopForeground(); + fgNotification.showNotification(getString(messageResId)); + + if (!engineRunner.isRunning()) { + stopSelf(); + } } private void toggleExitNodeWhenAvailable() throws Exception { @@ -405,8 +441,6 @@ private void toggleExitNode(List exitNodes) throws Exception { if (shouldSelectTarget) { engineRunner.selectRoute(target.name); } - - preferences.setLastExitNodeRoute(target.name); } private ExitNode findExitNode(List exitNodes, String name) { @@ -439,56 +473,90 @@ private List getExitNodes() { return exitNodes; } - private void updateWidgetStateAndBroadcast() { + private synchronized void updateWidgetStateAndBroadcast() { if (preferences == null || engineRunner == null) { return; } boolean isRunning = engineRunner.isRunning(); boolean hasSelectedExitNode = false; - String exitNodeName = preferences.getLastExitNodeRoute(); + String lastExitNodeRoute = preferences.getLastExitNodeRoute(); + String exitNodeName = lastExitNodeRoute; if (isRunning) { for (ExitNode exitNode : getExitNodes()) { if (exitNode.selected) { hasSelectedExitNode = true; exitNodeName = exitNode.name; - preferences.setLastExitNodeRoute(exitNodeName); + lastExitNodeRoute = exitNode.name; break; } } } - preferences.setWidgetState(isRunning, hasSelectedExitNode, exitNodeName); + preferences.setWidgetStateAndLastExitNodeRoute( + lastExitNodeRoute, + isRunning, + hasSelectedExitNode, + exitNodeName); Intent refreshIntent = new Intent(ACTION_WIDGET_REFRESH); - refreshIntent.setPackage(getPackageName()); + refreshIntent.setClassName(getPackageName(), WIDGET_PROVIDER_CLASS_NAME); sendBroadcast(refreshIntent); } - private void scheduleWidgetStateRefresh() { - new Thread(() -> { - for (int i = 0; i < WIDGET_STATE_REFRESH_COUNT; i++) { - try { - Thread.sleep(WIDGET_STATE_REFRESH_DELAY_MS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } + private synchronized void scheduleWidgetStateRefresh() { + if (!widgetRefreshInFlight.compareAndSet(false, true)) { + return; + } + widgetRefreshIteration.set(0); + AtomicReference> scheduledTaskRef = new AtomicReference<>(); + ScheduledFuture refreshTask = widgetRefreshExecutor.scheduleWithFixedDelay(() -> { + try { + int iteration = widgetRefreshIteration.incrementAndGet(); updateWidgetStateAndBroadcast(); - if (!engineRunner.isRunning()) { - return; + if (iteration >= WIDGET_STATE_REFRESH_COUNT + || !engineRunner.isRunning() + || hasSelectedExitNode()) { + stopWidgetStateRefresh(scheduledTaskRef.get()); } + } catch (Exception e) { + Log.e(LOGTAG, "failed to refresh widget state", e); + stopWidgetStateRefresh(scheduledTaskRef.get()); + } + }, WIDGET_STATE_REFRESH_DELAY_MS, WIDGET_STATE_REFRESH_DELAY_MS, TimeUnit.MILLISECONDS); + scheduledTaskRef.set(refreshTask); + widgetRefreshTask.set(refreshTask); + } - for (ExitNode exitNode : getExitNodes()) { - if (exitNode.selected) { - return; - } - } + private boolean hasSelectedExitNode() { + for (ExitNode exitNode : getExitNodes()) { + if (exitNode.selected) { + return true; } - }).start(); + } + return false; + } + + private synchronized void stopWidgetStateRefresh() { + ScheduledFuture refreshTask = widgetRefreshTask.getAndSet(null); + if (refreshTask != null) { + refreshTask.cancel(false); + } + widgetRefreshInFlight.set(false); + } + + private synchronized void stopWidgetStateRefresh(@Nullable ScheduledFuture expectedTask) { + if (expectedTask == null) { + return; + } + + if (widgetRefreshTask.compareAndSet(expectedTask, null)) { + expectedTask.cancel(false); + widgetRefreshInFlight.set(false); + } } private static class ExitNode { diff --git a/tool/src/main/res/values/strings.xml b/tool/src/main/res/values/strings.xml index f8da0b8d..0eda7d0d 100644 --- a/tool/src/main/res/values/strings.xml +++ b/tool/src/main/res/values/strings.xml @@ -2,4 +2,6 @@ NetBird NetBird service Service is running + Open NetBird to grant VPN permission for the widget + Open NetBird to finish setup before using the widget From c48194b3cc14f13478a72f54874849a7c68b7b7b Mon Sep 17 00:00:00 2001 From: NoxelFoxel Date: Mon, 27 Apr 2026 13:23:27 +0300 Subject: [PATCH 3/6] Fix: harden widget notification and refresh handling --- .../client/tool/ForegroundNotification.java | 29 +++++-- .../io/netbird/client/tool/VPNService.java | 75 +++++++++++++------ 2 files changed, 76 insertions(+), 28 deletions(-) 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 55d23c09..23c290bf 100644 --- a/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java +++ b/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java @@ -13,11 +13,13 @@ class ForegroundNotification { private static final int NOTIFICATION_ID = 102; + private static final int PROMPT_NOTIFICATION_ID = 103; private final VpnService service; public ForegroundNotification(android.net.VpnService vpnService) { this.service = vpnService; + ensureNotificationChannel(); } public void startForeground() { @@ -33,12 +35,10 @@ public void stopForeground() { } public void showNotification(String text) { - getNotificationManager().notify(NOTIFICATION_ID, buildNotification(text, false)); + getNotificationManager().notify(PROMPT_NOTIFICATION_ID, buildNotification(text, false)); } private Notification buildNotification(String text, boolean ongoing) { - ensureNotificationChannel(); - NotificationCompat.Builder builder = new NotificationCompat.Builder( service.getApplication(), service.getPackageName()) @@ -72,10 +72,25 @@ private NotificationManager getNotificationManager() { private PendingIntent createLaunchAppPendingIntent() { Intent notificationIntent = service.getPackageManager().getLaunchIntentForPackage(service.getPackageName()); if (notificationIntent == null) { - notificationIntent = new Intent(); - notificationIntent.setClassName( - service.getPackageName(), - service.getPackageName() + ".MainActivity"); + Intent launcherIntent = new Intent(Intent.ACTION_MAIN); + launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER); + launcherIntent.setPackage(service.getPackageName()); + if (launcherIntent.resolveActivity(service.getPackageManager()) != null) { + notificationIntent = launcherIntent; + } + } + + if (notificationIntent == null) { + Intent mainActivityIntent = new Intent(Intent.ACTION_MAIN); + mainActivityIntent.addCategory(Intent.CATEGORY_LAUNCHER); + mainActivityIntent.setClassName(service.getPackageName(), "io.netbird.client.MainActivity"); + if (mainActivityIntent.resolveActivity(service.getPackageManager()) != null) { + notificationIntent = mainActivityIntent; + } + } + + if (notificationIntent == null) { + return null; } notificationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); 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 3aadfd4f..33c092c5 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -1,18 +1,23 @@ package io.netbird.client.tool; +import android.Manifest; import android.app.Activity; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; import android.net.ConnectivityManager; import android.net.Network; import android.net.NetworkCapabilities; import android.net.VpnService; import android.os.Binder; +import android.os.Build; import android.os.IBinder; import android.os.Parcel; import android.util.Log; +import android.widget.Toast; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import java.util.ArrayList; import java.util.List; @@ -42,10 +47,10 @@ public class VPNService extends android.net.VpnService { public static final String ACTION_WIDGET_REFRESH = "io.netbird.client.widget.action.REFRESH"; private static final String INTENT_ALWAYS_ON_START = "android.net.VpnService"; private static final String EXIT_NODE_NETWORK = "0.0.0.0/0"; - private static final int EXIT_NODE_RETRY_COUNT = 12; - private static final long EXIT_NODE_RETRY_DELAY_MS = 500; private static final int WIDGET_STATE_REFRESH_COUNT = 12; private static final long WIDGET_STATE_REFRESH_DELAY_MS = 500; + private static final int EXIT_NODE_RETRY_COUNT = WIDGET_STATE_REFRESH_COUNT * 2; + private static final long EXIT_NODE_RETRY_DELAY_MS = 500; private static final String WIDGET_PROVIDER_CLASS_NAME = "io.netbird.client.NetbirdWidgetProvider"; private final IBinder myBinder = new MyLocalBinder(); private final ExecutorService widgetActionExecutor = Executors.newSingleThreadExecutor(); @@ -402,13 +407,28 @@ private boolean hasUsableActiveProfile() { private void promptUserToOpenApp(int messageResId) { fgNotification.stopForeground(); - fgNotification.showNotification(getString(messageResId)); + String message = getString(messageResId); + if (canPostNotifications()) { + fgNotification.showNotification(message); + } else { + Log.w(LOGTAG, "POST_NOTIFICATIONS is not granted; falling back to Toast for widget prompt"); + Toast.makeText(this, message, Toast.LENGTH_LONG).show(); + } if (!engineRunner.isRunning()) { stopSelf(); } } + private boolean canPostNotifications() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return true; + } + + return ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED; + } + private void toggleExitNodeWhenAvailable() throws Exception { for (int i = 0; i < EXIT_NODE_RETRY_COUNT; i++) { var exitNodes = getExitNodes(); @@ -458,29 +478,40 @@ private ExitNode findExitNode(List exitNodes, String name) { private List getExitNodes() { List exitNodes = new ArrayList<>(); - if (!engineRunner.isRunning()) { + EngineRunner currentEngineRunner = engineRunner; + if (currentEngineRunner == null || !currentEngineRunner.isRunning()) { return exitNodes; } - NetworkArray networks = engineRunner.networks(); - for (int i = 0; i < networks.size(); i++) { - var network = networks.get(i); - if (EXIT_NODE_NETWORK.equals(network.getNetwork())) { - exitNodes.add(new ExitNode(network.getName(), network.getIsSelected())); + try { + NetworkArray networks = currentEngineRunner.networks(); + if (networks == null) { + return exitNodes; + } + + for (int i = 0; i < networks.size(); i++) { + var network = networks.get(i); + if (network != null && EXIT_NODE_NETWORK.equals(network.getNetwork())) { + exitNodes.add(new ExitNode(network.getName(), network.getIsSelected())); + } } + } catch (Exception e) { + Log.w(LOGTAG, "failed to fetch exit nodes from engine", e); } return exitNodes; } - private synchronized void updateWidgetStateAndBroadcast() { - if (preferences == null || engineRunner == null) { + private void updateWidgetStateAndBroadcast() { + Preferences currentPreferences = preferences; + EngineRunner currentEngineRunner = engineRunner; + if (currentPreferences == null || currentEngineRunner == null) { return; } - boolean isRunning = engineRunner.isRunning(); + boolean isRunning = currentEngineRunner.isRunning(); boolean hasSelectedExitNode = false; - String lastExitNodeRoute = preferences.getLastExitNodeRoute(); + String lastExitNodeRoute = currentPreferences.getLastExitNodeRoute(); String exitNodeName = lastExitNodeRoute; if (isRunning) { @@ -494,15 +525,17 @@ private synchronized void updateWidgetStateAndBroadcast() { } } - preferences.setWidgetStateAndLastExitNodeRoute( - lastExitNodeRoute, - isRunning, - hasSelectedExitNode, - exitNodeName); + synchronized (this) { + currentPreferences.setWidgetStateAndLastExitNodeRoute( + lastExitNodeRoute, + isRunning, + hasSelectedExitNode, + exitNodeName); - Intent refreshIntent = new Intent(ACTION_WIDGET_REFRESH); - refreshIntent.setClassName(getPackageName(), WIDGET_PROVIDER_CLASS_NAME); - sendBroadcast(refreshIntent); + Intent refreshIntent = new Intent(ACTION_WIDGET_REFRESH); + refreshIntent.setClassName(getPackageName(), WIDGET_PROVIDER_CLASS_NAME); + sendBroadcast(refreshIntent); + } } private synchronized void scheduleWidgetStateRefresh() { From 1e3095df953545e0ea3a798efc987290e34dc9cb Mon Sep 17 00:00:00 2001 From: NoxelFoxel Date: Mon, 27 Apr 2026 13:38:31 +0300 Subject: [PATCH 4/6] Fix: serialize widget state updates --- .../io/netbird/client/tool/VPNService.java | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 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 33c092c5..f2ef3be5 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -54,6 +55,7 @@ public class VPNService extends android.net.VpnService { private static final String WIDGET_PROVIDER_CLASS_NAME = "io.netbird.client.NetbirdWidgetProvider"; private final IBinder myBinder = new MyLocalBinder(); private final ExecutorService widgetActionExecutor = Executors.newSingleThreadExecutor(); + private final ExecutorService widgetStateExecutor = Executors.newSingleThreadExecutor(); private final ScheduledExecutorService widgetRefreshExecutor = Executors.newSingleThreadScheduledExecutor(); private final AtomicBoolean widgetExitToggleInFlight = new AtomicBoolean(false); private final AtomicBoolean widgetRefreshInFlight = new AtomicBoolean(false); @@ -84,7 +86,7 @@ public void onCreate() { listener = routes -> { queueTUNRenewal(routes); - updateWidgetStateAndBroadcast(); + requestWidgetStateUpdate(); }; notifier = new NetworkChangeNotifier(this); @@ -210,6 +212,7 @@ public void onDestroy() { stopWidgetStateRefresh(); widgetActionExecutor.shutdownNow(); + widgetStateExecutor.shutdownNow(); widgetRefreshExecutor.shutdownNow(); } @@ -322,7 +325,7 @@ public static boolean isUsingAlwaysOnVPN(Context context) { public ServiceStateListener serviceStateListener = new ServiceStateListener() { @Override public void onStarted() { - updateWidgetStateAndBroadcast(); + requestWidgetStateUpdate(); scheduleWidgetStateRefresh(); } @@ -330,21 +333,21 @@ public void onStarted() { public void onStopped() { stopWidgetStateRefresh(); fgNotification.stopForeground(); - updateWidgetStateAndBroadcast(); + requestWidgetStateUpdate(); } @Override public void onError(String msg) { stopWidgetStateRefresh(); fgNotification.stopForeground(); - updateWidgetStateAndBroadcast(); + requestWidgetStateUpdate(); } }; private void handleWidgetConnectionToggle() { if (engineRunner.isRunning()) { engineRunner.stop(); - updateWidgetStateAndBroadcast(); + requestWidgetStateUpdate(); return; } @@ -358,7 +361,7 @@ private void handleWidgetConnectionToggle() { } engineRunner.runWithoutAuth(); - updateWidgetStateAndBroadcast(); + requestWidgetStateUpdate(); } private void handleWidgetExitNodeToggle() { @@ -386,7 +389,7 @@ private void handleWidgetExitNodeToggle() { Log.e(LOGTAG, "failed to toggle exit node from widget", e); } finally { widgetExitToggleInFlight.set(false); - updateWidgetStateAndBroadcast(); + requestWidgetStateUpdate(); } }); } @@ -397,10 +400,22 @@ private boolean ensureVpnPermissionFromWidget() { } promptUserToOpenApp(R.string.widget_open_app_permission_text); - updateWidgetStateAndBroadcast(); + requestWidgetStateUpdate(); return false; } + private void requestWidgetStateUpdate() { + if (widgetStateExecutor.isShutdown()) { + return; + } + + try { + widgetStateExecutor.execute(this::updateWidgetStateAndBroadcast); + } catch (RejectedExecutionException e) { + Log.w(LOGTAG, "widget state update was rejected", e); + } + } + private boolean hasUsableActiveProfile() { return profileManager != null && profileManager.hasUsableActiveProfile(); } @@ -548,7 +563,7 @@ private synchronized void scheduleWidgetStateRefresh() { ScheduledFuture refreshTask = widgetRefreshExecutor.scheduleWithFixedDelay(() -> { try { int iteration = widgetRefreshIteration.incrementAndGet(); - updateWidgetStateAndBroadcast(); + requestWidgetStateUpdate(); if (iteration >= WIDGET_STATE_REFRESH_COUNT || !engineRunner.isRunning() From 928ddb0c680b564104098a7747d949db9f212731 Mon Sep 17 00:00:00 2001 From: NoxelFoxel Date: Wed, 29 Apr 2026 19:21:59 +0300 Subject: [PATCH 5/6] Fix: widget exit node state handling --- .../java/io/netbird/client/MyApplication.java | 25 +++- .../netbird/client/NetbirdWidgetUpdater.java | 10 +- .../client/ui/home/NetworksFragment.java | 24 ---- .../io/netbird/client/tool/Preferences.java | 40 +----- .../client/tool/ProfileManagerWrapper.java | 22 +++ .../io/netbird/client/tool/VPNService.java | 125 +++++++++++++++--- 6 files changed, 161 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/io/netbird/client/MyApplication.java b/app/src/main/java/io/netbird/client/MyApplication.java index ea2493c1..84bc00fa 100644 --- a/app/src/main/java/io/netbird/client/MyApplication.java +++ b/app/src/main/java/io/netbird/client/MyApplication.java @@ -5,14 +5,37 @@ import androidx.appcompat.app.AppCompatDelegate; +import io.netbird.client.tool.Preferences; + public class MyApplication extends Application { @Override public void onCreate() { super.onCreate(); + registerWidgetCrashCleanup(); // Set Theme at start SharedPreferences prefs = getSharedPreferences("settings", MODE_PRIVATE); int themeMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); AppCompatDelegate.setDefaultNightMode(themeMode); } -} \ No newline at end of file + + private void registerWidgetCrashCleanup() { + Thread.UncaughtExceptionHandler previousHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + try { + new Preferences(this).clearWidgetState(); + NetbirdWidgetUpdater.updateAllWidgets(this); + } catch (Exception ignored) { + // Keep the original crash handling path intact. + } + + if (previousHandler != null) { + previousHandler.uncaughtException(thread, throwable); + return; + } + + android.os.Process.killProcess(android.os.Process.myPid()); + System.exit(10); + }); + } +} diff --git a/app/src/main/java/io/netbird/client/NetbirdWidgetUpdater.java b/app/src/main/java/io/netbird/client/NetbirdWidgetUpdater.java index 37d08ec8..a883fc72 100644 --- a/app/src/main/java/io/netbird/client/NetbirdWidgetUpdater.java +++ b/app/src/main/java/io/netbird/client/NetbirdWidgetUpdater.java @@ -31,9 +31,11 @@ private static RemoteViews createRemoteViews(Context context) { boolean vpnRunning = preferences.isWidgetVpnRunning(); boolean exitNodeActive = preferences.isWidgetExitNodeActive(); String exitNodeName = preferences.getWidgetExitNodeName(); - - if (!vpnRunning && isEmpty(exitNodeName)) { - exitNodeName = preferences.getLastExitNodeRoute(); + if (vpnRunning && !VPNService.isServiceRunning(context)) { + preferences.clearWidgetState(); + vpnRunning = false; + exitNodeActive = false; + exitNodeName = null; } boolean exitNodeAvailable = !isEmpty(exitNodeName); @@ -83,7 +85,7 @@ private static RemoteViews createRemoteViews(Context context) { views.setOnClickPendingIntent(R.id.widget_connection_status, connectionIntent); - if (exitNodeAvailable) { + if (exitNodeAvailable || !vpnRunning) { PendingIntent exitNodeIntent = servicePendingIntent( context, VPNService.ACTION_WIDGET_TOGGLE_EXIT_NODE, diff --git a/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java index e2a8fb52..892528a5 100644 --- a/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java +++ b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java @@ -27,7 +27,6 @@ import io.netbird.client.ServiceAccessor; import io.netbird.client.StateListenerRegistry; import io.netbird.client.databinding.FragmentNetworksBinding; -import io.netbird.client.tool.Preferences; public class NetworksFragment extends Fragment { @@ -142,31 +141,8 @@ private void updateResourcesCounter(List resources) { private void routeSwitchToggleHandler(String route, boolean isChecked) throws Exception { if (isChecked) { model.selectRoute(route); - rememberExitNodeSelection(route); } else { model.deselectRoute(route); - forgetExitNodeSelection(route); - } - } - - private void rememberExitNodeSelection(String route) { - for (Resource resource : resources) { - if (route.equals(resource.getName()) && resource.isExitNode()) { - new Preferences(requireContext()).setLastExitNodeRoute(route); - return; - } - } - } - - private void forgetExitNodeSelection(String route) { - for (Resource resource : resources) { - if (route.equals(resource.getName()) && resource.isExitNode()) { - Preferences preferences = new Preferences(requireContext()); - if (route.equals(preferences.getLastExitNodeRoute())) { - preferences.setLastExitNodeRoute(""); - } - return; - } } } } diff --git a/tool/src/main/java/io/netbird/client/tool/Preferences.java b/tool/src/main/java/io/netbird/client/tool/Preferences.java index f29ec247..3db41714 100644 --- a/tool/src/main/java/io/netbird/client/tool/Preferences.java +++ b/tool/src/main/java/io/netbird/client/tool/Preferences.java @@ -8,7 +8,6 @@ public class Preferences { private final String keyTraceLog = "tracelog"; private final String keyForceRelayConnection = "isConnectionForceRelayed"; - private final String keyLastExitNodeRoute = "lastExitNodeRoute"; private final String keyWidgetVpnRunning = "widgetVpnRunning"; private final String keyWidgetExitNodeActive = "widgetExitNodeActive"; private final String keyWidgetExitNodeName = "widgetExitNodeName"; @@ -42,18 +41,6 @@ public void disableForcedRelayConnection() { sharedPref.edit().putBoolean(keyForceRelayConnection, false).apply(); } - public String getLastExitNodeRoute() { - return sharedPref.getString(keyLastExitNodeRoute, null); - } - - public void setLastExitNodeRoute(String route) { - if (route == null || route.trim().isEmpty()) { - sharedPref.edit().remove(keyLastExitNodeRoute).apply(); - return; - } - sharedPref.edit().putString(keyLastExitNodeRoute, route).apply(); - } - public boolean isWidgetVpnRunning() { return sharedPref.getBoolean(keyWidgetVpnRunning, false); } @@ -80,27 +67,12 @@ public void setWidgetState(boolean vpnRunning, boolean exitNodeActive, String ex editor.apply(); } - public void setWidgetStateAndLastExitNodeRoute(String lastExitNodeRoute, - boolean vpnRunning, - boolean exitNodeActive, - String exitNodeName) { - SharedPreferences.Editor editor = sharedPref.edit() - .putBoolean(keyWidgetVpnRunning, vpnRunning) - .putBoolean(keyWidgetExitNodeActive, exitNodeActive); - - if (lastExitNodeRoute == null || lastExitNodeRoute.trim().isEmpty()) { - editor.remove(keyLastExitNodeRoute); - } else { - editor.putString(keyLastExitNodeRoute, lastExitNodeRoute); - } - - if (exitNodeName == null || exitNodeName.trim().isEmpty()) { - editor.remove(keyWidgetExitNodeName); - } else { - editor.putString(keyWidgetExitNodeName, exitNodeName); - } - - editor.apply(); + public void clearWidgetState() { + sharedPref.edit() + .putBoolean(keyWidgetVpnRunning, false) + .putBoolean(keyWidgetExitNodeActive, false) + .remove(keyWidgetExitNodeName) + .commit(); } public static String defaultServer() { diff --git a/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java b/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java index d961b04d..4aec079a 100644 --- a/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java +++ b/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java @@ -72,6 +72,7 @@ public void switchProfile(String profileName) throws Exception { stopEngine(); profileManager.switchProfile(profileName); + clearWidgetDisplayState(); } /** @@ -101,6 +102,9 @@ public void logoutProfile(String profileName) throws Exception { } profileManager.logoutProfile(profileName); + if (activeProfile.equals(profileName)) { + clearWidgetDisplayState(); + } } /** @@ -110,7 +114,16 @@ public void removeProfile(String profileName) throws Exception { if (profileName == null || profileName.trim().isEmpty()) { throw new IllegalArgumentException("Profile name cannot be empty"); } + String activeProfile = getActiveProfile(); + boolean removingActiveProfile = activeProfile.equals(profileName); + if (removingActiveProfile) { + stopEngine(); + } + profileManager.removeProfile(profileName); + if (removingActiveProfile) { + clearWidgetDisplayState(); + } } /** @@ -158,4 +171,13 @@ private void stopEngine() { // Don't throw exception - profile operations should continue even if stop fails } } + + private void clearWidgetDisplayState() { + try { + new Preferences(context).clearWidgetState(); + VPNService.sendWidgetRefreshBroadcast(context); + } catch (Exception e) { + Log.w(TAG, "Failed to clear widget display state", e); + } + } } 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 f2ef3be5..5bb99135 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -2,6 +2,7 @@ import android.Manifest; import android.app.Activity; +import android.app.ActivityManager; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; @@ -61,6 +62,7 @@ public class VPNService extends android.net.VpnService { private final AtomicBoolean widgetRefreshInFlight = new AtomicBoolean(false); private final AtomicInteger widgetRefreshIteration = new AtomicInteger(0); private final AtomicReference> widgetRefreshTask = new AtomicReference<>(); + private volatile String lastSelectedExitNodeName; private EngineRunner engineRunner; private ForegroundNotification fgNotification; private TUNParameters currentTUNParameters; @@ -122,6 +124,7 @@ public void onCreate() { public void onReceive(Context context, Intent intent) { if (ACTION_STOP_ENGINE.equals(intent.getAction())) { Log.d(LOGTAG, "Received stop engine broadcast"); + lastSelectedExitNodeName = null; if (engineRunner != null) { engineRunner.stop(); } @@ -200,6 +203,7 @@ public void onDestroy() { engineRunner.stop(); stopForeground(true); + clearWidgetStateAndBroadcast(); if (this.notifier != null) { this.notifier.removeRouteChangeListener(listener); @@ -222,6 +226,7 @@ public void onRevoke() { if (engineRunner != null) { engineRunner.stop(); stopForeground(true); + clearWidgetStateAndBroadcast(); } } @@ -322,6 +327,28 @@ public static boolean isUsingAlwaysOnVPN(Context context) { return false; } + public static void sendWidgetRefreshBroadcast(Context context) { + Intent refreshIntent = new Intent(ACTION_WIDGET_REFRESH); + refreshIntent.setClassName(context.getPackageName(), WIDGET_PROVIDER_CLASS_NAME); + context.sendBroadcast(refreshIntent); + } + + public static boolean isServiceRunning(Context context) { + ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + return false; + } + + for (ActivityManager.RunningServiceInfo service + : activityManager.getRunningServices(Integer.MAX_VALUE)) { + if (VPNService.class.getName().equals(service.service.getClassName())) { + return true; + } + } + return false; + } + public ServiceStateListener serviceStateListener = new ServiceStateListener() { @Override public void onStarted() { @@ -365,7 +392,8 @@ private void handleWidgetConnectionToggle() { } private void handleWidgetExitNodeToggle() { - if (!engineRunner.isRunning() && !hasUsableActiveProfile()) { + boolean isRunning = engineRunner.isRunning(); + if (!isRunning && !hasUsableActiveProfile()) { promptUserToOpenApp(R.string.widget_open_app_setup_text); return; } @@ -374,17 +402,28 @@ private void handleWidgetExitNodeToggle() { return; } - if (!engineRunner.isRunning()) { + if (!isRunning) { engineRunner.runWithoutAuth(); + requestWidgetStateUpdate(); + runExitNodeActionWhenAvailable(true); + return; } + runExitNodeActionWhenAvailable(false); + } + + private void runExitNodeActionWhenAvailable(boolean enableOnly) { if (!widgetExitToggleInFlight.compareAndSet(false, true)) { return; } widgetActionExecutor.execute(() -> { try { - toggleExitNodeWhenAvailable(); + if (enableOnly) { + enableExitNodeWhenAvailable(); + } else { + toggleExitNodeWhenAvailable(); + } } catch (Exception e) { Log.e(LOGTAG, "failed to toggle exit node from widget", e); } finally { @@ -461,21 +500,53 @@ private void toggleExitNodeWhenAvailable() throws Exception { } } - private void toggleExitNode(List exitNodes) throws Exception { - ExitNode target = findExitNode(exitNodes, preferences.getLastExitNodeRoute()); - if (target == null) { - target = exitNodes.get(0); + private void enableExitNodeWhenAvailable() throws Exception { + for (int i = 0; i < EXIT_NODE_RETRY_COUNT; i++) { + var exitNodes = getExitNodes(); + if (!exitNodes.isEmpty()) { + enableExitNode(exitNodes); + return; + } + + try { + Thread.sleep(EXIT_NODE_RETRY_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } } + } - boolean shouldSelectTarget = !target.selected; + private void toggleExitNode(List exitNodes) throws Exception { + boolean deselectedSelectedExitNode = false; for (ExitNode exitNode : exitNodes) { if (exitNode.selected) { + lastSelectedExitNodeName = exitNode.name; engineRunner.deselectRoute(exitNode.name); + deselectedSelectedExitNode = true; + } + } + + if (!deselectedSelectedExitNode) { + enableExitNode(exitNodes); + } + } + + private void enableExitNode(List exitNodes) throws Exception { + for (ExitNode exitNode : exitNodes) { + if (exitNode.selected) { + lastSelectedExitNodeName = exitNode.name; + return; } } - if (shouldSelectTarget) { - engineRunner.selectRoute(target.name); + + ExitNode target = findExitNode(exitNodes, lastSelectedExitNodeName); + if (target == null) { + target = exitNodes.get(0); } + + engineRunner.selectRoute(target.name); + lastSelectedExitNodeName = target.name; } private ExitNode findExitNode(List exitNodes, String name) { @@ -526,31 +597,41 @@ private void updateWidgetStateAndBroadcast() { boolean isRunning = currentEngineRunner.isRunning(); boolean hasSelectedExitNode = false; - String lastExitNodeRoute = currentPreferences.getLastExitNodeRoute(); - String exitNodeName = lastExitNodeRoute; + String exitNodeName = null; if (isRunning) { - for (ExitNode exitNode : getExitNodes()) { + List exitNodes = getExitNodes(); + for (ExitNode exitNode : exitNodes) { if (exitNode.selected) { hasSelectedExitNode = true; exitNodeName = exitNode.name; - lastExitNodeRoute = exitNode.name; + lastSelectedExitNodeName = exitNode.name; break; } } + if (!hasSelectedExitNode && !exitNodes.isEmpty()) { + ExitNode displayNode = findExitNode(exitNodes, lastSelectedExitNodeName); + if (displayNode == null) { + displayNode = exitNodes.get(0); + } + exitNodeName = displayNode.name; + } } synchronized (this) { - currentPreferences.setWidgetStateAndLastExitNodeRoute( - lastExitNodeRoute, - isRunning, - hasSelectedExitNode, - exitNodeName); + currentPreferences.setWidgetState(isRunning, hasSelectedExitNode, exitNodeName); + sendWidgetRefreshBroadcast(this); + } + } - Intent refreshIntent = new Intent(ACTION_WIDGET_REFRESH); - refreshIntent.setClassName(getPackageName(), WIDGET_PROVIDER_CLASS_NAME); - sendBroadcast(refreshIntent); + private void clearWidgetStateAndBroadcast() { + lastSelectedExitNodeName = null; + Preferences currentPreferences = preferences; + if (currentPreferences == null) { + currentPreferences = new Preferences(this); } + currentPreferences.clearWidgetState(); + sendWidgetRefreshBroadcast(this); } private synchronized void scheduleWidgetStateRefresh() { From 9844d184a8bdee6cdd17f7032b10bfa3ddfb5ecd Mon Sep 17 00:00:00 2001 From: NoxelFoxel Date: Wed, 29 Apr 2026 20:04:48 +0300 Subject: [PATCH 6/6] Fix CodeRabbit widget review issues --- .../java/io/netbird/client/MyApplication.java | 23 ------------------- .../client/tool/ProfileManagerWrapper.java | 5 +--- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/app/src/main/java/io/netbird/client/MyApplication.java b/app/src/main/java/io/netbird/client/MyApplication.java index 84bc00fa..1f5cd3db 100644 --- a/app/src/main/java/io/netbird/client/MyApplication.java +++ b/app/src/main/java/io/netbird/client/MyApplication.java @@ -5,37 +5,14 @@ import androidx.appcompat.app.AppCompatDelegate; -import io.netbird.client.tool.Preferences; - public class MyApplication extends Application { @Override public void onCreate() { super.onCreate(); - registerWidgetCrashCleanup(); // Set Theme at start SharedPreferences prefs = getSharedPreferences("settings", MODE_PRIVATE); int themeMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); AppCompatDelegate.setDefaultNightMode(themeMode); } - - private void registerWidgetCrashCleanup() { - Thread.UncaughtExceptionHandler previousHandler = Thread.getDefaultUncaughtExceptionHandler(); - Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { - try { - new Preferences(this).clearWidgetState(); - NetbirdWidgetUpdater.updateAllWidgets(this); - } catch (Exception ignored) { - // Keep the original crash handling path intact. - } - - if (previousHandler != null) { - previousHandler.uncaughtException(thread, throwable); - return; - } - - android.os.Process.killProcess(android.os.Process.myPid()); - System.exit(10); - }); - } } diff --git a/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java b/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java index 4aec079a..91fb4bb8 100644 --- a/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java +++ b/tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java @@ -117,13 +117,10 @@ public void removeProfile(String profileName) throws Exception { String activeProfile = getActiveProfile(); boolean removingActiveProfile = activeProfile.equals(profileName); if (removingActiveProfile) { - stopEngine(); + throw new IllegalStateException("Cannot remove active profile"); } profileManager.removeProfile(profileName); - if (removingActiveProfile) { - clearWidgetDisplayState(); - } } /**