diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 897d5fdf..a8aee97d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -55,6 +55,18 @@
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/io/netbird/client/MyApplication.java b/app/src/main/java/io/netbird/client/MyApplication.java
index ea2493c1..1f5cd3db 100644
--- a/app/src/main/java/io/netbird/client/MyApplication.java
+++ b/app/src/main/java/io/netbird/client/MyApplication.java
@@ -15,4 +15,4 @@ public void onCreate() {
int themeMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
AppCompatDelegate.setDefaultNightMode(themeMode);
}
-}
\ 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..a883fc72
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/NetbirdWidgetUpdater.java
@@ -0,0 +1,118 @@
+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 && !VPNService.isServiceRunning(context)) {
+ preferences.clearWidgetState();
+ vpnRunning = false;
+ exitNodeActive = false;
+ exitNodeName = null;
+ }
+ 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.main_status_disconnected));
+ views.setContentDescription(
+ R.id.widget_connection_switch,
+ context.getString(vpnRunning
+ ? R.string.widget_connection_switch_connected
+ : R.string.widget_connection_switch_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);
+ views.setContentDescription(
+ R.id.widget_exit_switch,
+ context.getString(exitNodeAvailable
+ ? (exitNodeActive
+ ? R.string.widget_exit_switch_enabled
+ : R.string.widget_exit_switch_disabled)
+ : R.string.widget_exit_switch_unavailable));
+
+ PendingIntent connectionIntent = servicePendingIntent(
+ context,
+ VPNService.ACTION_WIDGET_TOGGLE_CONNECTION,
+ REQUEST_TOGGLE_CONNECTION);
+
+ views.setOnClickPendingIntent(R.id.widget_connection_switch,
+ connectionIntent);
+ views.setOnClickPendingIntent(R.id.widget_connection_icon,
+ connectionIntent);
+ views.setOnClickPendingIntent(R.id.widget_connection_status,
+ connectionIntent);
+
+ if (exitNodeAvailable || !vpnRunning) {
+ PendingIntent exitNodeIntent = servicePendingIntent(
+ context,
+ VPNService.ACTION_WIDGET_TOGGLE_EXIT_NODE,
+ REQUEST_TOGGLE_EXIT_NODE);
+ 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/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..044923d8
--- /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..ea236452
--- /dev/null
+++ b/app/src/main/res/layout/widget_netbird.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d6b1f51b..f360db97 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,6 +8,15 @@
Settings
NetBird
+ Toggle NetBird and the last-used exit node
+ Connected
+ 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
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/ForegroundNotification.java b/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java
index 0161af9c..23c290bf 100644
--- a/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java
+++ b/tool/src/main/java/io/netbird/client/tool/ForegroundNotification.java
@@ -8,50 +8,96 @@
import android.content.Intent;
import android.graphics.Color;
import android.net.VpnService;
-import android.os.Build;
import androidx.core.app.NotificationCompat;
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() {
- 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);
-
- Intent notificationIntent = new Intent();
- notificationIntent.setClassName("io.netbird.client", "io.netbird.client.MainActivity");
+ service.startForeground(
+ NOTIFICATION_ID,
+ buildNotification(
+ service.getResources().getString(R.string.fg_notification_text),
+ 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 stopForeground() {
+ service.stopForeground(true);
+ }
+ public void showNotification(String text) {
+ getNotificationManager().notify(PROMPT_NOTIFICATION_ID, buildNotification(text, false));
+ }
- Notification notification = new NotificationCompat.Builder(service.getApplication(), channelId)
+ private Notification buildNotification(String text, boolean ongoing) {
+ 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);
- service.startForeground(NOTIFICATION_ID, notification);
+ PendingIntent pendingIntent = createLaunchAppPendingIntent();
+ if (pendingIntent != null) {
+ builder.setContentIntent(pendingIntent);
+ }
+
+ 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) {
+ 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);
+ 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 ddf57ca5..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,6 +8,9 @@ public class Preferences {
private final String keyTraceLog = "tracelog";
private final String keyForceRelayConnection = "isConnectionForceRelayed";
+ private final String keyWidgetVpnRunning = "widgetVpnRunning";
+ private final String keyWidgetExitNodeActive = "widgetExitNodeActive";
+ private final String keyWidgetExitNodeName = "widgetExitNodeName";
private final SharedPreferences sharedPref;
@@ -38,6 +41,40 @@ public void disableForcedRelayConnection() {
sharedPref.edit().putBoolean(keyForceRelayConnection, false).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 void clearWidgetState() {
+ sharedPref.edit()
+ .putBoolean(keyWidgetVpnRunning, false)
+ .putBoolean(keyWidgetExitNodeActive, false)
+ .remove(keyWidgetExitNodeName)
+ .commit();
+ }
+
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..91fb4bb8 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;
@@ -71,6 +72,7 @@ public void switchProfile(String profileName) throws Exception {
stopEngine();
profileManager.switchProfile(profileName);
+ clearWidgetDisplayState();
}
/**
@@ -100,6 +102,9 @@ public void logoutProfile(String profileName) throws Exception {
}
profileManager.logoutProfile(profileName);
+ if (activeProfile.equals(profileName)) {
+ clearWidgetDisplayState();
+ }
}
/**
@@ -109,6 +114,12 @@ 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) {
+ throw new IllegalStateException("Cannot remove active profile");
+ }
+
profileManager.removeProfile(profileName);
}
@@ -128,6 +139,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
*/
@@ -142,4 +168,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 8494d48d..5bb99135 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,36 @@
package io.netbird.client.tool;
+import android.Manifest;
import android.app.Activity;
+import android.app.ActivityManager;
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;
+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;
+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;
@@ -26,12 +44,31 @@ 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 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();
+ 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);
+ 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;
private NetworkChangeNotifier notifier;
+ private Preferences preferences;
+ private ProfileManagerWrapper profileManager;
private RouteChangeListener listener;
@@ -49,15 +86,18 @@ public void onCreate() {
var tunAdapter = new IFace(this);
var iFaceDiscover = new IFaceDiscover();
- listener = this::queueTUNRenewal;
+ listener = routes -> {
+ queueTUNRenewal(routes);
+ requestWidgetStateUpdate();
+ };
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);
+ profileManager = new ProfileManagerWrapper(this);
// Create foreground notification before initializing engine
fgNotification = new ForegroundNotification(this);
@@ -84,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();
}
@@ -113,6 +154,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);
}
@@ -152,6 +203,7 @@ public void onDestroy() {
engineRunner.stop();
stopForeground(true);
+ clearWidgetStateAndBroadcast();
if (this.notifier != null) {
this.notifier.removeRouteChangeListener(listener);
@@ -161,6 +213,11 @@ public void onDestroy() {
tunCreator.getHandler().getLooper().quitSafely();
tunCreator = null;
}
+
+ stopWidgetStateRefresh();
+ widgetActionExecutor.shutdownNow();
+ widgetStateExecutor.shutdownNow();
+ widgetRefreshExecutor.shutdownNow();
}
@Override
@@ -169,6 +226,7 @@ public void onRevoke() {
if (engineRunner != null) {
engineRunner.stop();
stopForeground(true);
+ clearWidgetStateAndBroadcast();
}
}
@@ -269,23 +327,377 @@ 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() {
-
+ requestWidgetStateUpdate();
+ scheduleWidgetStateRefresh();
}
@Override
public void onStopped() {
+ stopWidgetStateRefresh();
fgNotification.stopForeground();
+ requestWidgetStateUpdate();
}
@Override
public void onError(String msg) {
+ stopWidgetStateRefresh();
fgNotification.stopForeground();
+ requestWidgetStateUpdate();
}
};
+ private void handleWidgetConnectionToggle() {
+ if (engineRunner.isRunning()) {
+ engineRunner.stop();
+ requestWidgetStateUpdate();
+ return;
+ }
+
+ if (!hasUsableActiveProfile()) {
+ promptUserToOpenApp(R.string.widget_open_app_setup_text);
+ return;
+ }
+
+ if (!ensureVpnPermissionFromWidget()) {
+ return;
+ }
+
+ engineRunner.runWithoutAuth();
+ requestWidgetStateUpdate();
+ }
+
+ private void handleWidgetExitNodeToggle() {
+ boolean isRunning = engineRunner.isRunning();
+ if (!isRunning && !hasUsableActiveProfile()) {
+ promptUserToOpenApp(R.string.widget_open_app_setup_text);
+ return;
+ }
+
+ if (!ensureVpnPermissionFromWidget()) {
+ return;
+ }
+
+ if (!isRunning) {
+ engineRunner.runWithoutAuth();
+ requestWidgetStateUpdate();
+ runExitNodeActionWhenAvailable(true);
+ return;
+ }
+
+ runExitNodeActionWhenAvailable(false);
+ }
+
+ private void runExitNodeActionWhenAvailable(boolean enableOnly) {
+ if (!widgetExitToggleInFlight.compareAndSet(false, true)) {
+ return;
+ }
+
+ widgetActionExecutor.execute(() -> {
+ try {
+ if (enableOnly) {
+ enableExitNodeWhenAvailable();
+ } else {
+ toggleExitNodeWhenAvailable();
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "failed to toggle exit node from widget", e);
+ } finally {
+ widgetExitToggleInFlight.set(false);
+ requestWidgetStateUpdate();
+ }
+ });
+ }
+
+ private boolean ensureVpnPermissionFromWidget() {
+ if (VpnService.prepare(this) == null) {
+ return true;
+ }
+
+ promptUserToOpenApp(R.string.widget_open_app_permission_text);
+ 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();
+ }
+
+ private void promptUserToOpenApp(int messageResId) {
+ fgNotification.stopForeground();
+ 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();
+ if (!exitNodes.isEmpty()) {
+ toggleExitNode(exitNodes);
+ return;
+ }
+
+ try {
+ Thread.sleep(EXIT_NODE_RETRY_DELAY_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ }
+
+ 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;
+ }
+ }
+ }
+
+ 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;
+ }
+ }
+
+ 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) {
+ 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<>();
+ EngineRunner currentEngineRunner = engineRunner;
+ if (currentEngineRunner == null || !currentEngineRunner.isRunning()) {
+ return exitNodes;
+ }
+
+ 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 void updateWidgetStateAndBroadcast() {
+ Preferences currentPreferences = preferences;
+ EngineRunner currentEngineRunner = engineRunner;
+ if (currentPreferences == null || currentEngineRunner == null) {
+ return;
+ }
+
+ boolean isRunning = currentEngineRunner.isRunning();
+ boolean hasSelectedExitNode = false;
+ String exitNodeName = null;
+
+ if (isRunning) {
+ List exitNodes = getExitNodes();
+ for (ExitNode exitNode : exitNodes) {
+ if (exitNode.selected) {
+ hasSelectedExitNode = true;
+ exitNodeName = 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.setWidgetState(isRunning, hasSelectedExitNode, exitNodeName);
+ sendWidgetRefreshBroadcast(this);
+ }
+ }
+
+ private void clearWidgetStateAndBroadcast() {
+ lastSelectedExitNodeName = null;
+ Preferences currentPreferences = preferences;
+ if (currentPreferences == null) {
+ currentPreferences = new Preferences(this);
+ }
+ currentPreferences.clearWidgetState();
+ sendWidgetRefreshBroadcast(this);
+ }
+
+ 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();
+ requestWidgetStateUpdate();
+
+ 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);
+ }
+
+ private boolean hasSelectedExitNode() {
+ for (ExitNode exitNode : getExitNodes()) {
+ if (exitNode.selected) {
+ return true;
+ }
+ }
+ 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 {
+ 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) {
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