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