Skip to content

Conversation

@pappz
Copy link
Collaborator

@pappz pappz commented Nov 28, 2025

Summary by CodeRabbit

  • New Features

    • Multi-profile management UI: create, switch, logout, and remove profiles with dialogs and feedback
    • Profiles entry added to the navigation drawer and a profile icon asset
    • Route controls exposed so UI can select/deselect routes and react to route changes
  • Refactor

    • Profile-aware plumbing: profile manager integration so active profile and config/state paths are used app-wide, improving profile switching consistency

✏️ Tip: You can customize this high-level summary in your review settings.

VPNServiceRepository created its own service binding which caused conflicts
when unbinding - it would disconnect other components relying on the service.

Now NetworksFragmentViewModel uses ServiceAccessor from MainActivity, which
maintains a single shared service binding across all components.
@coderabbitai
Copy link

coderabbitai bot commented Nov 28, 2025

Walkthrough

Adds profile management (UI, resources, ProfileManagerWrapper) and shifts config/state path handling from static Preferences to ProfileManagerWrapper. Replaces VPNServiceRepository with a ServiceAccessor pattern, updates engine startup (EngineRunner, VPNService) to use ProfileManagerWrapper, and wires route APIs through MainActivity.

Changes

Cohort / File(s) Summary
Small cleanup
app/src/main/java/io/netbird/client/CustomTabURLOpener.java
Replaced anonymous ActivityResultCallback with a lambda; no behavioral change.
Service binding API
app/src/main/java/io/netbird/client/ServiceAccessor.java, app/src/main/java/io/netbird/client/repository/VPNServiceBindListener.java, app/src/main/java/io/netbird/client/repository/VPNServiceRepository.java
Removed VPNServiceRepository and VPNServiceBindListener; added ServiceAccessor methods for select/deselect routes and add/remove RouteChangeListener.
Main application surface
app/src/main/java/io/netbird/client/MyApplication.java, app/src/main/java/io/netbird/client/MainActivity.java
Removed getVPNServiceRepository(); added MainActivity delegators for route management and updateProfileMenuItem to surface active profile in the navigation UI.
Profile manager & preferences
tool/src/main/java/io/netbird/client/tool/Profile.java, tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java, tool/src/main/java/io/netbird/client/tool/Preferences.java
Added Profile value class and ProfileManagerWrapper (list/switch/add/remove/logout, active config/state paths). Removed Preferences.configFile/stateFile helpers.
Engine & VPN integration
tool/src/main/java/io/netbird/client/tool/EngineRunner.java, tool/src/main/java/io/netbird/client/tool/VPNService.java
EngineRunner constructor changed to accept ProfileManagerWrapper; AndroidPlatformFiles creation delayed until runtime using active profile paths. VPNService uses ProfileManagerWrapper, adds ACTION_STOP_ENGINE and a receiver to stop engine for profile operations.
Fragments using config path
app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java, app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java
Replaced direct Preferences.configFile(...) calls with ProfileManagerWrapper.getActiveConfigPath() and added error handling for failures.
Networks UI & ViewModel
app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java, app/src/main/java/io/netbird/client/ui/home/NetworksFragmentViewModel.java
Migrated from VPNServiceRepository to injecting ServiceAccessor; ViewModel implements RouteChangeListener, StateListener, adds factory getFactory(ServiceAccessor), and delegates select/deselect route calls to ServiceAccessor.
Profiles UI components
app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java, app/src/main/java/io/netbird/client/ui/profile/ProfilesAdapter.java
Added ProfilesFragment (add/switch/logout/remove flows) and ProfilesAdapter with ProfileActionListener to manage profiles in UI.
Resources: layouts, menu, navigation, drawable, strings
app/src/main/res/layout/fragment_profiles.xml, app/src/main/res/layout/list_item_profile.xml, app/src/main/res/menu/activity_main_drawer.xml, app/src/main/res/navigation/mobile_navigation.xml, app/src/main/res/drawable/ic_menu_profile.xml, app/src/main/res/values/strings.xml
Added profile screen layouts, list item layout, drawer menu item nav_profiles, navigation entry, drawable icon, and many profile-related strings.
Submodule
netbird
Submodule pointer updated; no functional changes.

Sequence Diagram(s)

sequenceDiagram
    participant UI as ProfilesFragment / MainActivity
    participant PM as ProfileManagerWrapper
    participant App as VPNService
    participant Engine as EngineRunner

    UI->>PM: switchProfile(name)
    PM-->>UI: validate & stopEngine() (broadcast ACTION_STOP_ENGINE)
    PM->>App: send broadcast ACTION_STOP_ENGINE
    App->>App: stop running engine (on receive)
    App->>PM: (ack) shutdown complete (implicit)
    PM->>PM: update active profile (gomobile ProfileManager)
    PM->>UI: return success
    UI->>Engine: next start uses PM.getActiveConfigPath() & getActiveStateFilePath()
    Engine->>PM: request active paths at run-time
    Engine->>Engine: create AndroidPlatformFiles with active paths and start client
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45–75 minutes

  • Pay attention to:
    • ServiceAccessor exception handling propagation and callers (MainActivity, ViewModel).
    • ProfileManagerWrapper stopEngine() broadcast timing and VPNService receiver unregister/register logic for race conditions.
    • EngineRunner lazy AndroidPlatformFiles creation correctness when profile paths are missing or invalid.
    • All call-sites updated for removed VPNServiceRepository/getVPNServiceRepository().
    • UI state synchronization (MainActivity.updateProfileMenuItem and navigation destination handling).

Possibly related PRs

Suggested reviewers

  • doromaraujo

Poem

🐇 I hop through code with whiskers bright,
New profiles bloom in soft spotlight.
Routes now flow where rabbits lead,
Configs remapped for every seed.
A joyful sprint — the network's right! 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.71% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add profile switch' accurately summarizes the primary feature: introducing profile management and switching capabilities throughout the application.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch profile-switch

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a2cfff6 and 57ffbcc.

📒 Files selected for processing (1)
  • netbird (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • netbird

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pappz pappz marked this pull request as ready for review December 1, 2025 14:45
@pappz pappz changed the base branch from fix-unbind to main December 1, 2025 17:08
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (14)
app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java (1)

111-118: Config path via ProfileManagerWrapper is correct; consider failure handling

Using ProfileManagerWrapper.getActiveConfigPath() to populate CONFIG_FILE_PATH_KEY keeps this aligned with the new profile model. The hard RuntimeException on failure makes this fragment non-recoverable if the active profile/config path can’t be resolved; if that’s not expected to be truly fatal, consider logging and showing a user-facing error instead of crashing.

Also applies to: 120-125

app/src/main/res/menu/activity_main_drawer.xml (1)

6-11: Drawer item wiring is fine; verify checked-state behavior across groups

The new group_profile and nav_profiles item are correctly defined and reference the new icon/title. Depending on how you hook the drawer to NavController, you may end up with one checked item in each group (profiles + another destination); if that’s not desired, consider having all navigable items in a single checkable group.

app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java (1)

71-79: Profile-based config path is correct; reassess throwing on failure

Initializing goPreferences from ProfileManagerWrapper.getActiveConfigPath() correctly ties advanced settings to the active profile. However, converting any failure here into a RuntimeException will crash the screen; if active profile resolution can fail in real-world cases (e.g., corrupted profile state), you might instead want to show a Toast/dialog and disable advanced options rather than crashing.

app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java (3)

79-100: Unused dialog view inflation.

dialogView is inflated on line 80 but never used. The dialog only uses the EditText created on line 81. Either use the inflated layout or remove the unused inflation.

 private void showAddDialog() {
-    View dialogView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_simple_alert_message, null);
     EditText input = new EditText(requireContext());
     input.setHint(R.string.profiles_dialog_add_hint);

164-167: onBackPressed() is deprecated.

Activity.onBackPressed() is deprecated in API 33+. Consider using the OnBackPressedDispatcher or NavController.popBackStack() for navigation.

-            // Navigate back to home
-            requireActivity().onBackPressed();
+            // Navigate back to home
+            requireActivity().getOnBackPressedDispatcher().onBackPressed();

Or if using Navigation Component:

NavHostFragment.findNavController(this).popBackStack();

146-150: Hardcoded error message strings.

Error messages on lines 148, 170, 188, and 217 use hardcoded English text concatenated with the exception message. Consider using string resources with placeholders for consistency and i18n support.

-            Toast.makeText(requireContext(),
-                    "Failed to add profile: " + e.getMessage(),
-                    Toast.LENGTH_SHORT).show();
+            Toast.makeText(requireContext(),
+                    getString(R.string.profiles_error_add_failed, e.getMessage()),
+                    Toast.LENGTH_SHORT).show();
app/src/main/java/io/netbird/client/ui/profile/ProfilesAdapter.java (2)

7-7: Unused import.

ImageView is imported but not used in this file.

-import android.widget.ImageView;

82-89: Hardcoded "default" profile name.

The string "default" is hardcoded here and in ProfilesFragment.java (line 195). Consider extracting this to a constant for maintainability.

// In a shared location (e.g., Profile.java or a constants class)
public static final String DEFAULT_PROFILE_NAME = "default";
app/src/main/java/io/netbird/client/MainActivity.java (2)

380-398: Exception declaration mismatch with actual behavior.

Both selectRoute and deselectRoute declare throws Exception but silently return when mBinder is null instead of throwing. This inconsistency can mislead callers who expect an exception on failure.

Consider either:

  1. Throwing an exception when mBinder is null to match the signature
  2. Removing throws Exception if the null case should be silently ignored
 @Override
 public void selectRoute(String route) throws Exception {
     if (mBinder == null) {
         Log.w(LOGTAG, "VPN binder is null");
-        return;
+        throw new IllegalStateException("VPN service not bound");
     }

     mBinder.selectRoute(route);
 }

 @Override
 public void deselectRoute(String route) throws Exception {
     if (mBinder == null) {
         Log.w(LOGTAG, "VPN binder is null");
-        return;
+        throw new IllegalStateException("VPN service not bound");
     }

     mBinder.deselectRoute(route);
 }

614-628: Instantiating ProfileManagerWrapper on every call is inefficient.

updateProfileMenuItem is called from onCreate, onResume, and destination changes. Each invocation creates a new ProfileManagerWrapper instance. Consider caching the wrapper as a field.

+    private io.netbird.client.tool.ProfileManagerWrapper profileManager;
+
     private void updateProfileMenuItem(NavigationView navigationView) {
         try {
-            // Get active profile from ProfileManager instead of reading file
-            io.netbird.client.tool.ProfileManagerWrapper profileManager =
-                new io.netbird.client.tool.ProfileManagerWrapper(this);
+            if (profileManager == null) {
+                profileManager = new io.netbird.client.tool.ProfileManagerWrapper(this);
+            }
             String activeProfile = profileManager.getActiveProfile();
             Menu menu = navigationView.getMenu();
             MenuItem profileItem = menu.findItem(R.id.nav_profiles);
             if (profileItem != null && activeProfile != null) {
                 profileItem.setTitle(activeProfile);
             }
         } catch (Exception e) {
             Log.e(LOGTAG, "Failed to update profile menu item", e);
         }
     }
app/src/main/java/io/netbird/client/ui/home/NetworksFragmentViewModel.java (4)

28-39: Suppress unchecked cast warning in factory.

The unchecked cast (T) on line 34 will generate a compiler warning. Consider adding @SuppressWarnings("unchecked") to the method.

     public static ViewModelProvider.Factory getFactory(ServiceAccessor serviceAccessor) {
         return new ViewModelProvider.Factory() {
             @NonNull
             @Override
+            @SuppressWarnings("unchecked")
             public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
                 if (modelClass.isAssignableFrom(NetworksFragmentViewModel.class)) {
                     return (T) new NetworksFragmentViewModel(serviceAccessor);
                 }
                 throw new IllegalArgumentException("Unknown ViewModel class");
             }
         };
     }

51-63: Wrapping in RuntimeException loses error context.

Re-throwing a generic RuntimeException obscures the original exception type and provides no additional context. Consider logging or adding a message, or simply declaring the checked exception if the caller can handle it.

     private List<String> createPeerRoutesList(PeerRoutes peerRoutes) {
         List<String> routes = new ArrayList<>();

         try {
             for (int i = 0; i < peerRoutes.size(); i++) {
                 routes.add(peerRoutes.get(i));
             }
         } catch (Exception e) {
-            throw new RuntimeException(e);
+            throw new RuntimeException("Failed to create peer routes list", e);
         }

         return routes;
     }

86-88: Same issue: add context to RuntimeException.

         } catch (Exception e) {
-            throw new RuntimeException(e);
+            throw new RuntimeException("Failed to create network domains list", e);
         }

149-189: Consider using a default adapter or base class for StateListener.

All these empty implementations add boilerplate. If StateListener is under your control, adding default empty implementations to the interface would simplify this ViewModel. Alternatively, create a StateListenerAdapter base class.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f9376e3 and a2cfff6.

📒 Files selected for processing (24)
  • app/src/main/java/io/netbird/client/CustomTabURLOpener.java (1 hunks)
  • app/src/main/java/io/netbird/client/MainActivity.java (7 hunks)
  • app/src/main/java/io/netbird/client/MyApplication.java (0 hunks)
  • app/src/main/java/io/netbird/client/ServiceAccessor.java (2 hunks)
  • app/src/main/java/io/netbird/client/repository/VPNServiceBindListener.java (0 hunks)
  • app/src/main/java/io/netbird/client/repository/VPNServiceRepository.java (0 hunks)
  • app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java (4 hunks)
  • app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java (4 hunks)
  • app/src/main/java/io/netbird/client/ui/home/NetworksFragmentViewModel.java (2 hunks)
  • app/src/main/java/io/netbird/client/ui/profile/ProfilesAdapter.java (1 hunks)
  • app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java (1 hunks)
  • app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java (2 hunks)
  • app/src/main/res/drawable/ic_menu_profile.xml (1 hunks)
  • app/src/main/res/layout/fragment_profiles.xml (1 hunks)
  • app/src/main/res/layout/list_item_profile.xml (1 hunks)
  • app/src/main/res/menu/activity_main_drawer.xml (1 hunks)
  • app/src/main/res/navigation/mobile_navigation.xml (1 hunks)
  • app/src/main/res/values/strings.xml (2 hunks)
  • netbird (1 hunks)
  • tool/src/main/java/io/netbird/client/tool/EngineRunner.java (2 hunks)
  • tool/src/main/java/io/netbird/client/tool/Preferences.java (0 hunks)
  • tool/src/main/java/io/netbird/client/tool/Profile.java (1 hunks)
  • tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java (1 hunks)
  • tool/src/main/java/io/netbird/client/tool/VPNService.java (4 hunks)
💤 Files with no reviewable changes (4)
  • app/src/main/java/io/netbird/client/repository/VPNServiceBindListener.java
  • app/src/main/java/io/netbird/client/MyApplication.java
  • tool/src/main/java/io/netbird/client/tool/Preferences.java
  • app/src/main/java/io/netbird/client/repository/VPNServiceRepository.java
🧰 Additional context used
🧬 Code graph analysis (5)
app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java (1)
app/src/main/java/io/netbird/client/ui/home/NetworksFragmentViewModel.java (1)
  • NetworksFragmentViewModel (18-190)
app/src/main/java/io/netbird/client/ui/profile/ProfilesAdapter.java (1)
tool/src/main/java/io/netbird/client/tool/Profile.java (1)
  • Profile (3-37)
tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java (1)
tool/src/main/java/io/netbird/client/tool/VPNService.java (1)
  • VPNService (25-321)
app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java (2)
tool/src/main/java/io/netbird/client/tool/Profile.java (1)
  • Profile (3-37)
tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java (1)
  • ProfileManagerWrapper (16-145)
app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java (2)
tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java (1)
  • ProfileManagerWrapper (16-145)
app/src/main/java/io/netbird/client/ui/server/ChangeServerFragmentViewModel.java (1)
  • ChangeServerFragmentViewModel (19-182)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: build-debug
  • GitHub Check: Analyze (java-kotlin)
🔇 Additional comments (22)
netbird (1)

1-1: Ensure the gomobile API dependency is verified through the build process.

The submodule pointer update introduces new gomobile APIs used by ProfileManagerWrapper (Android.newProfileManager(), switchProfile(), addProfile(), etc.). Verify that the new commit 49cd86440f222682f21c17029b1e8a5cc3ad6d05 provides these ProfileManager APIs by confirming the Android build succeeds without API compatibility errors. The GitHub Actions workflows will catch any missing or incompatible APIs during compilation.

app/src/main/java/io/netbird/client/CustomTabURLOpener.java (1)

30-34: Lambda callback refactor is behaviorally identical

The lambda correctly preserves the previous behavior (resetting isOpened and invoking resultCallback.onClosed()), so this is a safe and cleaner refactor.

app/src/main/res/navigation/mobile_navigation.xml (1)

27-31: Profiles destination wiring looks consistent

nav_profiles correctly targets io.netbird.client.ui.profile.ProfilesFragment with the expected label and layout; this should integrate cleanly with the drawer item and profiles UI.

app/src/main/res/drawable/ic_menu_profile.xml (1)

1-9: Vector icon definition is sound

The profile vector drawable is well-formed (24dp viewport, single path, solid fill) and appropriate for use as the new drawer/menu icon.

app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java (1)

39-55: ServiceAccessor injection is correct; ensure hosting activity implements it

Using ServiceAccessor via onAttach and passing it into NetworksFragmentViewModel.getFactory(serviceAccessor) is consistent and keeps the ViewModel decoupled from the Fragment. Just make sure every activity that can host NetworksFragment now implements ServiceAccessor, otherwise this will throw at runtime.

Also applies to: 69-70

app/src/main/res/layout/fragment_profiles.xml (1)

1-40: Profiles layout is structurally solid

RecyclerView and FAB are correctly constrained; padding and a11y (FAB contentDescription) look good. This should give a clean profiles list screen out of the box.

app/src/main/res/layout/list_item_profile.xml (1)

1-116: LGTM!

The layout is well-structured using MaterialCardView and ConstraintLayout with proper constraint chains for button distribution. The active badge visibility toggle and button styling are appropriately configured.

app/src/main/java/io/netbird/client/ServiceAccessor.java (1)

3-19: LGTM!

The interface extension is clean. Route management methods appropriately declare checked exceptions, and the listener pattern for route changes is consistent with the existing service architecture.

app/src/main/java/io/netbird/client/ui/profile/ProfilesAdapter.java (1)

69-106: Click listeners re-check conditions that are already enforced by button state.

The click listeners on lines 92 and 102 re-check !profile.isActive() and !profile.getName().equals("default") even though the buttons are already disabled. While this is defensive, it's redundant.

The defensive checks are acceptable as they provide an extra safety layer if button state is somehow inconsistent.

tool/src/main/java/io/netbird/client/tool/EngineRunner.java (2)

76-96: LGTM - Runtime profile path resolution enables dynamic switching.

The refactoring to retrieve config/state paths at runtime from ProfileManagerWrapper is well-structured and enables profile switching without recreating the entire client. Error handling with RuntimeException is appropriate for this critical path.


87-90: Uncaught RuntimeException on background thread.

If profileManager.getActiveConfigPath() or getActiveStateFilePath() fails, the RuntimeException is thrown inside a Runnable on a new thread (line 115). This will crash the thread but may not be surfaced to the user or properly logged beyond the thread's default uncaught exception handler.

Verify that the thread's uncaught exception is handled appropriately. Consider catching the exception and calling notifyError() instead:

             } catch (Exception e) {
                 Log.e(LOGTAG, "Failed to get profile paths from ProfileManager", e);
-                throw new RuntimeException("Failed to get profile paths: " + e.getMessage(), e);
+                notifyError(new Exception("Failed to get profile paths: " + e.getMessage(), e));
+                return;
             }
tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java (2)

95-100: TOCTOU between getActiveProfile() and conditional stopEngine().

There's a time-of-check-to-time-of-use gap: the active profile could change between the getActiveProfile() call on line 96 and the stopEngine() call on line 99 if another thread/process modifies the profile state.

This is likely a minor concern in practice since profile operations are user-initiated and serialized through the UI. Verify this assumption holds.


31-47: LGTM - Robust profile listing with null-safety.

The listProfiles() method properly handles null checks for both the array and individual profile elements, with exception logging and graceful empty list fallback.

tool/src/main/java/io/netbird/client/tool/VPNService.java (3)

57-70: LGTM - Clean integration of ProfileManagerWrapper.

The ProfileManagerWrapper is properly instantiated and passed to EngineRunner, enabling dynamic profile-based configuration. The initialization order is logical, with the profile manager created before the engine runner.


81-99: LGTM - Broadcast receiver properly registered.

The stopEngineReceiver is correctly registered with RECEIVER_NOT_EXPORTED flag, ensuring only internal broadcasts are received. The null check on engineRunner before calling stop() is good defensive practice.


136-143: LGTM - Proper cleanup with exception handling.

The receiver unregistration in onDestroy() properly catches IllegalArgumentException in case the receiver was never registered or already unregistered.

app/src/main/java/io/netbird/client/MainActivity.java (2)

400-418: LGTM!

The route change listener management methods correctly delegate to the binder with appropriate null checks and logging.


119-121: Profile menu updates in multiple lifecycle hooks look correct.

Updating the profile menu item in onCreate, onResume, and when navigating back to home ensures the UI stays in sync after profile switches. The null checks on binding and binding.navView are appropriate.

Also applies to: 171-174, 263-266

app/src/main/java/io/netbird/client/ui/home/NetworksFragmentViewModel.java (2)

23-26: LGTM!

The constructor properly initializes the service accessor and registers the route change listener, and onCleared() correctly unregisters to prevent leaks.

Also applies to: 41-45


128-134: Correct use of postValue for background thread updates.

Using postValue instead of setValue is correct here since route changes may be triggered from background threads.

app/src/main/res/values/strings.xml (2)

117-143: LGTM! Comprehensive and well-structured profile strings.

The string resources cover all necessary UI scenarios including dialogs, validation errors, and success messages. Format specifiers (%s) are used correctly for dynamic profile names, and apostrophes are properly escaped.


14-14: LGTM!

Menu string added appropriately alongside other menu items.

Comment on lines 328 to 337
private void setPreSharedKey(String key, Context context) {
String configFilePath = Preferences.configFile(context);
ProfileManagerWrapper profileManager = new ProfileManagerWrapper(context);
String configFilePath;
try {
configFilePath = profileManager.getActiveConfigPath();
} catch (Exception e) {
Toast.makeText(context, "Failed to get config path: " + e.getMessage(), Toast.LENGTH_LONG).show();
return;
}
io.netbird.gomobile.android.Preferences preferences = new io.netbird.gomobile.android.Preferences(configFilePath);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Config path reuse is good; fix pre‑shared key error Toast message

Using ProfileManagerWrapper in both setPreSharedKey and hasPreSharedKey keeps these operations aligned with the active profile and adds reasonable error handling (Toast/log + early return), which is good.

There is, however, a bug in the error Toast in setPreSharedKey: it concatenates the string resource ID directly into the message, resulting in something like "2131230890: <msg>" instead of the localized text. Consider this adjustment:

-        } catch (Exception e) {
-            Log.e(LOGTAG, "Failed to save pre-shared key", e);
-            Toast.makeText(context, R.string.advanced_presharedkey_save_error + ": " + e.getMessage(), Toast.LENGTH_LONG).show();
-        }
+        } catch (Exception e) {
+            Log.e(LOGTAG, "Failed to save pre-shared key", e);
+            String message = context.getString(R.string.advanced_presharedkey_save_error) + ": " + e.getMessage();
+            Toast.makeText(context, message, Toast.LENGTH_LONG).show();
+        }

Also applies to: 348-356

🤖 Prompt for AI Agents
In app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java around
lines 328-337 (and likewise apply to 348-356), the Toast constructs concatenate
a string resource ID instead of using the localized string; change the Toast to
call context.getString(...) (preferably a string resource that accepts the
exception/message as an argument) or build the message via
context.getString(R.string.your_string, e.getMessage()) so the localized text is
used, then pass that resulting String into Toast.makeText(...). Ensure both
occurrences are updated and remove direct concatenation of the resource ID.

Comment on lines +88 to +93
.setPositiveButton(android.R.string.ok, (d, which) -> {
String profileName = input.getText().toString().trim();
if (profileName.isEmpty()) {
Toast.makeText(requireContext(), R.string.profiles_error_empty_name, Toast.LENGTH_SHORT).show();
return;
}
Copy link

@coderabbitai coderabbitai bot Dec 3, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Dialog dismisses even when validation fails.

The return statement on line 92 doesn't prevent the dialog from closing—AlertDialog dismisses automatically after the positive button callback. Users submitting an empty name will see the toast but lose the dialog.

Consider validating before dismissal:

-            .setPositiveButton(android.R.string.ok, (d, which) -> {
-                String profileName = input.getText().toString().trim();
-                if (profileName.isEmpty()) {
-                    Toast.makeText(requireContext(), R.string.profiles_error_empty_name, Toast.LENGTH_SHORT).show();
-                    return;
-                }
-                addProfile(profileName);
-            })
+            .setPositiveButton(android.R.string.ok, null) // Set listener after show()

Then after dialog.show():

dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
    String profileName = input.getText().toString().trim();
    if (profileName.isEmpty()) {
        Toast.makeText(requireContext(), R.string.profiles_error_empty_name, Toast.LENGTH_SHORT).show();
        return;
    }
    addProfile(profileName);
    dialog.dismiss();
});
🤖 Prompt for AI Agents
In app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java around
lines 88 to 93, the positive button's onClick lambda uses a return to stop
processing but AlertDialog will auto-dismiss regardless; change to set the
positive button click handler after dialog.show() using
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(...), validate
the trimmed profileName inside that click listener, show the toast and do
nothing if empty, and only call addProfile(profileName) followed by
dialog.dismiss() when the name is valid; ensure the dialog and input variables
are final or effectively final so they are accessible in the post-show click
listener.

Copy link
Collaborator

Choose a reason for hiding this comment

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

showAddDialog also only checks for empty profile names. If the user provides a name containing only invalid characters, it is considered valid, go client then sanitizes the name and saves it as a profile without a name that can't be switched to, or removed.

Have to decide whether it will detect invalid characters at input time, at Profile object creation to tell the user to input a valid name or fail either in the ProfileManagerWrapper or go client's ProfileManager when attempting to create a profile with an empty name after sanitization.

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look at ProfileManagerWrapper implementation to see validation logic
cat tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java

Length of output: 4816


🏁 Script executed:

#!/bin/bash
# Look at the addProfile method in ProfilesFragment to see how errors are currently handled
ast-grep --pattern $'private void addProfile($$$) {
  $$$
}'

Length of output: 2460


🏁 Script executed:

#!/bin/bash
# Search for any profile name validation patterns or constants
rg -nP --type=java -C3 'profile.*name|validateProfile|isValid.*name' --iglob '*Profile*'

Length of output: 4306


🏁 Script executed:

#!/bin/bash
# Look for any error strings related to invalid profile names
rg -n 'profiles_error_invalid_name' app/src/main/res/values/strings.xml

Length of output: 132


🏁 Script executed:

#!/bin/bash
# Search for profile name sanitization or validation in the Go code
rg -nP --type=go -C5 'sanitize|Sanitize|cleanName|CleanName|invalidChar|InvalidChar' --iglob '*profile*'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for regex patterns or character validation in Go profile code
rg -nP --type=go -C5 'regexp|Regexp|Match|pattern|[a-zA-Z0-9_-]' --iglob '*profile*' | head -80

Length of output: 7226


@doromaraujo, excellent catch! You're right that the current validation is insufficient. I recommend implementing validation at both the UI layer and ProfileManagerWrapper for defense in depth:

Recommended Approach

1. UI Layer (showAddDialog) - for immediate user feedback
2. ProfileManagerWrapper - as a safety net before calling Go client

Implementation

UI Layer Validation (ProfilesFragment.java)

Add a validation method and update showAddDialog:

private static final String PROFILE_NAME_PATTERN = "^[a-zA-Z0-9_-]+$";

private boolean isValidProfileName(String name) {
    if (name == null || name.trim().isEmpty()) {
        return false;
    }
    // Match letters, numbers, hyphens, and underscores only
    return name.matches(PROFILE_NAME_PATTERN);
}

private void showAddDialog() {
    View dialogView = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_simple_alert_message, null);
    EditText input = new EditText(requireContext());
    input.setHint(R.string.profiles_dialog_add_hint);

    AlertDialog dialog = new AlertDialog.Builder(requireContext())
            .setTitle(R.string.profiles_dialog_add_title)
            .setMessage(R.string.profiles_dialog_add_message)
            .setView(input)
            .setPositiveButton(android.R.string.ok, null) // Set listener after show()
            .setNegativeButton(android.R.string.cancel, null)
            .create();
    
    dialog.show();
    
    // Override button to prevent auto-dismiss on validation failure
    dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
        String profileName = input.getText().toString().trim();
        
        if (profileName.isEmpty()) {
            Toast.makeText(requireContext(), R.string.profiles_error_empty_name, Toast.LENGTH_SHORT).show();
            return;
        }
        
        if (!isValidProfileName(profileName)) {
            Toast.makeText(requireContext(), R.string.profiles_error_invalid_name, Toast.LENGTH_SHORT).show();
            return;
        }
        
        addProfile(profileName);
        dialog.dismiss();
    });
}

ProfileManagerWrapper Layer (ProfileManagerWrapper.java)

Add validation as a safety net:

private static final String PROFILE_NAME_PATTERN = "^[a-zA-Z0-9_-]+$";

/**
 * Creates a new profile
 */
public void addProfile(String profileName) throws Exception {
    if (profileName == null || profileName.trim().isEmpty()) {
        throw new IllegalArgumentException("Profile name cannot be empty");
    }
    
    // Validate that name contains only valid characters
    if (!profileName.matches(PROFILE_NAME_PATTERN)) {
        throw new IllegalArgumentException("Profile name can only contain letters, numbers, hyphens, and underscores");
    }
    
    profileManager.addProfile(profileName);
}

Benefits of This Approach

  1. UI validation - Provides immediate feedback without network/disk operations, prevents dialog dismissal
  2. Wrapper validation - Catches edge cases if called from other code paths, prevents sanitized-to-empty profiles
  3. Consistent pattern - Both layers use the same regex matching the existing error string
  4. User-friendly - Clear error messages guide users to valid input

This also addresses the dialog dismissal issue from my previous comment. The validation runs before dialog.dismiss() is called, keeping the dialog open on invalid input.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Agree, I will update

Copy link

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Comment on lines +25 to +31
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Profile profile = (Profile) o;
return name.equals(profile.name);
}
Copy link

@coderabbitai coderabbitai bot Dec 3, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential NPE in equals() if name is null.

If a Profile is constructed with a null name, name.equals(profile.name) will throw a NullPointerException.

Consider adding null-safety:

 @Override
 public boolean equals(Object o) {
     if (this == o) return true;
     if (o == null || getClass() != o.getClass()) return false;
     Profile profile = (Profile) o;
-    return name.equals(profile.name);
+    return name != null ? name.equals(profile.name) : profile.name == null;
 }

Or use Objects.equals(name, profile.name) from java.util.Objects.

📝 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
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Profile profile = (Profile) o;
return name.equals(profile.name);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Profile profile = (Profile) o;
return name != null ? name.equals(profile.name) : profile.name == null;
}
🤖 Prompt for AI Agents
In tool/src/main/java/io/netbird/client/tool/Profile.java around lines 25 to 31,
the equals() method calls name.equals(profile.name) which can throw NPE when
name is null; change the comparison to a null-safe check by using
java.util.Objects.equals(name, profile.name) (add the import if missing) so
equality works when either name is null; also ensure hashCode() is consistent
with equals (use Objects.hashCode(name) if present).

Copy link
Collaborator

Choose a reason for hiding this comment

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

Either do this null-safety check here, keep Profile instances from being created with a null name or both.

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all instantiations of Profile class to check if any might pass null
rg -nP --type=java -C3 'new\s+Profile\s*\('

Length of output: 808


🏁 Script executed:

#!/bin/bash
# Check ProfileManagerWrapper's listProfiles method to see how Profile objects are created
ast-grep --pattern $'public List<Profile> listProfiles() {
  $$$
}'

Length of output: 1860


🏁 Script executed:

#!/bin/bash
# Look for any other usages of Profile constructor or Profile class
rg -nP --type=java -C5 'Profile\s+\w+\s*='

Length of output: 3262


@doromaraujo, I recommend implementing both approaches for defense in depth:

  1. Constructor validation - Prevents creating invalid Profile objects (fail-fast)
  2. Null-safe equals/hashCode - Additional safety layer

Based on my analysis, Profile objects are created from gomobile bindings in ProfileManagerWrapper.listProfiles() where p.getName() could potentially return null. A Profile without a name is semantically invalid, so constructor validation makes sense.

Here's the complete solution:

package io.netbird.client.tool;

import java.util.Objects;

public class Profile {
    private final String name;
    private final boolean isActive;

    public Profile(String name, boolean isActive) {
        this.name = Objects.requireNonNull(name, "Profile name cannot be null");
        this.isActive = isActive;
    }

    public String getName() {
        return name;
    }

    public boolean isActive() {
        return isActive;
    }

    @Override
    public String toString() {
        return name + (isActive ? " (Active)" : "");
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Profile profile = (Profile) o;
        return Objects.equals(name, profile.name);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(name);
    }
}

Benefits:

  • Constructor validation ensures no Profile is ever created with a null name (throws NullPointerException immediately with a clear message)
  • Objects.equals() and Objects.hashCode() provide null-safe implementations that remain consistent
  • The code is more robust and follows Java best practices for value objects

Comment on lines +33 to +36
@Override
public int hashCode() {
return name.hashCode();
}
Copy link

@coderabbitai coderabbitai bot Dec 3, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

hashCode() can throw NPE if name is null.

Similar to equals(), if name is null, name.hashCode() will throw.

 @Override
 public int hashCode() {
-    return name.hashCode();
+    return name != null ? name.hashCode() : 0;
 }

Or use Objects.hashCode(name).

📝 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
@Override
public int hashCode() {
return name.hashCode();
}
@Override
public int hashCode() {
return name != null ? name.hashCode() : 0;
}
🤖 Prompt for AI Agents
In tool/src/main/java/io/netbird/client/tool/Profile.java around lines 33 to 36,
the current hashCode() calls name.hashCode() which will throw a
NullPointerException if name is null; change the implementation to handle null
safely by returning 0 (or another fixed value) when name is null or use
Objects.hashCode(name) so null is handled automatically, and ensure import
java.util.Objects is present if you choose that approach.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Either do this null-safety check here, keep Profile instances from being created with a null name or both.

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all places where Profile objects are instantiated
rg -n "new Profile\(" --type=java -C3

# Check ProfileManagerWrapper implementation for how it creates Profile objects
cat tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java

# Look for any existing validation patterns in the codebase
rg -n "requireNonNull|checkNotNull|isEmpty.*throw|\\bnull.*throw" --type=java -C2

Length of output: 7335


@doromaraujo I recommend both approaches, with primary emphasis on constructor validation (fail-fast):

Primary: Add validation in the constructor

Based on the codebase patterns, especially in ProfileManagerWrapper which validates profile names, the constructor should prevent null or empty names:

public Profile(String name, boolean isActive) {
    if (name == null || name.trim().isEmpty()) {
        throw new IllegalArgumentException("Profile name cannot be null or empty");
    }
    this.name = name;
    this.isActive = isActive;
}

This matches the validation style used in ProfileManagerWrapper methods like switchProfile() and addProfile().

Secondary: Add defensive check in hashCode()

Even with constructor validation, it's good practice to be defensive in hashCode():

@Override
public int hashCode() {
    return name != null ? name.hashCode() : 0;
}

Why both?

  1. Constructor validation ensures no invalid Profile objects are ever created (fail-fast principle)
  2. The defensive check in hashCode() protects against edge cases like deserialization or reflection-based instantiation
  3. This is consistent with the codebase's existing validation patterns in ProfileManagerWrapper

Would you like me to provide a complete code suggestion with both changes?

Comment on lines +65 to +74
public void switchProfile(String profileName) throws Exception {
if (profileName == null || profileName.trim().isEmpty()) {
throw new IllegalArgumentException("Profile name cannot be empty");
}

// Stop VPN service before switching profile
stopEngine();

profileManager.switchProfile(profileName);
}
Copy link

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:

cd tool/src/main/java/io/netbird/client/tool && cat -n ProfileManagerWrapper.java | head -80

Repository: netbirdio/android-client

Length of output: 3125


🏁 Script executed:

# Search for stopEngine implementation and related broadcast code
rg -n "stopEngine|ACTION_STOP_ENGINE" --type=java -A 5 -B 2

Repository: netbirdio/android-client

Length of output: 17821


🏁 Script executed:

# Search for VPNService to understand broadcast handling
rg -n "class VPNService|sendBroadcast" --type=java -A 3

Repository: netbirdio/android-client

Length of output: 2277


🏁 Script executed:

# Check if profileManager.switchProfile accesses any engine state or config
rg -n "switchProfile|profileManager.switchProfile" --type=java -B 3 -A 3

Repository: netbirdio/android-client

Length of output: 3502


🏁 Script executed:

# Look for any synchronization mechanisms (locks, CountdownLatch, etc.) around stopEngine
rg -n "synchronized|Lock|CountdownLatch|Semaphore|wait\(\)|notify\(\)" tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java

Repository: netbirdio/android-client

Length of output: 50


🏁 Script executed:

# Search for EngineRunner class and stop method more thoroughly
fd -n "EngineRunner" --type f --extension java

Repository: netbirdio/android-client

Length of output: 298


🏁 Script executed:

# Check if there's documentation or comments about thread safety
rg -n "EngineRunner|engineRunner\.stop" --type=java -B 2 -A 8 | head -100

Repository: netbirdio/android-client

Length of output: 9379


🏁 Script executed:

# Get the full EngineRunner.java file to see stop() implementation
wc -l tool/src/main/java/io/netbird/client/tool/EngineRunner.java

Repository: netbirdio/android-client

Length of output: 131


🏁 Script executed:

# Read EngineRunner to find stop() method
cat tool/src/main/java/io/netbird/client/tool/EngineRunner.java | head -150

Repository: netbirdio/android-client

Length of output: 6018


🏁 Script executed:

# Read the rest of EngineRunner to find stop() method
tail -n +150 tool/src/main/java/io/netbird/client/tool/EngineRunner.java

Repository: netbirdio/android-client

Length of output: 2653


Race condition confirmed: profile switch executes before engine actually stops.

stopEngine() sends an asynchronous broadcast (line 138), but switchProfile() is called synchronously on line 73 without waiting for the engine to stop. The BroadcastReceiver.onReceive() handler in VPNService (which calls engineRunner.stop()) executes later on the main thread, potentially after the profile has already been switched. Although EngineRunner.stop() itself is synchronized, this doesn't prevent the race since the broadcast delivery is inherently asynchronous.

To fix this, consider using a direct synchronous call to stop the engine (e.g., via service binding) instead of broadcasting, or add a synchronization mechanism (e.g., CountDownLatch or callback) to confirm engine stop before proceeding with the profile switch.

🤖 Prompt for AI Agents
tool/src/main/java/io/netbird/client/tool/ProfileManagerWrapper.java around
lines 65-74: the current switchProfile calls stopEngine() which only sends an
asynchronous broadcast, causing a race where
profileManager.switchProfile(profileName) executes before the engine actually
stops; change stopEngine to use a synchronous stop path (preferably bind to the
VPN service and call a direct stop method on the service/EngineRunner) or
implement a synchronization handshake (e.g., send the stop intent but block with
a CountDownLatch or wait on a callback that the VPNService/EngineRunner signals
when fully stopped) and include a reasonable timeout and proper exception
handling so switchProfile only calls profileManager.switchProfile(profileName)
after the engine stop confirmation.

Comment on lines +134 to +144
private void stopEngine() {
try {
Intent stopIntent = new Intent(VPNService.ACTION_STOP_ENGINE);
stopIntent.setPackage(context.getPackageName());
context.sendBroadcast(stopIntent);
Log.d(TAG, "Sent stop engine broadcast for profile operation");
} catch (Exception e) {
Log.w(TAG, "Failed to send stop engine broadcast: " + e.getMessage());
// Don't throw exception - profile operations should continue even if stop fails
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

We could use a similar approach that was used with EngineRestarter, wrapping EngineRunner to a class to both stop the engine without a broadcast receiver and to track for sure that it was stopped using the ServiceStateListener interface and pass it as a parameter to ProfileManagerWrapper.

If using this approach, the ProfileManagerWrapper instance could be created in VPNService (either when bound or created), and provided to the ProfilesFragment via the ServiceAccessor interface.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants