Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6c26479
Refactor EngineRunner to not depend on VPNService
doromaraujo Sep 17, 2025
03179de
Remove unused imports
doromaraujo Sep 17, 2025
1a75990
Merge branch 'main' into feature/allow-selecting-routes
doromaraujo Sep 18, 2025
05e2195
Send received routesString as extra on broadcast intent
doromaraujo Sep 22, 2025
5f89ae6
Add TUNParameters to hold last used vpn parameters
doromaraujo Sep 22, 2025
fdde77b
Add method to trigger renewal of TUN file descriptor on client
doromaraujo Sep 22, 2025
c63d3d6
Add call to VPNService to save last used parameters when successfully…
doromaraujo Sep 22, 2025
fe5dd65
Replace ',' with ';' on onNetworkChanged call
doromaraujo Sep 22, 2025
f232a6f
Add check on current looper when adding dns to VPNService.builder
doromaraujo Sep 22, 2025
4bfb241
Add broadcast receiver to VPNService to recreate TUN
doromaraujo Sep 22, 2025
7b32084
Remove unused import
doromaraujo Sep 22, 2025
6056bb6
Change log formatting
doromaraujo Sep 22, 2025
88699ed
Remove usage of BroadcastReceiver on VPNService
doromaraujo Sep 22, 2025
4539db8
Revert "Add check on current looper when adding dns to VPNService.bui…
doromaraujo Sep 22, 2025
6af4015
Add call to goClient.renewTun function when renewing TUN
doromaraujo Sep 23, 2025
32bfe69
Add ViewModel to NetworksFragment
doromaraujo Sep 25, 2025
c44ff83
Reapply filtering to adapter after ui state is refreshed
doromaraujo Sep 25, 2025
b1631d5
Remove old code
doromaraujo Sep 25, 2025
60bb0e2
Remove old code
doromaraujo Sep 25, 2025
b8789f6
Add support for multiple route change listeners to NetworkChangeNotifier
doromaraujo Sep 25, 2025
3b811b4
Add RouteChangeListener registration to VPNServiceRepository
doromaraujo Sep 25, 2025
9cb1092
Add NetworksFragmentViewModel as a RouteChangeListener
doromaraujo Sep 25, 2025
6a8dd37
Rename VPNServiceBindListener onBind function
doromaraujo Sep 25, 2025
d652e28
Set peer text alignment to end
doromaraujo Sep 25, 2025
2b314d8
Add switch control to list_item_resource.xml
doromaraujo Sep 25, 2025
f82473b
Add action to select/deselect routes
doromaraujo Sep 27, 2025
3ffafe1
Add stateFile method to Preferences to retrieve a location
doromaraujo Sep 27, 2025
b7d62c0
Send state file path to engine's go client
doromaraujo Sep 27, 2025
d871f94
Set submodule reference to forked repo
doromaraujo Sep 27, 2025
192f6fe
Use routing peers on networks adapter to display status color
doromaraujo Sep 30, 2025
1fc1901
Use isSelected from resource to count it as a connected one
doromaraujo Oct 1, 2025
6a726f2
Add NetworkDomain to Resource
doromaraujo Oct 1, 2025
ca1b87b
Add extra clause to getConnectionStatusIndicatorDrawable
doromaraujo Oct 1, 2025
3d2cefb
Propagate exception when selecting / deselecting routes
doromaraujo Oct 1, 2025
5675522
Set switch control tag to true when setting its checked value
doromaraujo Oct 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions app/src/main/java/io/netbird/client/MyApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,4 +29,8 @@ public void registerNetworkReceiver() {
new IntentFilter(NetworkChangeNotifier.action)
);
}

public VPNServiceRepository getVPNServiceRepository() {
return new VPNServiceRepository(this);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.netbird.client.repository;

public interface VPNServiceBindListener {
void onServiceBind();
}
Original file line number Diff line number Diff line change
@@ -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<String> createPeerRoutesList(PeerRoutes peerRoutes) {
List<String> routes = new ArrayList<>();

try {
for (int i = 0; i < peerRoutes.size(); i++) {
routes.add(peerRoutes.get(i));
}
} catch (Exception e) {
throw new RuntimeException(e);
}

return routes;
}

private List<NetworkDomain> createNetworkDomainsList(NetworkDomains networkDomains) {
List<NetworkDomain> 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<Resource> getNetworks() {
if (binder == null) {
return new ArrayList<>();
}

var resources = new ArrayList<Resource>();
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<RoutingPeer> getRoutingPeers() {
if (binder == null) {
return new ArrayList<>();
}

var peers = new ArrayList<RoutingPeer>();
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);
}
}
}
26 changes: 26 additions & 0 deletions app/src/main/java/io/netbird/client/ui/home/NetworkDomain.java
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> getResolvedIPs() {
return this.resolvedIPs;
}
}
105 changes: 86 additions & 19 deletions app/src/main/java/io/netbird/client/ui/home/NetworksAdapter.java
Original file line number Diff line number Diff line change
@@ -1,45 +1,50 @@
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<NetworksAdapter.ResourceViewHolder> {

public interface RouteSwitchToggleHandler {
void handleSwitchToggle(String route, boolean isChecked) throws Exception;
}

private final List<Resource> resourcesList;
private final List<RoutingPeer> peers;
private final List<Resource> filteredResourcesList;


private final RouteSwitchToggleHandler switchToggleHandler;
private String filterQueryString = "";

public NetworksAdapter(List<Resource> resourcesList) {
public NetworksAdapter(List<Resource> resourcesList, List<RoutingPeer> peers, RouteSwitchToggleHandler switchToggleHandler) {
this.resourcesList = resourcesList;
this.peers = peers;
filteredResourcesList = new ArrayList<>(resourcesList);
this.switchToggleHandler = switchToggleHandler;
sort();
}

@NonNull
@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
Expand Down Expand Up @@ -67,7 +72,7 @@ private void doFilterBySearchQuery() {

ArrayList<Resource> 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);
}
}
Expand All @@ -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;
}

/**
* <p>
* 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
* </p>
* <p>
* OR
* </p>
* <p>
* 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.
* <p>
* Barring those conditions, it simply checks if the resource is selected or not.
* </p>
*/
@DrawableRes
private int getConnectionStatusIndicatorDrawable(Resource resource, List<RoutingPeer> 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<RoutingPeer> 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);
Expand Down
Loading
Loading