Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

<receiver
android:name=".NetbirdWidgetProvider"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/netbird_widget_info" />
</receiver>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</application>

</manifest>
</manifest>
25 changes: 24 additions & 1 deletion app/src/main/java/io/netbird/client/MyApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

private void registerWidgetCrashCleanup() {
Thread.UncaughtExceptionHandler previousHandler = Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
try {
new Preferences(this).clearWidgetState();
NetbirdWidgetUpdater.updateAllWidgets(this);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} catch (Exception ignored) {
// Keep the original crash handling path intact.
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n app/src/main/java/io/netbird/client/MyApplication.java | head -60

Repository: netbirdio/android-client

Length of output: 1740


Catch Throwable instead of Exception to ensure all cleanup exceptions don't break crash delegation.

In this UncaughtExceptionHandler, the cleanup block (lines 26-27) catches only Exception. If cleanup throws an Error (e.g., OutOfMemoryError), it won't be caught and the exception will propagate, preventing the delegation to previousHandler and proper process cleanup at lines 32-38.

Targeted fix
-        } catch (Exception ignored) {
+        } catch (Throwable ignored) {
             // Keep the original crash handling path intact.
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (Exception ignored) {
// Keep the original crash handling path intact.
}
} catch (Throwable ignored) {
// Keep the original crash handling path intact.
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/io/netbird/client/MyApplication.java` around lines 28 - 30,
In the UncaughtExceptionHandler inside MyApplication.java, change the cleanup
catch from catching Exception to catching Throwable so Errors (e.g.,
OutOfMemoryError) are also handled; update the try/catch around the cleanup
logic (the block that currently reads "catch (Exception ignored)") to "catch
(Throwable t)" and ensure you still swallow/log as appropriate before delegating
to previousHandler and running the remaining process cleanup code referenced in
this handler.


if (previousHandler != null) {
previousHandler.uncaughtException(thread, throwable);
return;
}

android.os.Process.killProcess(android.os.Process.myPid());
System.exit(10);
});
}
}
23 changes: 23 additions & 0 deletions app/src/main/java/io/netbird/client/NetbirdWidgetProvider.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
118 changes: 118 additions & 0 deletions app/src/main/java/io/netbird/client/NetbirdWidgetUpdater.java
Original file line number Diff line number Diff line change
@@ -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
);
}
}
9 changes: 9 additions & 0 deletions app/src/main/res/drawable/widget_background.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/nb_bg" />
<corners android:radius="16dp" />
<stroke
android:width="1dp"
android:color="@color/nb_gray_separator" />
</shape>
20 changes: 20 additions & 0 deletions app/src/main/res/drawable/widget_switch_off.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<size
android:width="52dp"
android:height="32dp" />
<solid android:color="@color/track_off_color" />
<corners android:radius="16dp" />
</shape>
</item>
<item
android:width="32dp"
android:height="32dp"
android:gravity="start|center_vertical">
<shape android:shape="oval">
<solid android:color="@color/thumb_off_color" />
</shape>
</item>
</layer-list>
20 changes: 20 additions & 0 deletions app/src/main/res/drawable/widget_switch_on.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<size
android:width="52dp"
android:height="32dp" />
<solid android:color="@color/track_on_color" />
<corners android:radius="16dp" />
</shape>
</item>
<item
android:width="32dp"
android:height="32dp"
android:gravity="end|center_vertical">
<shape android:shape="oval">
<solid android:color="@color/thumb_on_color" />
</shape>
</item>
</layer-list>
104 changes: 104 additions & 0 deletions app/src/main/res/layout/widget_netbird.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:gravity="center"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">

<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">

<TextView
android:id="@+id/widget_connection_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:text="@string/main_status_disconnected"
android:textColor="@color/nb_txt"
android:textSize="14sp"
android:textStyle="bold" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="4dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">

<ImageView
android:id="@+id/widget_connection_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_netbird_tile" />

<TextView
android:id="@+id/widget_connection_switch"
android:layout_width="52dp"
android:layout_height="32dp"
android:layout_marginStart="12dp"
android:background="@drawable/widget_switch_off"
android:contentDescription="@string/widget_connection_switch_disconnected"
android:text="" />
Comment on lines +48 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add contentDescription to the “switch” TextViews for accessibility.

The connection and exit-node "switches" are clickable TextViews with empty text and only a background drawable, so screen readers have nothing to announce for these touch targets. Setting a meaningful contentDescription (and updating it from NetbirdWidgetUpdater based on the on/off state) makes the widget usable with TalkBack.

♿ Suggested layout change
             <TextView
                 android:id="@+id/widget_connection_switch"
                 android:layout_width="52dp"
                 android:layout_height="32dp"
                 android:layout_marginStart="12dp"
                 android:background="@drawable/widget_switch_off"
+                android:contentDescription="@string/widget_status_disconnected"
                 android:text="" />
             <TextView
                 android:id="@+id/widget_exit_switch"
                 android:layout_width="52dp"
                 android:layout_height="32dp"
                 android:layout_marginStart="12dp"
                 android:background="@drawable/widget_switch_off"
+                android:contentDescription="@string/widget_exit_node_unavailable"
                 android:text="" />

You can then keep these descriptions in sync from NetbirdWidgetUpdater.createRemoteViews(...) via views.setContentDescription(...).

Also applies to: 93-99

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/res/layout/widget_netbird.xml` around lines 48 - 54, Add
meaningful contentDescription attributes to the empty "switch" TextViews so
screen readers announce them (e.g., for the connection switch with id
widget_connection_switch and the exit-node switch at the other block), and
update NetbirdWidgetUpdater.createRemoteViews to call
views.setContentDescription(viewId, description) to toggle the description based
on on/off state so TalkBack can announce current state.

</LinearLayout>
</LinearLayout>

<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">

<TextView
android:id="@+id/widget_exit_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:text="@string/widget_exit_node_unavailable"
android:textColor="@color/nb_txt_light"
android:textSize="14sp"
android:textStyle="bold" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="4dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">

<ImageView
android:id="@+id/widget_exit_icon"
android:layout_width="30dp"
android:layout_height="30dp"
android:contentDescription="@string/networks_desc_exit_node"
android:src="@drawable/exit" />

<TextView
android:id="@+id/widget_exit_switch"
android:layout_width="52dp"
android:layout_height="32dp"
android:layout_marginStart="12dp"
android:background="@drawable/widget_switch_off"
android:contentDescription="@string/widget_exit_switch_unavailable"
android:text="" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
9 changes: 9 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
<string name="action_settings">Settings</string>
<string name="quick_settings_tile_label">NetBird</string>

<string name="widget_description">Toggle NetBird and the last-used exit node</string>
<string name="widget_status_connected">Connected</string>
<string name="widget_exit_node_unavailable">No exit node</string>
<string name="widget_connection_switch_connected">NetBird connected, tap to disconnect</string>
<string name="widget_connection_switch_disconnected">NetBird disconnected, tap to connect</string>
<string name="widget_exit_switch_enabled">Exit node enabled, tap to disable</string>
<string name="widget_exit_switch_disabled">Exit node disabled, tap to enable</string>
<string name="widget_exit_switch_unavailable">Exit node unavailable</string>

<string name="menu_advanced">Advanced</string>
<string name="menu_about">About</string>
<string name="menu_docs">Docs</string>
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/res/xml/netbird_widget_info.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialLayout="@layout/widget_netbird"
android:minWidth="250dp"
android:minHeight="48dp"
android:previewImage="@drawable/ic_netbird_tile"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />
Loading