-
Notifications
You must be signed in to change notification settings - Fork 41
Add profile switch #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add profile switch #115
Conversation
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.
WalkthroughAdds 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45–75 minutes
Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
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. Comment |
There was a problem hiding this 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 handlingUsing
ProfileManagerWrapper.getActiveConfigPath()to populateCONFIG_FILE_PATH_KEYkeeps this aligned with the new profile model. The hardRuntimeExceptionon 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 groupsThe new
group_profileandnav_profilesitem 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 failureInitializing
goPreferencesfromProfileManagerWrapper.getActiveConfigPath()correctly ties advanced settings to the active profile. However, converting any failure here into aRuntimeExceptionwill 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.
dialogViewis inflated on line 80 but never used. The dialog only uses theEditTextcreated 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 theOnBackPressedDispatcherorNavController.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.
ImageViewis imported but not used in this file.-import android.widget.ImageView;
82-89: Hardcoded "default" profile name.The string
"default"is hardcoded here and inProfilesFragment.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
selectRouteanddeselectRoutedeclarethrows Exceptionbut silently return whenmBinderis null instead of throwing. This inconsistency can mislead callers who expect an exception on failure.Consider either:
- Throwing an exception when
mBinderis null to match the signature- Removing
throws Exceptionif 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: InstantiatingProfileManagerWrapperon every call is inefficient.
updateProfileMenuItemis called fromonCreate,onResume, and destination changes. Each invocation creates a newProfileManagerWrapperinstance. 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 inRuntimeExceptionloses error context.Re-throwing a generic
RuntimeExceptionobscures 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 forStateListener.All these empty implementations add boilerplate. If
StateListeneris under your control, adding default empty implementations to the interface would simplify this ViewModel. Alternatively, create aStateListenerAdapterbase class.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 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
49cd86440f222682f21c17029b1e8a5cc3ad6d05provides 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 identicalThe lambda correctly preserves the previous behavior (resetting
isOpenedand invokingresultCallback.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_profilescorrectly targetsio.netbird.client.ui.profile.ProfilesFragmentwith 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 soundThe 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 itUsing
ServiceAccessorviaonAttachand passing it intoNetworksFragmentViewModel.getFactory(serviceAccessor)is consistent and keeps the ViewModel decoupled from the Fragment. Just make sure every activity that can hostNetworksFragmentnow implementsServiceAccessor, 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 solidRecyclerView 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
ProfileManagerWrapperis well-structured and enables profile switching without recreating the entire client. Error handling withRuntimeExceptionis appropriate for this critical path.
87-90: Uncaught RuntimeException on background thread.If
profileManager.getActiveConfigPath()orgetActiveStateFilePath()fails, theRuntimeExceptionis thrown inside aRunnableon 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 betweengetActiveProfile()and conditionalstopEngine().There's a time-of-check-to-time-of-use gap: the active profile could change between the
getActiveProfile()call on line 96 and thestopEngine()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
ProfileManagerWrapperis properly instantiated and passed toEngineRunner, 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
stopEngineReceiveris correctly registered withRECEIVER_NOT_EXPORTEDflag, ensuring only internal broadcasts are received. The null check onengineRunnerbefore callingstop()is good defensive practice.
136-143: LGTM - Proper cleanup with exception handling.The receiver unregistration in
onDestroy()properly catchesIllegalArgumentExceptionin 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 onbindingandbinding.navVieware 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 ofpostValuefor background thread updates.Using
postValueinstead ofsetValueis 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.
| 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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| .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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
- UI validation - Provides immediate feedback without network/disk operations, prevents dialog dismissal
- Wrapper validation - Catches edge cases if called from other code paths, prevents sanitized-to-empty profiles
- Consistent pattern - Both layers use the same regex matching the existing error string
- 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree, I will update
There was a problem hiding this comment.
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!
| @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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| @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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- Constructor validation - Prevents creating invalid Profile objects (fail-fast)
- 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()andObjects.hashCode()provide null-safe implementations that remain consistent- The code is more robust and follows Java best practices for value objects
| @Override | ||
| public int hashCode() { | ||
| return name.hashCode(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| @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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
- Constructor validation ensures no invalid
Profileobjects are ever created (fail-fast principle) - The defensive check in
hashCode()protects against edge cases like deserialization or reflection-based instantiation - 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?
| 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); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cd tool/src/main/java/io/netbird/client/tool && cat -n ProfileManagerWrapper.java | head -80Repository: 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 2Repository: netbirdio/android-client
Length of output: 17821
🏁 Script executed:
# Search for VPNService to understand broadcast handling
rg -n "class VPNService|sendBroadcast" --type=java -A 3Repository: 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 3Repository: 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.javaRepository: netbirdio/android-client
Length of output: 50
🏁 Script executed:
# Search for EngineRunner class and stop method more thoroughly
fd -n "EngineRunner" --type f --extension javaRepository: 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 -100Repository: 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.javaRepository: 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 -150Repository: 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.javaRepository: 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.
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
Summary by CodeRabbit
New Features
Refactor
✏️ Tip: You can customize this high-level summary in your review settings.