diff --git a/app/src/main/java/io/netbird/client/CustomTabURLOpener.java b/app/src/main/java/io/netbird/client/CustomTabURLOpener.java index 1f27953..4065b5f 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(); } ); } diff --git a/app/src/main/java/io/netbird/client/MainActivity.java b/app/src/main/java/io/netbird/client/MainActivity.java index 0a72dfa..666b950 100644 --- a/app/src/main/java/io/netbird/client/MainActivity.java +++ b/app/src/main/java/io/netbird/client/MainActivity.java @@ -2,11 +2,9 @@ import android.animation.StateListAnimator; import android.app.Activity; -import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; import android.content.ServiceConnection; import android.graphics.drawable.ColorDrawable; import android.net.Uri; @@ -32,7 +30,6 @@ import androidx.appcompat.app.AlertDialog; import androidx.core.content.ContextCompat; import androidx.core.view.GravityCompat; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.navigation.NavController; import androidx.navigation.Navigation; import androidx.navigation.ui.AppBarConfiguration; @@ -41,7 +38,7 @@ import androidx.appcompat.app.AppCompatActivity; import io.netbird.client.databinding.ActivityMainBinding; -import io.netbird.client.tool.NetworkChangeNotifier; +import io.netbird.client.tool.RouteChangeListener; import io.netbird.client.tool.ServiceStateListener; import io.netbird.client.tool.VPNService; import io.netbird.client.ui.PreferenceUI; @@ -119,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) { @@ -168,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(); } @@ -256,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 @@ -369,6 +377,46 @@ public NetworkArray getNetworks() { return mBinder.networks(); } + @Override + public void selectRoute(String route) throws Exception { + if (mBinder == null) { + Log.w(LOGTAG, "VPN binder is null"); + return; + } + + mBinder.selectRoute(route); + } + + @Override + public void deselectRoute(String route) throws Exception { + if (mBinder == null) { + Log.w(LOGTAG, "VPN binder is null"); + return; + } + + mBinder.deselectRoute(route); + } + + @Override + public void addRouteChangeListener(RouteChangeListener listener) { + if (mBinder == null) { + Log.w(LOGTAG, "VPN binder is null"); + return; + } + + mBinder.addRouteChangeListener(listener); + } + + @Override + public void removeRouteChangeListener(RouteChangeListener listener) { + if (mBinder == null) { + Log.w(LOGTAG, "VPN binder is null"); + return; + } + + mBinder.removeRouteChangeListener(listener); + } + @Override public void registerServiceStateListener(StateListener listener) { @@ -563,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) { @@ -572,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/MyApplication.java b/app/src/main/java/io/netbird/client/MyApplication.java index 42877dc..ea2493c 100644 --- a/app/src/main/java/io/netbird/client/MyApplication.java +++ b/app/src/main/java/io/netbird/client/MyApplication.java @@ -1,14 +1,9 @@ package io.netbird.client; import android.app.Application; -import android.content.IntentFilter; import android.content.SharedPreferences; import androidx.appcompat.app.AppCompatDelegate; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -import io.netbird.client.repository.VPNServiceRepository; -import io.netbird.client.tool.NetworkChangeNotifier; public class MyApplication extends Application { @@ -20,8 +15,4 @@ public void onCreate() { int themeMode = prefs.getInt("theme_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); AppCompatDelegate.setDefaultNightMode(themeMode); } - - public VPNServiceRepository getVPNServiceRepository() { - return new VPNServiceRepository(this); - } } \ No newline at end of file diff --git a/app/src/main/java/io/netbird/client/ServiceAccessor.java b/app/src/main/java/io/netbird/client/ServiceAccessor.java index e018aaf..bf17e44 100644 --- a/app/src/main/java/io/netbird/client/ServiceAccessor.java +++ b/app/src/main/java/io/netbird/client/ServiceAccessor.java @@ -1,5 +1,6 @@ package io.netbird.client; +import io.netbird.client.tool.RouteChangeListener; import io.netbird.gomobile.android.NetworkArray; import io.netbird.gomobile.android.PeerInfoArray; @@ -10,4 +11,10 @@ public interface ServiceAccessor { NetworkArray getNetworks(); void stopEngine(); + + void selectRoute(String route) throws Exception; + void deselectRoute(String route) throws Exception; + + void addRouteChangeListener(RouteChangeListener listener); + void removeRouteChangeListener(RouteChangeListener listener); } \ No newline at end of file diff --git a/app/src/main/java/io/netbird/client/repository/VPNServiceBindListener.java b/app/src/main/java/io/netbird/client/repository/VPNServiceBindListener.java deleted file mode 100644 index 6758514..0000000 --- a/app/src/main/java/io/netbird/client/repository/VPNServiceBindListener.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.netbird.client.repository; - -public interface VPNServiceBindListener { - void onServiceBind(); -} diff --git a/app/src/main/java/io/netbird/client/repository/VPNServiceRepository.java b/app/src/main/java/io/netbird/client/repository/VPNServiceRepository.java deleted file mode 100644 index 990ba7c..0000000 --- a/app/src/main/java/io/netbird/client/repository/VPNServiceRepository.java +++ /dev/null @@ -1,173 +0,0 @@ -package io.netbird.client.repository; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.os.IBinder; - -import java.util.ArrayList; -import java.util.List; - -import io.netbird.client.tool.RouteChangeListener; -import io.netbird.client.tool.VPNService; -import io.netbird.client.ui.home.NetworkDomain; -import io.netbird.client.ui.home.Resource; -import io.netbird.client.ui.home.RoutingPeer; -import io.netbird.client.ui.home.Status; -import io.netbird.gomobile.android.NetworkDomains; -import io.netbird.gomobile.android.PeerRoutes; - -public class VPNServiceRepository { - private VPNService.MyLocalBinder binder; - private final Context context; - private VPNServiceBindListener serviceBindListener; - - private final ServiceConnection serviceConnection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - binder = (VPNService.MyLocalBinder) service; - if (serviceBindListener != null) { - serviceBindListener.onServiceBind(); - } - } - - @Override - public void onServiceDisconnected(ComponentName name) { - if (binder != null) { - binder = null; - } - - serviceBindListener = null; - } - }; - - public VPNServiceRepository(Context context) { - this.context = context; - } - - private List createPeerRoutesList(PeerRoutes peerRoutes) { - List routes = new ArrayList<>(); - - try { - for (int i = 0; i < peerRoutes.size(); i++) { - routes.add(peerRoutes.get(i)); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - - return routes; - } - - private List createNetworkDomainsList(NetworkDomains networkDomains) { - List domains = new ArrayList<>(); - - io.netbird.gomobile.android.NetworkDomain goNetworkDomain; - NetworkDomain networkDomain; - String ipAddress; - - try { - for (int i = 0; i < networkDomains.size(); i++) { - goNetworkDomain = networkDomains.get(i); - networkDomain = new NetworkDomain(goNetworkDomain.getAddress()); - - var resolvedIPs = goNetworkDomain.getResolvedIPs(); - - for (int j = 0; j < resolvedIPs.size(); j++) { - ipAddress = resolvedIPs.get(j); - networkDomain.addResolvedIP(ipAddress); - } - - domains.add(networkDomain); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - - return domains; - } - - public void setServiceBindListener(VPNServiceBindListener listener) { - this.serviceBindListener = listener; - } - - public void bindService() { - var intent = new Intent(context, VPNService.class); - context.bindService(intent, serviceConnection, Context.BIND_ABOVE_CLIENT); - } - - public void unbindService() { - if (binder != null) { - context.unbindService(serviceConnection); - binder = null; - } - } - - public List getNetworks() { - if (binder == null) { - return new ArrayList<>(); - } - - var resources = new ArrayList(); - var networks = binder.networks(); - - for (int i = 0; i < networks.size(); i++) { - var network = networks.get(i); - var networkDomains = network.getNetworkDomains(); - - resources.add(new Resource(Status.fromString(network.getStatus()), - network.getName(), - network.getNetwork(), - network.getPeer(), - network.getIsSelected(), - createNetworkDomainsList(networkDomains))); - } - - return resources; - } - - public List getRoutingPeers() { - if (binder == null) { - return new ArrayList<>(); - } - - var peers = new ArrayList(); - var peersFromEngine = binder.peersInfo(); - - for (int i = 0; i < peersFromEngine.size(); i++) { - var peerInfo = peersFromEngine.get(i); - var peerRoutes = peerInfo.getPeerRoutes(); - - peers.add(new RoutingPeer( - Status.fromString(peerInfo.getConnStatus()), - createPeerRoutesList(peerRoutes))); - } - - return peers; - } - - public void addRouteChangeListener(RouteChangeListener listener) { - if (binder != null) { - binder.addRouteChangeListener(listener); - } - } - - public void removeRouteChangeListener(RouteChangeListener listener) { - if (binder != null) { - binder.removeRouteChangeListener(listener); - } - } - - public void selectRoute(String route) throws Exception { - if (binder != null) { - binder.selectRoute(route); - } - } - - public void deselectRoute(String route) throws Exception { - if (binder != null) { - binder.deselectRoute(route); - } - } -} 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/home/NetworksFragment.java b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java index 18f32bb..892528a 100644 --- a/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java +++ b/app/src/main/java/io/netbird/client/ui/home/NetworksFragment.java @@ -24,6 +24,7 @@ import io.netbird.client.PlatformUtils; import io.netbird.client.R; +import io.netbird.client.ServiceAccessor; import io.netbird.client.StateListenerRegistry; import io.netbird.client.databinding.FragmentNetworksBinding; @@ -35,6 +36,7 @@ public class NetworksFragment extends Fragment { private final List peers = new ArrayList<>(); private NetworksFragmentViewModel model; private StateListenerRegistry stateListenerRegistry; + private ServiceAccessor serviceAccessor; @Override public void onAttach(@NonNull Context context) { @@ -45,6 +47,12 @@ public void onAttach(@NonNull Context context) { } else { throw new RuntimeException(context + " must implement StateListenerRegistry"); } + + if (context instanceof ServiceAccessor) { + serviceAccessor = (ServiceAccessor) context; + } else { + throw new RuntimeException(context + " must implement ServiceAccessor"); + } } @Nullable @@ -58,8 +66,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - model = new ViewModelProvider(this, - ViewModelProvider.Factory.from(NetworksFragmentViewModel.initializer)) + model = new ViewModelProvider(this, NetworksFragmentViewModel.getFactory(serviceAccessor)) .get(NetworksFragmentViewModel.class); stateListenerRegistry.registerServiceStateListener(model); diff --git a/app/src/main/java/io/netbird/client/ui/home/NetworksFragmentViewModel.java b/app/src/main/java/io/netbird/client/ui/home/NetworksFragmentViewModel.java index 28b0343..a1ddb1a 100644 --- a/app/src/main/java/io/netbird/client/ui/home/NetworksFragmentViewModel.java +++ b/app/src/main/java/io/netbird/client/ui/home/NetworksFragmentViewModel.java @@ -1,63 +1,136 @@ package io.netbird.client.ui.home; -import static androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY; - +import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; -import androidx.lifecycle.viewmodel.ViewModelInitializer; +import androidx.lifecycle.ViewModelProvider; import java.util.ArrayList; +import java.util.List; -import io.netbird.client.MyApplication; +import io.netbird.client.ServiceAccessor; import io.netbird.client.StateListener; -import io.netbird.client.repository.VPNServiceBindListener; -import io.netbird.client.repository.VPNServiceRepository; import io.netbird.client.tool.RouteChangeListener; +import io.netbird.gomobile.android.NetworkDomains; +import io.netbird.gomobile.android.PeerRoutes; -public class NetworksFragmentViewModel extends ViewModel implements VPNServiceBindListener, RouteChangeListener, StateListener { - private final VPNServiceRepository repository; +public class NetworksFragmentViewModel extends ViewModel implements RouteChangeListener, StateListener { + private final ServiceAccessor serviceAccessor; private final MutableLiveData uiState = new MutableLiveData<>(new NetworksFragmentUiState(new ArrayList<>(), new ArrayList<>())); - public NetworksFragmentViewModel(VPNServiceRepository repository) { - this.repository = repository; - this.repository.setServiceBindListener(this); - this.repository.bindService(); + public NetworksFragmentViewModel(ServiceAccessor serviceAccessor) { + this.serviceAccessor = serviceAccessor; + serviceAccessor.addRouteChangeListener(this); + } + + public static ViewModelProvider.Factory getFactory(ServiceAccessor serviceAccessor) { + return new ViewModelProvider.Factory() { + @NonNull + @Override + public T create(@NonNull Class modelClass) { + if (modelClass.isAssignableFrom(NetworksFragmentViewModel.class)) { + return (T) new NetworksFragmentViewModel(serviceAccessor); + } + throw new IllegalArgumentException("Unknown ViewModel class"); + } + }; } @Override protected void onCleared() { super.onCleared(); - repository.removeRouteChangeListener(this); - repository.unbindService(); + serviceAccessor.removeRouteChangeListener(this); } public LiveData getUiState() { return uiState; } - private void postResources() { - var resources = repository.getNetworks(); - var peers = repository.getRoutingPeers(); + private List createPeerRoutesList(PeerRoutes peerRoutes) { + List routes = new ArrayList<>(); - // This value will be set from a background thread. - uiState.postValue(new NetworksFragmentUiState(resources, peers)); + try { + for (int i = 0; i < peerRoutes.size(); i++) { + routes.add(peerRoutes.get(i)); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + + return routes; } - static final ViewModelInitializer initializer = new ViewModelInitializer<>( - NetworksFragmentViewModel.class, - creationExtras -> { - MyApplication app = (MyApplication) creationExtras.get(APPLICATION_KEY); - assert app != null; - return new NetworksFragmentViewModel(app.getVPNServiceRepository()); + private List createNetworkDomainsList(NetworkDomains networkDomains) { + List domains = new ArrayList<>(); + + io.netbird.gomobile.android.NetworkDomain goNetworkDomain; + NetworkDomain networkDomain; + String ipAddress; + + try { + for (int i = 0; i < networkDomains.size(); i++) { + goNetworkDomain = networkDomains.get(i); + networkDomain = new NetworkDomain(goNetworkDomain.getAddress()); + + var resolvedIPs = goNetworkDomain.getResolvedIPs(); + + for (int j = 0; j < resolvedIPs.size(); j++) { + ipAddress = resolvedIPs.get(j); + networkDomain.addResolvedIP(ipAddress); + } + + domains.add(networkDomain); } - ); + } catch (Exception e) { + throw new RuntimeException(e); + } - @Override - public void onServiceBind() { - this.repository.addRouteChangeListener(this); - postResources(); + return domains; + } + + private List getNetworks() { + var resources = new ArrayList(); + var networks = serviceAccessor.getNetworks(); + + for (int i = 0; i < networks.size(); i++) { + var network = networks.get(i); + var networkDomains = network.getNetworkDomains(); + + resources.add(new Resource(Status.fromString(network.getStatus()), + network.getName(), + network.getNetwork(), + network.getPeer(), + network.getIsSelected(), + createNetworkDomainsList(networkDomains))); + } + + return resources; + } + + private List getRoutingPeers() { + var peers = new ArrayList(); + var peersFromEngine = serviceAccessor.getPeersList(); + + for (int i = 0; i < peersFromEngine.size(); i++) { + var peerInfo = peersFromEngine.get(i); + var peerRoutes = peerInfo.getPeerRoutes(); + + peers.add(new RoutingPeer( + Status.fromString(peerInfo.getConnStatus()), + createPeerRoutesList(peerRoutes))); + } + + return peers; + } + + private void postResources() { + var resources = getNetworks(); + var peers = getRoutingPeers(); + + // This value will be set from a background thread. + uiState.postValue(new NetworksFragmentUiState(resources, peers)); } @Override @@ -66,11 +139,11 @@ public void onRouteChanged(String routes) { } public void selectRoute(String route) throws Exception { - this.repository.selectRoute(route); + this.serviceAccessor.selectRoute(route); } public void deselectRoute(String route) throws Exception { - this.repository.deselectRoute(route); + this.serviceAccessor.deselectRoute(route); } // region StateListener implementation 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..202f8b9 --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/profile/ProfilesFragment.java @@ -0,0 +1,251 @@ +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); + final EditText input = new EditText(requireContext()); + input.setHint(R.string.profiles_dialog_add_hint); + + final 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) + .setNegativeButton(android.R.string.cancel, null) + .create(); + + dialog.show(); + + // Set click listener after show() 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; + } + + // Validate profile name based on go client sanitization rules + String sanitizedName = sanitizeProfileName(profileName); + if (sanitizedName.isEmpty()) { + Toast.makeText(requireContext(), + "Profile name must contain at least one letter, digit, underscore or hyphen", + Toast.LENGTH_LONG).show(); + return; + } + + addProfile(profileName); + dialog.dismiss(); + }); + } + + /** + * Sanitizes profile name using the same rules as the go client. + * Only keeps letters, digits, underscores, and hyphens. + * This matches the sanitization in netbird/client/internal/profilemanager/profilemanager.go + */ + private String sanitizeProfileName(String name) { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (Character.isLetterOrDigit(c) || c == '_' || c == '-') { + result.append(c); + } + } + return result.toString(); + } + + 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..79ed741 --- /dev/null +++ b/app/src/main/res/layout/fragment_profiles.xml @@ -0,0 +1,40 @@ + + + + + + + + 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 @@ + + + + + + + + + + + +