diff --git a/.gitmodules b/.gitmodules index 932099a..b5c5152 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "netbird"] path = netbird - url = https://github.com/netbirdio/netbird.git + url = https://github.com/doromaraujo/netbird.git + branch = feature/android-allow-selecting-routes \ No newline at end of file diff --git a/app/src/main/java/io/netbird/client/MyApplication.java b/app/src/main/java/io/netbird/client/MyApplication.java index 0af6e16..462c3a4 100644 --- a/app/src/main/java/io/netbird/client/MyApplication.java +++ b/app/src/main/java/io/netbird/client/MyApplication.java @@ -7,6 +7,7 @@ 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 { @@ -28,4 +29,8 @@ public void registerNetworkReceiver() { new IntentFilter(NetworkChangeNotifier.action) ); } + + public VPNServiceRepository getVPNServiceRepository() { + return new VPNServiceRepository(this); + } } \ 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 new file mode 100644 index 0000000..6758514 --- /dev/null +++ b/app/src/main/java/io/netbird/client/repository/VPNServiceBindListener.java @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..990ba7c --- /dev/null +++ b/app/src/main/java/io/netbird/client/repository/VPNServiceRepository.java @@ -0,0 +1,173 @@ +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/home/NetworkDomain.java b/app/src/main/java/io/netbird/client/ui/home/NetworkDomain.java new file mode 100644 index 0000000..01a375e --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/home/NetworkDomain.java @@ -0,0 +1,26 @@ +package io.netbird.client.ui.home; + +import java.util.ArrayList; +import java.util.List; + +public class NetworkDomain { + private final String address; + private final List resolvedIPs; + + public NetworkDomain(String address) { + this.address = address; + this.resolvedIPs = new ArrayList<>(); + } + + public String getAddress() { + return address; + } + + public void addResolvedIP(String ipAddress) { + this.resolvedIPs.add(ipAddress); + } + + public List getResolvedIPs() { + return this.resolvedIPs; + } +} diff --git a/app/src/main/java/io/netbird/client/ui/home/NetworksAdapter.java b/app/src/main/java/io/netbird/client/ui/home/NetworksAdapter.java index a7f78ca..19a8110 100644 --- a/app/src/main/java/io/netbird/client/ui/home/NetworksAdapter.java +++ b/app/src/main/java/io/netbird/client/ui/home/NetworksAdapter.java @@ -1,31 +1,35 @@ package io.netbird.client.ui.home; -import android.util.Log; import android.view.LayoutInflater; import android.view.ViewGroup; +import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; - import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; import io.netbird.client.R; import io.netbird.client.databinding.ListItemResourceBinding; public class NetworksAdapter extends RecyclerView.Adapter { - + public interface RouteSwitchToggleHandler { + void handleSwitchToggle(String route, boolean isChecked) throws Exception; + } private final List resourcesList; + private final List peers; private final List filteredResourcesList; - - + private final RouteSwitchToggleHandler switchToggleHandler; private String filterQueryString = ""; - public NetworksAdapter(List resourcesList) { + public NetworksAdapter(List resourcesList, List peers, RouteSwitchToggleHandler switchToggleHandler) { this.resourcesList = resourcesList; + this.peers = peers; filteredResourcesList = new ArrayList<>(resourcesList); + this.switchToggleHandler = switchToggleHandler; sort(); } @@ -33,13 +37,14 @@ public NetworksAdapter(List resourcesList) { @Override public ResourceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { // Use ViewBinding to inflate the layout - ListItemResourceBinding binding = ListItemResourceBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); - return new ResourceViewHolder(binding); + ListItemResourceBinding binding = ListItemResourceBinding.inflate( + LayoutInflater.from(parent.getContext()), parent, false); + return new ResourceViewHolder(binding, switchToggleHandler); } @Override public void onBindViewHolder(@NonNull ResourceViewHolder holder, int position) { - holder.bind(filteredResourcesList.get(position)); + holder.bind(filteredResourcesList.get(position), peers); } @Override @@ -67,7 +72,7 @@ private void doFilterBySearchQuery() { ArrayList temporaryList = new ArrayList<>(resourcesList); for (Resource res : temporaryList) { - if (res.getName().toLowerCase().contains(filterQueryString.toLowerCase())){ + if (res.getName().toLowerCase().contains(filterQueryString.toLowerCase())) { filteredResourcesList.add(res); } } @@ -81,24 +86,86 @@ private void sort() { public static class ResourceViewHolder extends RecyclerView.ViewHolder { ListItemResourceBinding binding; + RouteSwitchToggleHandler switchToggleHandler; - public ResourceViewHolder(ListItemResourceBinding binding) { + public ResourceViewHolder(ListItemResourceBinding binding, RouteSwitchToggleHandler switchToggleHandler) { super(binding.getRoot()); this.binding = binding; + this.switchToggleHandler = switchToggleHandler; + } + + /** + *

+ * Returns a drawable indicating whether a given resource is CONNECTED, SELECTED or DESELECTED. + * A resource is considered CONNECTED when, given a list of routing peers, at least one of them + * also has a CONNECTED status and contains a route that maps to that given resource's address + *

+ *

+ * OR + *

+ *

+ * if the resource is mapped to a domain whose any of its resolved IP addresses is contained + * in any of the CONNECTED routing peer's routes. + *

+ * Barring those conditions, it simply checks if the resource is selected or not. + *

+ */ + @DrawableRes + private int getConnectionStatusIndicatorDrawable(Resource resource, List peers) { + var connectedPeers = peers.stream() + .filter(peer -> peer.getStatus().equals(Status.CONNECTED)) + .collect(Collectors.toList()); + + var totalPeersWithRouteMatchingResourceAddress = connectedPeers.stream() + .filter(peer -> peer.getRoutes().contains(resource.getAddress())) + .count(); + + if (totalPeersWithRouteMatchingResourceAddress > 0) { + return R.drawable.peer_status_connected; + } + + var allResolvedIPAddresses = resource.getDomains().stream() + .flatMap(domain -> domain.getResolvedIPs().stream()); + + var allConnectedRoutingPeerRoutes = connectedPeers.stream() + .flatMap(peer -> peer.getRoutes().stream()) + .collect(Collectors.toList()); + + if (allResolvedIPAddresses.anyMatch(allConnectedRoutingPeerRoutes::contains)) { + return R.drawable.peer_status_connected; + } + + if (resource.isSelected()) return R.drawable.peer_status_selected; + return R.drawable.peer_status_disconnected; } - public void bind(Resource resource) { + public void bind(Resource resource, List peers) { binding.address.setText(resource.getAddress()); binding.name.setText(resource.getName()); binding.peer.setText(resource.getPeer()); - if (resource.getStatus() == Status.CONNECTED) { - binding.verticalLine.setBackgroundResource(R.drawable.peer_status_connected); // Green for connected - } else { - binding.verticalLine.setBackgroundResource(R.drawable.peer_status_disconnected); // Red for disconnected - } - - if(resource.isExitNode()) { + // Necessary when rebinding because onCheckedChangeListener is already set. + binding.switchControl.setTag(true); + + binding.switchControl.setChecked(resource.isSelected()); + binding.switchControl.setTag(false); + binding.switchControl.setOnCheckedChangeListener((buttonView, isChecked) -> { + try { + boolean tag = (boolean)binding.switchControl.getTag(); + if (!tag) { + this.switchToggleHandler.handleSwitchToggle(resource.getName(), isChecked); + } + } catch (Exception ignored) { + // This is done so that reversing the toggle action won't retrigger the toggle handler. + binding.switchControl.setTag(true); + binding.switchControl.setChecked(!isChecked); + binding.switchControl.setTag(false); + } + }); + + binding.verticalLine.setBackgroundResource(getConnectionStatusIndicatorDrawable(resource, peers)); + + if (resource.isExitNode()) { binding.exitNode.setVisibility(android.view.View.VISIBLE); } else { binding.exitNode.setVisibility(android.view.View.GONE); 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 a651aea..46260af 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 @@ -1,6 +1,5 @@ package io.netbird.client.ui.home; -import android.content.Context; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.text.Editable; @@ -15,39 +14,28 @@ import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; +import java.util.List; import io.netbird.client.R; -import io.netbird.client.ServiceAccessor; import io.netbird.client.databinding.FragmentNetworksBinding; -import io.netbird.gomobile.android.Network; -import io.netbird.gomobile.android.NetworkArray; -import io.netbird.gomobile.android.PeerInfo; -import io.netbird.gomobile.android.PeerInfoArray; public class NetworksFragment extends Fragment { private FragmentNetworksBinding binding; - private ServiceAccessor serviceAccessor; - private RecyclerView resourcesListView; + private NetworksAdapter adapter; + private final List resources = new ArrayList<>(); + private final List peers = new ArrayList<>(); + private NetworksFragmentViewModel model; public static NetworksFragment newInstance() { return new NetworksFragment(); } - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - if (context instanceof ServiceAccessor) { - serviceAccessor = (ServiceAccessor) context; - } else { - throw new RuntimeException(context + " must implement ServiceAccessor"); - } - } - @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -55,30 +43,34 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c return binding.getRoot(); } - @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); + model = new ViewModelProvider(this, + ViewModelProvider.Factory.from(NetworksFragmentViewModel.initializer)) + .get(NetworksFragmentViewModel.class); + ZeroPeerView.setupLearnWhyClick(binding.zeroPeerLayout, requireContext()); - NetworkArray networks = serviceAccessor.getNetworks(); - updateNetworkCount(networks); + adapter = new NetworksAdapter(resources, peers, this::routeSwitchToggleHandler); - ZeroPeerView.updateVisibility(binding.zeroPeerLayout, binding.networksList, networks.size() > 0); + RecyclerView resourcesRecyclerView = binding.networksRecyclerView; + resourcesRecyclerView.setAdapter(adapter); + resourcesRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); - resourcesListView = binding.networksRecyclerView; - resourcesListView.setLayoutManager(new LinearLayoutManager(requireContext())); + model.getUiState().observe(getViewLifecycleOwner(), uiState -> { + resources.clear(); + resources.addAll(uiState.getResources()); - ArrayList resources = new ArrayList<>(); - for( int i = 0; i < networks.size(); i++) { - Network network = networks.get(i); - Status status = Status.fromString(network.getStatus()); - resources.add(new Resource(status, network.getName(), network.getNetwork(), network.getPeer())); - } + peers.clear(); + peers.addAll(uiState.getPeers()); - NetworksAdapter adapter = new NetworksAdapter(resources); - resourcesListView.setAdapter(adapter); + updateResourcesCounter(resources); + ZeroPeerView.updateVisibility(binding.zeroPeerLayout, binding.networksList, !resources.isEmpty()); + adapter.notifyDataSetChanged(); + adapter.filterBySearchQuery(binding.searchView.getText().toString()); + }); binding.searchView.clearFocus(); binding.searchView.addTextChangedListener(new TextWatcher() { @@ -99,20 +91,27 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat }); } - private void updateNetworkCount(NetworkArray networks) { + private void updateResourcesCounter(List resources) { TextView textPeersCount = binding.textOpenPanel; int connected = 0; - for(int i = 0; i < networks.size(); i++) { - Network network = networks.get(i); - Status status = Status.fromString(network.getStatus()); - if (status.equals(Status.CONNECTED)) { + + for (var resource : resources) { + if (resource.isSelected()) { connected++; } } - String text = getString(R.string.resources_connected, connected, networks.size()); + String text = getString(R.string.resources_connected, connected, resources.size()); textPeersCount.post(() -> textPeersCount.setText(Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY)) ); } + + private void routeSwitchToggleHandler(String route, boolean isChecked) throws Exception { + if (isChecked) { + model.selectRoute(route); + } else { + model.deselectRoute(route); + } + } } diff --git a/app/src/main/java/io/netbird/client/ui/home/NetworksFragmentUiState.java b/app/src/main/java/io/netbird/client/ui/home/NetworksFragmentUiState.java new file mode 100644 index 0000000..bede7a5 --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/home/NetworksFragmentUiState.java @@ -0,0 +1,20 @@ +package io.netbird.client.ui.home; + +import java.util.List; + +public class NetworksFragmentUiState { + private final List resources; + private final List peers; + + public NetworksFragmentUiState(List resources, List peers) { + this.resources = resources; + this.peers = peers; + } + + public List getResources() { + return resources; + } + + public List getPeers() { return peers; + } +} 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 new file mode 100644 index 0000000..db5b79d --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/home/NetworksFragmentViewModel.java @@ -0,0 +1,77 @@ +package io.netbird.client.ui.home; + +import static androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.viewmodel.ViewModelInitializer; + +import java.util.ArrayList; + +import io.netbird.client.MyApplication; +import io.netbird.client.repository.VPNServiceBindListener; +import io.netbird.client.repository.VPNServiceRepository; +import io.netbird.client.tool.RouteChangeListener; + +public class NetworksFragmentViewModel extends ViewModel implements VPNServiceBindListener, RouteChangeListener { + private final VPNServiceRepository repository; + 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(); + } + + @Override + protected void onCleared() { + super.onCleared(); + repository.removeRouteChangeListener(this); + repository.unbindService(); + } + + public LiveData getUiState() { + return uiState; + } + + public void getResources() { + var resources = repository.getNetworks(); + var peers = repository.getRoutingPeers(); + + uiState.setValue(new NetworksFragmentUiState(resources, peers)); + } + + static final ViewModelInitializer initializer = new ViewModelInitializer<>( + NetworksFragmentViewModel.class, + creationExtras -> { + MyApplication app = (MyApplication) creationExtras.get(APPLICATION_KEY); + assert app != null; + return new NetworksFragmentViewModel(app.getVPNServiceRepository()); + } + ); + + @Override + public void onServiceBind() { + this.repository.addRouteChangeListener(this); + getResources(); + } + + @Override + public void onRouteChanged(String routes) { + var resources = repository.getNetworks(); + var peers = repository.getRoutingPeers(); + + // This value will be set from a background thread. + uiState.postValue(new NetworksFragmentUiState(resources, peers)); + } + + public void selectRoute(String route) throws Exception { + this.repository.selectRoute(route); + } + + public void deselectRoute(String route) throws Exception { + this.repository.deselectRoute(route); + } +} diff --git a/app/src/main/java/io/netbird/client/ui/home/Resource.java b/app/src/main/java/io/netbird/client/ui/home/Resource.java index fe83f11..813904f 100644 --- a/app/src/main/java/io/netbird/client/ui/home/Resource.java +++ b/app/src/main/java/io/netbird/client/ui/home/Resource.java @@ -1,35 +1,49 @@ package io.netbird.client.ui.home; -public class Resource { - private final Status status; - private final String name; - private final String address; - private final String peer; +import java.util.List; - public Resource(Status status, String name, String address, String peer) { - this.status = status; - this.name = name; - this.address = address; - this.peer = peer; - } +public class Resource { + private final Status status; + private final String name; + private final String address; + private final String peer; + private final boolean isSelected; + private final List domains; + public Resource(Status status, String name, String address, String peer, boolean isSelected, List domains) { + this.status = status; + this.name = name; + this.address = address; + this.peer = peer; + this.isSelected = isSelected; + this.domains = domains; + } - public String getName() { - return name; - } + public String getName() { + return name; + } - public String getAddress() { - return address; - } + public String getAddress() { + return address; + } public String getPeer() { return peer; } public Status getStatus() { - return status; + return status; } + public boolean isExitNode() { - return address.equals("0.0.0.0/0"); + return address.equals("0.0.0.0/0"); + } + + public boolean isSelected() { + return isSelected; + } + + public List getDomains() { + return this.domains; } } \ No newline at end of file diff --git a/app/src/main/java/io/netbird/client/ui/home/RoutingPeer.java b/app/src/main/java/io/netbird/client/ui/home/RoutingPeer.java new file mode 100644 index 0000000..e5f63fc --- /dev/null +++ b/app/src/main/java/io/netbird/client/ui/home/RoutingPeer.java @@ -0,0 +1,20 @@ +package io.netbird.client.ui.home; + +import java.util.List; + +public class RoutingPeer { + private final Status status; + private final List routes; + public RoutingPeer(Status status, List routes) { + this.status = status; + this.routes = routes; + } + + public Status getStatus() { + return this.status; + } + + public List getRoutes() { + return this.routes; + } +} diff --git a/app/src/main/res/drawable/peer_status_selected.xml b/app/src/main/res/drawable/peer_status_selected.xml new file mode 100644 index 0000000..0483122 --- /dev/null +++ b/app/src/main/res/drawable/peer_status_selected.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_item_resource.xml b/app/src/main/res/layout/list_item_resource.xml index 4454355..7b70798 100644 --- a/app/src/main/res/layout/list_item_resource.xml +++ b/app/src/main/res/layout/list_item_resource.xml @@ -52,7 +52,7 @@ tools:text="172.16.254.0/24" android:textColor="@color/nb_txt_light" android:textSize="14sp" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@id/switch_control" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toTopOf="@id/peer" android:layout_marginEnd="16dp" /> @@ -61,18 +61,27 @@ android:id="@+id/peer" android:layout_width="0dp" android:layout_height="wrap_content" - tools:text="mypeer.really.long.hostname.example.com" + android:layout_marginStart="8dp" + android:layout_marginTop="4dp" + android:layout_marginEnd="16dp" + android:ellipsize="end" + android:maxLines="1" + android:textAlignment="textEnd" android:textColor="@color/nb_txt_light" android:textSize="14sp" - android:maxLines="1" - android:ellipsize="end" - app:layout_constraintTop_toBottomOf="@id/address" app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/switch_control" app:layout_constraintStart_toStartOf="@id/guideline_half" + app:layout_constraintTop_toBottomOf="@id/address" + tools:text="mypeer.really.long.hostname.example.com" /> + + + app:layout_constraintTop_toTopOf="parent" /> serviceStateListeners = new HashSet<>(); private final Client goClient; - public EngineRunner(VPNService vpnService) { - context = vpnService; - NetworkChangeNotifier notifier = new NetworkChangeNotifier(vpnService); - IFace iFace = new IFace(vpnService); + public EngineRunner(String configurationFilePath, NetworkChangeListener networkChangeListener, TunAdapter tunAdapter, + IFaceDiscover iFaceDiscover, String versionName, boolean isTraceLogEnabled, boolean isDebuggable, + String stateFilePath) { + var platformFiles = new AndroidPlatformFiles(configurationFilePath, stateFilePath); + goClient = Android.newClient( - Preferences.configFile(vpnService), + platformFiles, androidSDKVersion(), DeviceName.getDeviceName(), - Version.getVersionName(vpnService), - iFace, - new IFaceDiscover(), - notifier); + versionName, + tunAdapter, + iFaceDiscover, + networkChangeListener); - updateLogLevel(); + updateLogLevel(isTraceLogEnabled, isDebuggable); } - public void run(URLOpener urlOpener) { - runClient(urlOpener); + public void run(@NotNull DNSWatch dnsWatch, @NotNull Preferences preferences, boolean isDebuggable, @NotNull URLOpener urlOpener) { + runClient(dnsWatch, preferences, isDebuggable, urlOpener); } - public void runWithoutAuth() { - runClient(null); + public void runWithoutAuth(@NotNull DNSWatch dnsWatch, @NotNull Preferences preferences, boolean isDebuggable) { + runClient(dnsWatch, preferences, isDebuggable, null); } - private synchronized void runClient(URLOpener urlOpener) { + private synchronized void runClient(@NotNull DNSWatch dnsWatch, @NotNull Preferences preferences, boolean isDebuggable, @Nullable URLOpener urlOpener) { Log.d(LOGTAG, "run engine"); if (engineIsRunning) { Log.e(LOGTAG, "engine already running"); return; } - updateLogLevel(); + updateLogLevel(preferences.isTraceLogEnabled(), isDebuggable); engineIsRunning = true; Runnable r = () -> { - DNSWatch dnsWatch = new DNSWatch(context); - Preferences preferences = new Preferences(context); var envList = EnvVarPackager.getEnvironmentVariables(preferences); try { notifyServiceStateListeners(true); - if(urlOpener == null) { + if (urlOpener == null) { goClient.runWithoutLogin(dnsWatch.dnsServers(), () -> dnsWatch.setDNSChangeListener(this::changed), envList); } else { goClient.run(urlOpener, dnsWatch.dnsServers(), () -> dnsWatch.setDNSChangeListener(this::changed), envList); @@ -87,6 +88,7 @@ private synchronized void runClient(URLOpener urlOpener) { private void changed(DNSList dnsServers) throws Exception { goClient.onUpdatedHostDNS(dnsServers); } + public synchronized boolean isRunning() { return engineIsRunning; } @@ -145,9 +147,8 @@ private synchronized void notifyServiceStateListeners(boolean engineIsRunning) { } } - private void updateLogLevel() { - Preferences pref = new Preferences(context); - if (Version.isDebuggable(context) || pref.isTraceLogEnabled()) { + private void updateLogLevel(boolean isTraceLogEnabled, boolean isDebuggable) { + if (isDebuggable || isTraceLogEnabled) { goClient.setTraceLogLevel(); } else { goClient.setInfoLogLevel(); @@ -155,6 +156,38 @@ private void updateLogLevel() { } private int androidSDKVersion() { - return Build.VERSION.SDK_INT ; + return Build.VERSION.SDK_INT; + } + + public void renewTUN(int fd) { + Log.d(LOGTAG, String.format("renewing TUN fd: %d", fd)); + try { + goClient.renewTun(fd); + } catch (Exception e) { + Log.e(LOGTAG, "goClient error", e); + notifyError(e); + } + } + + public void selectRoute(String route) throws Exception { + Log.d(LOGTAG, String.format("selecting route: %s", route)); + try { + goClient.selectRoute(route); + } catch (Exception e) { + Log.e(LOGTAG, "goClient error", e); + notifyError(e); + throw e; + } + } + + public void deselectRoute(String route) throws Exception { + Log.d(LOGTAG, String.format("deselecting route: %s", route)); + try { + goClient.deselectRoute(route); + } catch (Exception e) { + Log.e(LOGTAG, "goClient error", e); + notifyError(e); + throw e; + } } } diff --git a/tool/src/main/java/io/netbird/client/tool/IFace.java b/tool/src/main/java/io/netbird/client/tool/IFace.java index 37d8ff8..918d2ac 100644 --- a/tool/src/main/java/io/netbird/client/tool/IFace.java +++ b/tool/src/main/java/io/netbird/client/tool/IFace.java @@ -33,12 +33,20 @@ public long configureInterface(String address, long mtu, String dns, String sear LinkedList routes = toRoutes(routesString); InetNetwork addr = InetNetwork.parse(address); + long fd = -1; + try { - return createTun(addr.getAddress().getHostAddress(), addr.getMask(), (int) mtu, dns, searchDomains, routes); - }catch (Exception e) { + fd = createTun(addr.getAddress().getHostAddress(), addr.getMask(), (int) mtu, dns, searchDomains, routes); + } catch (Exception e) { Log.e(LOGTAG, "failed to create tunnel", e); - throw e; } + + // only set the currently used TUN parameters if createTun didn't throw exceptions + if (fd != -1) { + this.vpnService.setCurrentTUNParameters(new TUNParameters(address, mtu, dns, searchDomainsString, routesString)); + } + + return fd; } @Override diff --git a/tool/src/main/java/io/netbird/client/tool/NetworkChangeNotifier.java b/tool/src/main/java/io/netbird/client/tool/NetworkChangeNotifier.java index 7cbc826..f19c422 100644 --- a/tool/src/main/java/io/netbird/client/tool/NetworkChangeNotifier.java +++ b/tool/src/main/java/io/netbird/client/tool/NetworkChangeNotifier.java @@ -5,6 +5,10 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + import io.netbird.gomobile.android.NetworkChangeListener; public class NetworkChangeNotifier implements NetworkChangeListener { @@ -13,13 +17,24 @@ public class NetworkChangeNotifier implements NetworkChangeListener { private final Context context; + private final List routeChangeListeners; + NetworkChangeNotifier(Context context) { this.context = context; + this.routeChangeListeners = new ArrayList<>(); } @Override public void onNetworkChanged(String routes) { - sendBroadcast(); + if (routes != null) { + routes = routes.replace(",", ";"); + } + + for (var listener : routeChangeListeners) { + listener.onRouteChanged(routes); + } + + sendBroadcast(routes); } @Override @@ -27,9 +42,23 @@ public void setInterfaceIP(String ip) { } - private void sendBroadcast() { + public void addRouteChangeListener(RouteChangeListener routeChangeListener) { + Objects.requireNonNull(routeChangeListener); + + if (!this.routeChangeListeners.contains(routeChangeListener)) { + this.routeChangeListeners.add(routeChangeListener); + } + } + + public void removeRouteChangeListener(RouteChangeListener routeChangeListener) { + Objects.requireNonNull(routeChangeListener); + this.routeChangeListeners.remove(routeChangeListener); + } + + private void sendBroadcast(String routes) { Intent intent = new Intent(action); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra("routes", routes); LocalBroadcastManager.getInstance(context).sendBroadcast(intent); } } \ No newline at end of file diff --git a/tool/src/main/java/io/netbird/client/tool/Preferences.java b/tool/src/main/java/io/netbird/client/tool/Preferences.java index 1f9c7b7..c580749 100644 --- a/tool/src/main/java/io/netbird/client/tool/Preferences.java +++ b/tool/src/main/java/io/netbird/client/tool/Preferences.java @@ -14,6 +14,9 @@ public class Preferences { public static String configFile(Context context){ return context.getFilesDir().getPath() + "/netbird.cfg"; } + public static String stateFile(Context context) { + return context.getFilesDir().getPath() + "/state.json"; + } public Preferences(Context context) { sharedPref = context.getSharedPreferences("netbird", Context.MODE_PRIVATE); diff --git a/tool/src/main/java/io/netbird/client/tool/RouteChangeListener.java b/tool/src/main/java/io/netbird/client/tool/RouteChangeListener.java new file mode 100644 index 0000000..8a01901 --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/RouteChangeListener.java @@ -0,0 +1,5 @@ +package io.netbird.client.tool; + +public interface RouteChangeListener { + void onRouteChanged(String routes); +} diff --git a/tool/src/main/java/io/netbird/client/tool/TUNCreatorLooperThread.java b/tool/src/main/java/io/netbird/client/tool/TUNCreatorLooperThread.java new file mode 100644 index 0000000..eeca20a --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/TUNCreatorLooperThread.java @@ -0,0 +1,54 @@ +package io.netbird.client.tool; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; + +import java.util.Objects; + +public class TUNCreatorLooperThread extends Thread { + private static final String TAG = TUNCreatorLooperThread.class.getSimpleName(); + private Handler handler; + + private final Consumer tunCreator; + + public TUNCreatorLooperThread(Consumer tunCreator) { + this.tunCreator = tunCreator; + } + + public void run() { + Looper.prepare(); + + synchronized (this) { + handler = new Handler(Objects.requireNonNull(Looper.myLooper())) { + @Override + public void handleMessage(@NonNull Message msg) { + if (msg.what == 1) { + Log.d(TAG, "handleMessage: renewing TUN!"); + String routes = msg.obj.toString(); + tunCreator.accept(routes); + } + } + }; + notifyAll(); + } + + Looper.loop(); + } + + public synchronized Handler getHandler() { + while (handler == null) { + try { + wait(); + } catch (InterruptedException e) { + Log.d(TAG, "getHandler: ", e); + } + } + + return handler; + } +} diff --git a/tool/src/main/java/io/netbird/client/tool/TUNParameters.java b/tool/src/main/java/io/netbird/client/tool/TUNParameters.java new file mode 100644 index 0000000..e5d7257 --- /dev/null +++ b/tool/src/main/java/io/netbird/client/tool/TUNParameters.java @@ -0,0 +1,25 @@ +package io.netbird.client.tool; + +public class TUNParameters { + String address; + long mtu; + String dns; + String searchDomainsString; + String routesString; + + public TUNParameters(String address, long mtu, String dns, String searchDomainsString, String routesString) { + this.address = address; + this.mtu = mtu; + this.dns = dns; + this.searchDomainsString = searchDomainsString; + this.routesString = routesString; + } + + public boolean didRoutesChange(String routesString) { + if (this.routesString != null) { + return !this.routesString.equals(routesString); + } else { + return routesString != null; + } + } +} diff --git a/tool/src/main/java/io/netbird/client/tool/VPNService.java b/tool/src/main/java/io/netbird/client/tool/VPNService.java index 3d24785..1639d8a 100644 --- a/tool/src/main/java/io/netbird/client/tool/VPNService.java +++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java @@ -11,6 +11,7 @@ import android.os.IBinder; import android.os.Parcel; import android.util.Log; + import androidx.annotation.Nullable; import io.netbird.gomobile.android.ConnectionListener; @@ -23,17 +24,35 @@ public class VPNService extends android.net.VpnService { private final static String LOGTAG = "service"; public static final String INTENT_ACTION_START = "io.netbird.client.intent.action.START_SERVICE"; private static final String INTENT_ALWAYS_ON_START = "android.net.VpnService"; - private final IBinder myBinder = new MyLocalBinder(); - private EngineRunner engineRunner; private ForegroundNotification fgNotification; + private TUNParameters currentTUNParameters; + private NetworkChangeNotifier notifier; + + private RouteChangeListener listener; @Override public void onCreate() { super.onCreate(); Log.d(LOGTAG, "onCreate"); - engineRunner = new EngineRunner(this); + + var configurationFilePath = Preferences.configFile(this); + var stateFilePath = Preferences.stateFile(this); + var versionName = Version.getVersionName(this); + var tunAdapter = new IFace(this); + var iFaceDiscover = new IFaceDiscover(); + + listener = this::queueTUNRenewal; + + notifier = new NetworkChangeNotifier(this); + notifier.addRouteChangeListener(listener); + + var preferences = new Preferences(this); + var isDebuggable = Version.isDebuggable(this); + + engineRunner = new EngineRunner(configurationFilePath, notifier, tunAdapter, iFaceDiscover, versionName, + preferences.isTraceLogEnabled(), isDebuggable, stateFilePath); fgNotification = new ForegroundNotification(this); engineRunner.addServiceStateListener(serviceStateListener); } @@ -45,9 +64,12 @@ public int onStartCommand(@Nullable final Intent intent, final int flags, final return START_NOT_STICKY; } - if(INTENT_ALWAYS_ON_START.equals(intent.getAction())) { + if (INTENT_ALWAYS_ON_START.equals(intent.getAction())) { fgNotification.startForeground(); - engineRunner.runWithoutAuth(); + engineRunner.runWithoutAuth( + new DNSWatch(this), + new Preferences(this), + Version.isDebuggable(this)); } return super.onStartCommand(intent, flags, startId); } @@ -59,9 +81,9 @@ public IBinder onBind(Intent intent) { } @Override - public boolean onUnbind (Intent intent) { + public boolean onUnbind(Intent intent) { Log.d(LOGTAG, "unbind from activity"); - if(!engineRunner.isRunning()) { + if (!engineRunner.isRunning()) { stopSelf(); } return false; // false means do not call onRebind @@ -73,12 +95,21 @@ public void onDestroy() { Log.d(LOGTAG, "onDestroy"); engineRunner.stop(); stopForeground(true); + + if (this.notifier != null) { + this.notifier.removeRouteChangeListener(listener); + } + + if (tunCreator != null) { + tunCreator.getHandler().getLooper().quitSafely(); + tunCreator = null; + } } @Override public void onRevoke() { Log.d(LOGTAG, "VPN permission on revoke"); - if(engineRunner!=null) { + if (engineRunner != null) { engineRunner.stop(); stopForeground(true); } @@ -104,7 +135,9 @@ public Intent prepareVpnIntent(Activity context) { public void runEngine(URLOpener urlOpener) { fgNotification.startForeground(); - engineRunner.run(urlOpener); + engineRunner.run(new DNSWatch(VPNService.this), + new Preferences(VPNService.this), + Version.isDebuggable(VPNService.this), urlOpener); } public void stopEngine() { @@ -134,8 +167,28 @@ public void addServiceStateListener(ServiceStateListener serviceStateListener) { public void removeServiceStateListener(ServiceStateListener serviceStateListener) { engineRunner.removeServiceStateListener(serviceStateListener); } + + public void addRouteChangeListener(RouteChangeListener listener) { + if (VPNService.this.notifier != null) { + VPNService.this.notifier.addRouteChangeListener(listener); + } + } + + public void removeRouteChangeListener(RouteChangeListener listener) { + if (VPNService.this.notifier != null) { + VPNService.this.notifier.removeRouteChangeListener(listener); + } + } + + public void selectRoute(String route) throws Exception { + engineRunner.selectRoute(route); + } + + public void deselectRoute(String route) throws Exception { + engineRunner.deselectRoute(route); + } } - + public static boolean isUsingAlwaysOnVPN(Context context) { ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); Network[] networks = connectivityManager.getAllNetworks(); @@ -169,4 +222,48 @@ public void onError(String msg) { fgNotification.stopForeground(); } }; + + private TUNCreatorLooperThread tunCreator; + + private void queueTUNRenewal(String routes) { + if (tunCreator == null) { + tunCreator = new TUNCreatorLooperThread(this::recreateTUN); + tunCreator.setPriority(Thread.MAX_PRIORITY); + tunCreator.start(); + } + + var message = tunCreator.getHandler().obtainMessage(1, routes); + boolean isQueued = tunCreator.getHandler().sendMessage(message); + + Log.d(LOGTAG, String.format("is TUN renewal queued? %b", isQueued)); + } + + private void recreateTUN(String routes) { + if (!engineRunner.isRunning()) return; + + // Renew TUN file descriptor if routes changed. + if (currentTUNParameters != null && currentTUNParameters.didRoutesChange(routes)) { + var iface = new IFace(VPNService.this); + + try { + int fd = (int)iface.configureInterface( + currentTUNParameters.address, + currentTUNParameters.mtu, + currentTUNParameters.dns, + currentTUNParameters.searchDomainsString, + routes); + + if (fd != -1) { + this.protect(fd); + this.engineRunner.renewTUN(fd); + } + } catch (Exception e) { + Log.e(LOGTAG, "failed to recreate tunnel after route changed", e); + } + } + } + + public void setCurrentTUNParameters(TUNParameters currentTUNParameters) { + this.currentTUNParameters = currentTUNParameters; + } }