diff --git a/app/src/main/java/io/netbird/client/CustomTabURLOpener.java b/app/src/main/java/io/netbird/client/CustomTabURLOpener.java index 1f27953..471baa6 100644 --- a/app/src/main/java/io/netbird/client/CustomTabURLOpener.java +++ b/app/src/main/java/io/netbird/client/CustomTabURLOpener.java @@ -28,12 +28,9 @@ public CustomTabURLOpener(AppCompatActivity activity, OnCustomTabResult resultC this.context = activity; this.customTabLauncher = activity.registerForActivityResult( - new ActivityResultContracts.StartActivityForResult(), new ActivityResultCallback() { - @Override - public void onActivityResult(ActivityResult o) { - isOpened = false; - resultCallback.onClosed(); - } + new ActivityResultContracts.StartActivityForResult(), o -> { + isOpened = false; + resultCallback.onClosed(); } ); } @@ -58,9 +55,33 @@ public void onLoginSuccess() { public void open(String url, String userCode) { isOpened = true; try { + // Force Google account picker by setting prompt=select_account + // This allows users to choose which Google account to use + String modifiedUrl = url; + + // Check if this is an OAuth URL (login.netbird.io, accounts.google.com, or has oauth/authorize) + if (url.contains("/authorize") || url.contains("oauth") || url.contains("accounts.google.com")) { + Uri uri = Uri.parse(url); + Uri.Builder builder = uri.buildUpon().clearQuery(); + + // Copy all existing parameters except 'prompt' + for (String paramName : uri.getQueryParameterNames()) { + if (!paramName.equals("prompt")) { + for (String value : uri.getQueryParameters(paramName)) { + builder.appendQueryParameter(paramName, value); + } + } + } + + // Add prompt=select_account to force account picker + builder.appendQueryParameter("prompt", "select_account"); + modifiedUrl = builder.build().toString(); + Log.i(TAG, "Replaced prompt parameter with select_account. Modified URL: " + modifiedUrl); + } + CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder().build(); Intent intent = customTabsIntent.intent; - intent.setData(Uri.parse(url)); + intent.setData(Uri.parse(modifiedUrl)); customTabLauncher.launch(intent); } catch (Exception e) { Log.e(TAG, "Failed to launch CustomTab: " + e.getMessage()); diff --git a/app/src/main/java/io/netbird/client/MainActivity.java b/app/src/main/java/io/netbird/client/MainActivity.java index e642d1c..666b950 100644 --- a/app/src/main/java/io/netbird/client/MainActivity.java +++ b/app/src/main/java/io/netbird/client/MainActivity.java @@ -116,6 +116,9 @@ protected void onCreate(Bundle savedInstanceState) { // Set the listener for menu item selections navigationView.setNavigationItemSelectedListener(this); + + // Update profile menu item with active profile name + updateProfileMenuItem(navigationView); // On TV, request focus when drawer opens so D-pad navigation works if (isRunningOnTV) { @@ -165,6 +168,10 @@ public void onDrawerClosed(View drawerView) { navController.addOnDestinationChangedListener((controller, destination, arguments) -> { if (destination.getId() == R.id.nav_home) { removeToolbarShadow(); + // Update profile menu item when returning to home (e.g., after profile switch) + if (binding != null && binding.navView != null) { + updateProfileMenuItem(binding.navView); + } } else { resetToolbar(); } @@ -253,6 +260,10 @@ public void onStart() { @Override protected void onResume() { super.onResume(); + // Update profile menu item when returning to MainActivity + if (binding != null && binding.navView != null) { + updateProfileMenuItem(binding.navView); + } } @Override @@ -600,6 +611,22 @@ public void onError(String msg) { } }; + 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); + 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); + } + } + @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (!isRunningOnTV) { @@ -609,7 +636,7 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { Log.d(LOGTAG, "Key pressed: " + keyCode + " (" + KeyEvent.keyCodeToString(keyCode) + "), repeat: " + event.getRepeatCount()); if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { - boolean isOnHomeScreen = navController != null && + boolean isOnHomeScreen = navController != null && navController.getCurrentDestination() != null && navController.getCurrentDestination().getId() == R.id.nav_home; diff --git a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java index 5e7a25b..ed060c5 100644 --- a/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java +++ b/app/src/main/java/io/netbird/client/ui/advanced/AdvancedFragment.java @@ -22,6 +22,7 @@ import io.netbird.client.databinding.FragmentAdvancedBinding; import io.netbird.client.tool.Logcat; import io.netbird.client.tool.Preferences; +import io.netbird.client.tool.ProfileManagerWrapper; public class AdvancedFragment extends Fragment { @@ -67,7 +68,14 @@ private void configureForceRelayConnectionSwitch(@NonNull ComponentSwitchBinding public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - String configFilePath = Preferences.configFile(inflater.getContext()); + // Get config path from ProfileManager instead of constructing it + ProfileManagerWrapper profileManager = new ProfileManagerWrapper(inflater.getContext()); + String configFilePath; + try { + configFilePath = profileManager.getActiveConfigPath(); + } catch (Exception e) { + throw new RuntimeException("Failed to get config path: " + e.getMessage(), e); + } goPreferences = new io.netbird.gomobile.android.Preferences(configFilePath); binding = FragmentAdvancedBinding.inflate(inflater, container, false); @@ -318,7 +326,14 @@ private boolean isValidPresharedKey(String key) { } 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); try { preferences.setPreSharedKey(key); @@ -331,7 +346,14 @@ private void setPreSharedKey(String key, Context context) { } private boolean hasPreSharedKey(Context context) { - String configFilePath = Preferences.configFile(context); + ProfileManagerWrapper profileManager = new ProfileManagerWrapper(context); + String configFilePath; + try { + configFilePath = profileManager.getActiveConfigPath(); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to get config path", e); + return false; + } io.netbird.gomobile.android.Preferences preferences = new io.netbird.gomobile.android.Preferences(configFilePath); try { return !preferences.getPreSharedKey().isEmpty(); diff --git a/app/src/main/java/io/netbird/client/ui/profile/ProfilesAdapter.java b/app/src/main/java/io/netbird/client/ui/profile/ProfilesAdapter.java new file mode 100644 index 0000000..42c4340 --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/profile/ProfilesAdapter.java @@ -0,0 +1,108 @@ +package io.netbird.client.ui.profile; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +import io.netbird.client.R; +import io.netbird.client.tool.Profile; + +public class ProfilesAdapter extends RecyclerView.Adapter { + + private final List profiles; + private final ProfileActionListener listener; + + public interface ProfileActionListener { + void onSwitchProfile(Profile profile); + void onLogoutProfile(Profile profile); + void onRemoveProfile(Profile profile); + } + + public ProfilesAdapter(List profiles, ProfileActionListener listener) { + this.profiles = profiles; + this.listener = listener; + } + + @NonNull + @Override + public ProfileViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.list_item_profile, parent, false); + return new ProfileViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ProfileViewHolder holder, int position) { + Profile profile = profiles.get(position); + holder.bind(profile, listener); + } + + @Override + public int getItemCount() { + return profiles.size(); + } + + static class ProfileViewHolder extends RecyclerView.ViewHolder { + private final TextView textName; + private final TextView badgeActive; + private final Button btnSwitch; + private final Button btnLogout; + private final Button btnRemove; + + public ProfileViewHolder(@NonNull View itemView) { + super(itemView); + textName = itemView.findViewById(R.id.text_profile_name); + badgeActive = itemView.findViewById(R.id.badge_active); + btnSwitch = itemView.findViewById(R.id.btn_switch); + btnLogout = itemView.findViewById(R.id.btn_logout); + btnRemove = itemView.findViewById(R.id.btn_remove); + } + + public void bind(Profile profile, ProfileActionListener listener) { + textName.setText(profile.getName()); + + if (profile.isActive()) { + badgeActive.setVisibility(View.VISIBLE); + btnSwitch.setEnabled(false); + btnSwitch.setText(R.string.profiles_active); + } else { + badgeActive.setVisibility(View.GONE); + btnSwitch.setEnabled(true); + btnSwitch.setText(R.string.profiles_switch); + } + + // Disable remove for default profile + if (profile.getName().equals("default")) { + btnRemove.setEnabled(false); + btnRemove.setAlpha(0.5f); + } else { + btnRemove.setEnabled(true); + btnRemove.setAlpha(1.0f); + } + + btnSwitch.setOnClickListener(v -> { + if (!profile.isActive()) { + listener.onSwitchProfile(profile); + } + }); + + btnLogout.setOnClickListener(v -> { + listener.onLogoutProfile(profile); + }); + + btnRemove.setOnClickListener(v -> { + if (!profile.getName().equals("default")) { + listener.onRemoveProfile(profile); + } + }); + } + } +} diff --git a/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java b/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java new file mode 100644 index 0000000..0f976c0 --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java @@ -0,0 +1,221 @@ +package io.netbird.client.ui.profile; + +import android.app.AlertDialog; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import java.util.ArrayList; +import java.util.List; + +import io.netbird.client.R; +import io.netbird.client.tool.Profile; +import io.netbird.client.tool.ProfileManagerWrapper; + +public class ProfilesFragment extends Fragment { + private static final String TAG = "ProfilesFragment"; + + private RecyclerView recyclerView; + private ProfilesAdapter adapter; + private ProfileManagerWrapper profileManager; + private List profiles = new ArrayList<>(); + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_profiles, container, false); + + // Initialize profile manager + profileManager = new ProfileManagerWrapper(requireContext()); + + recyclerView = view.findViewById(R.id.recycler_profiles); + recyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + + adapter = new ProfilesAdapter(profiles, new ProfilesAdapter.ProfileActionListener() { + @Override + public void onSwitchProfile(Profile profile) { + showSwitchDialog(profile); + } + + @Override + public void onLogoutProfile(Profile profile) { + showLogoutDialog(profile); + } + + @Override + public void onRemoveProfile(Profile profile) { + showRemoveDialog(profile); + } + }); + recyclerView.setAdapter(adapter); + + FloatingActionButton btnAdd = view.findViewById(R.id.btn_add_profile); + btnAdd.setOnClickListener(v -> showAddDialog()); + + loadProfiles(); + + return view; + } + + private void loadProfiles() { + profiles.clear(); + List loadedProfiles = profileManager.listProfiles(); + profiles.addAll(loadedProfiles); + adapter.notifyDataSetChanged(); + } + + 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, (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); + }) + .setNegativeButton(android.R.string.cancel, null) + .create(); + + dialog.show(); + } + + private void showSwitchDialog(Profile profile) { + String message = getString(R.string.profiles_dialog_switch_message, profile.getName()); + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.profiles_dialog_switch_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, (d, which) -> switchProfile(profile)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void showLogoutDialog(Profile profile) { + String message = getString(R.string.profiles_dialog_logout_message, profile.getName()); + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.profiles_dialog_logout_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, (d, which) -> logoutProfile(profile)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void showRemoveDialog(Profile profile) { + String message = getString(R.string.profiles_dialog_remove_message, profile.getName()); + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.profiles_dialog_remove_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, (d, which) -> removeProfile(profile)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void addProfile(String profileName) { + try { + profileManager.addProfile(profileName); + Toast.makeText(requireContext(), + getString(R.string.profiles_success_added, profileName), + Toast.LENGTH_SHORT).show(); + loadProfiles(); + } catch (Exception e) { + Log.e(TAG, "Failed to add profile", e); + String errorMsg = e.getMessage(); + if (errorMsg != null && errorMsg.contains("already exists")) { + Toast.makeText(requireContext(), + getString(R.string.profiles_error_already_exists, profileName), + Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(requireContext(), + "Failed to add profile: " + e.getMessage(), + Toast.LENGTH_SHORT).show(); + } + } + } + + private void switchProfile(Profile profile) { + try { + // Switch profile (VPN service will be stopped automatically in ProfileManagerWrapper) + profileManager.switchProfile(profile.getName()); + + Toast.makeText(requireContext(), + getString(R.string.profiles_success_switched, profile.getName()), + Toast.LENGTH_SHORT).show(); + + loadProfiles(); + + // Navigate back to home + requireActivity().onBackPressed(); + } catch (Exception e) { + Log.e(TAG, "Failed to switch profile", e); + Toast.makeText(requireContext(), + "Failed to switch profile: " + e.getMessage(), + Toast.LENGTH_SHORT).show(); + } + } + + private void logoutProfile(Profile profile) { + try { + // Logout from profile (VPN service will be stopped automatically if it's the active profile) + profileManager.logoutProfile(profile.getName()); + + Toast.makeText(requireContext(), + getString(R.string.profiles_success_logged_out, profile.getName()), + Toast.LENGTH_SHORT).show(); + + loadProfiles(); + } catch (Exception e) { + Log.e(TAG, "Failed to logout from profile", e); + Toast.makeText(requireContext(), + "Failed to logout: " + e.getMessage(), + Toast.LENGTH_SHORT).show(); + } + } + + private void removeProfile(Profile profile) { + try { + if (profile.getName().equals("default")) { + Toast.makeText(requireContext(), + R.string.profiles_error_cannot_remove_default, + Toast.LENGTH_SHORT).show(); + return; + } + + if (profile.isActive()) { + Toast.makeText(requireContext(), + R.string.profiles_error_cannot_remove_active, + Toast.LENGTH_SHORT).show(); + return; + } + + profileManager.removeProfile(profile.getName()); + Toast.makeText(requireContext(), + getString(R.string.profiles_success_removed, profile.getName()), + Toast.LENGTH_SHORT).show(); + loadProfiles(); + } catch (Exception e) { + Log.e(TAG, "Failed to remove profile", e); + Toast.makeText(requireContext(), + "Failed to remove profile: " + e.getMessage(), + Toast.LENGTH_SHORT).show(); + } + } +} diff --git a/app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java b/app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java index 893e2bd..0e9fa4c 100644 --- a/app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java +++ b/app/src/main/java/io/netbird/client/ui/server/ChangeServerFragment.java @@ -27,6 +27,7 @@ import io.netbird.client.ServiceAccessor; import io.netbird.client.databinding.FragmentServerBinding; import io.netbird.client.tool.Preferences; +import io.netbird.client.tool.ProfileManagerWrapper; public class ChangeServerFragment extends Fragment { @@ -107,8 +108,17 @@ private void clearErrorFlags() { public CreationExtras getDefaultViewModelCreationExtras() { final var defaultExtras = super.getDefaultViewModelCreationExtras(); + // Get config path from ProfileManager instead of constructing it + ProfileManagerWrapper profileManager = new ProfileManagerWrapper(requireContext()); + String configFilePath; + try { + configFilePath = profileManager.getActiveConfigPath(); + } catch (Exception e) { + throw new RuntimeException("Failed to get config path: " + e.getMessage(), e); + } + final var extras = new MutableCreationExtras(defaultExtras); - extras.set(ChangeServerFragmentViewModel.CONFIG_FILE_PATH_KEY, Preferences.configFile(requireContext())); + extras.set(ChangeServerFragmentViewModel.CONFIG_FILE_PATH_KEY, configFilePath); extras.set(ChangeServerFragmentViewModel.DEVICE_NAME_KEY, deviceName()); extras.set(ChangeServerFragmentViewModel.STOP_ENGINE_COMMAND_KEY, (ChangeServerFragmentViewModel.Operation) () -> serviceAccessor.stopEngine()); diff --git a/app/src/main/res/drawable/ic_menu_profile.xml b/app/src/main/res/drawable/ic_menu_profile.xml new file mode 100644 index 0000000..114cef0 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_profile.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_profiles.xml b/app/src/main/res/layout/fragment_profiles.xml new file mode 100644 index 0000000..0d552b8 --- /dev/null +++ b/app/src/main/res/layout/fragment_profiles.xml @@ -0,0 +1,37 @@ + + + + + + + + diff --git a/app/src/main/res/layout/list_item_profile.xml b/app/src/main/res/layout/list_item_profile.xml new file mode 100644 index 0000000..4233931 --- /dev/null +++ b/app/src/main/res/layout/list_item_profile.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + +