diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d3ade888..257d36ab 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,18 @@
android:name="io.netbird.permission.NOTIFICATION"
android:protectionLevel="signature" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/io/netbird/client/MainActivity.java b/app/src/main/java/io/netbird/client/MainActivity.java
index a29d09e2..3256cba4 100644
--- a/app/src/main/java/io/netbird/client/MainActivity.java
+++ b/app/src/main/java/io/netbird/client/MainActivity.java
@@ -582,6 +582,17 @@ public NetworkArray getNetworks() {
return mBinder.networks();
}
+ @Override
+ public void applySplitTunneling() {
+ if (mBinder == null) {
+ // Nothing is running to rebuild; the new selection is read when the
+ // tunnel is next created.
+ return;
+ }
+
+ mBinder.applySplitTunneling();
+ }
+
@Override
public void selectRoute(String route) throws Exception {
if (mBinder == null) {
diff --git a/app/src/main/java/io/netbird/client/ServiceAccessor.java b/app/src/main/java/io/netbird/client/ServiceAccessor.java
index 08ef2fd5..bffc12fb 100644
--- a/app/src/main/java/io/netbird/client/ServiceAccessor.java
+++ b/app/src/main/java/io/netbird/client/ServiceAccessor.java
@@ -22,6 +22,9 @@ public interface ServiceAccessor {
void stopEngine();
+ /** Rebuilds the tunnel so a split tunnelling change applies without reconnecting. */
+ void applySplitTunneling();
+
void selectRoute(String route) throws Exception;
void deselectRoute(String route) throws Exception;
diff --git a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java
index 64ce0399..6e88456d 100644
--- a/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java
+++ b/app/src/main/java/io/netbird/client/ui/settings/SettingsFragment.java
@@ -52,6 +52,9 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
binding.rowAdvanced.setOnClickListener(v ->
navController.navigate(R.id.nav_advanced));
+ binding.rowSplitTunneling.setOnClickListener(v ->
+ navController.navigate(R.id.nav_split_tunneling));
+
binding.rowLanguage.setOnClickListener(v ->
new LanguagePickerSheet().show(getChildFragmentManager(), "language_picker"));
diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/AppEntry.java b/app/src/main/java/io/netbird/client/ui/splittunneling/AppEntry.java
new file mode 100644
index 00000000..3cd0e749
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/ui/splittunneling/AppEntry.java
@@ -0,0 +1,29 @@
+package io.netbird.client.ui.splittunneling;
+
+import android.graphics.drawable.Drawable;
+
+/** One installed application, as shown in the split tunnelling list. */
+public class AppEntry {
+
+ private final String packageName;
+ private final String label;
+ private final Drawable icon;
+
+ public AppEntry(String packageName, String label, Drawable icon) {
+ this.packageName = packageName;
+ this.label = label;
+ this.icon = icon;
+ }
+
+ public String getPackageName() {
+ return packageName;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public Drawable getIcon() {
+ return icon;
+ }
+}
diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/AppListAdapter.java b/app/src/main/java/io/netbird/client/ui/splittunneling/AppListAdapter.java
new file mode 100644
index 00000000..567ccafe
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/ui/splittunneling/AppListAdapter.java
@@ -0,0 +1,108 @@
+package io.netbird.client.ui.splittunneling;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+import io.netbird.client.databinding.ListItemAppBinding;
+
+public class AppListAdapter extends RecyclerView.Adapter {
+
+ public interface OnAppToggledListener {
+ void onAppToggled(String packageName, boolean selected);
+ }
+
+ private final List apps = new ArrayList<>();
+ private final List filteredApps = new ArrayList<>();
+ private final OnAppToggledListener toggleListener;
+
+ private Set selected;
+ private String filterQueryString = "";
+
+ public AppListAdapter(Set selected, OnAppToggledListener toggleListener) {
+ this.selected = selected;
+ this.toggleListener = toggleListener;
+ }
+
+ public void submitApps(List newApps) {
+ apps.clear();
+ apps.addAll(newApps);
+ applyFilter();
+ }
+
+ /** Called when the mode changes, which swaps which of the two lists is shown. */
+ public void setSelected(Set selected) {
+ this.selected = selected;
+ notifyDataSetChanged();
+ }
+
+ public void filterBySearchQuery(String query) {
+ filterQueryString = query == null ? "" : query;
+ applyFilter();
+ }
+
+ private void applyFilter() {
+ filteredApps.clear();
+ if (filterQueryString.isEmpty()) {
+ filteredApps.addAll(apps);
+ } else {
+ String needle = filterQueryString.toLowerCase(Locale.getDefault());
+ for (AppEntry app : apps) {
+ if (app.getLabel().toLowerCase(Locale.getDefault()).contains(needle)) {
+ filteredApps.add(app);
+ }
+ }
+ }
+ notifyDataSetChanged();
+ }
+
+ @NonNull
+ @Override
+ public AppViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ ListItemAppBinding binding = ListItemAppBinding.inflate(
+ LayoutInflater.from(parent.getContext()), parent, false);
+ return new AppViewHolder(binding);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull AppViewHolder holder, int position) {
+ holder.bind(filteredApps.get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return filteredApps.size();
+ }
+
+ class AppViewHolder extends RecyclerView.ViewHolder {
+
+ private final ListItemAppBinding binding;
+
+ AppViewHolder(ListItemAppBinding binding) {
+ super(binding.getRoot());
+ this.binding = binding;
+ }
+
+ void bind(AppEntry app) {
+ binding.appName.setText(app.getLabel());
+ binding.appPackage.setText(app.getPackageName());
+ binding.appIcon.setImageDrawable(app.getIcon());
+
+ // Cleared before setChecked so recycling a row into a different app
+ // cannot fire a toggle the user never made.
+ binding.switchControl.setOnCheckedChangeListener(null);
+ binding.switchControl.setChecked(selected.contains(app.getPackageName()));
+ binding.switchControl.setOnCheckedChangeListener((buttonView, isChecked) ->
+ toggleListener.onAppToggled(app.getPackageName(), isChecked));
+
+ binding.getRoot().setOnClickListener(v -> binding.switchControl.toggle());
+ }
+ }
+}
diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelModeSheet.java b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelModeSheet.java
new file mode 100644
index 00000000..6f7db1b3
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelModeSheet.java
@@ -0,0 +1,78 @@
+package io.netbird.client.ui.splittunneling;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
+
+import io.netbird.client.databinding.SheetSplitTunnelModeBinding;
+import io.netbird.client.tool.SplitTunnelConfig;
+
+public class SplitTunnelModeSheet extends BottomSheetDialogFragment {
+
+ public interface OnModeChangedListener {
+ void onModeChanged(SplitTunnelConfig.Mode mode);
+ }
+
+ private static final String ARG_MODE = "mode";
+
+ private SheetSplitTunnelModeBinding binding;
+
+ public static SplitTunnelModeSheet newInstance(SplitTunnelConfig.Mode current) {
+ SplitTunnelModeSheet sheet = new SplitTunnelModeSheet();
+ Bundle args = new Bundle();
+ args.putString(ARG_MODE, current.name());
+ sheet.setArguments(args);
+ return sheet;
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ binding = SheetSplitTunnelModeBinding.inflate(inflater, container, false);
+
+ binding.modeRowOff.setOnClickListener(v -> pick(SplitTunnelConfig.Mode.OFF));
+ binding.modeRowExclude.setOnClickListener(v -> pick(SplitTunnelConfig.Mode.EXCLUDE));
+ binding.modeRowInclude.setOnClickListener(v -> pick(SplitTunnelConfig.Mode.INCLUDE));
+
+ showCheckmarkFor(currentMode());
+ return binding.getRoot();
+ }
+
+ private SplitTunnelConfig.Mode currentMode() {
+ Bundle args = getArguments();
+ if (args == null) {
+ return SplitTunnelConfig.Mode.OFF;
+ }
+ try {
+ return SplitTunnelConfig.Mode.valueOf(args.getString(ARG_MODE, SplitTunnelConfig.Mode.OFF.name()));
+ } catch (IllegalArgumentException e) {
+ return SplitTunnelConfig.Mode.OFF;
+ }
+ }
+
+ private void showCheckmarkFor(SplitTunnelConfig.Mode mode) {
+ binding.modeCheckOff.setVisibility(mode == SplitTunnelConfig.Mode.OFF ? View.VISIBLE : View.INVISIBLE);
+ binding.modeCheckExclude.setVisibility(mode == SplitTunnelConfig.Mode.EXCLUDE ? View.VISIBLE : View.INVISIBLE);
+ binding.modeCheckInclude.setVisibility(mode == SplitTunnelConfig.Mode.INCLUDE ? View.VISIBLE : View.INVISIBLE);
+ }
+
+ private void pick(SplitTunnelConfig.Mode mode) {
+ if (getParentFragment() instanceof OnModeChangedListener) {
+ ((OnModeChangedListener) getParentFragment()).onModeChanged(mode);
+ }
+ dismiss();
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ binding = null;
+ }
+}
diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java
new file mode 100644
index 00000000..703fc27a
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingFragment.java
@@ -0,0 +1,206 @@
+package io.netbird.client.ui.splittunneling;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.util.Log;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.LinearLayoutManager;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import io.netbird.client.R;
+import io.netbird.client.ServiceAccessor;
+import io.netbird.client.databinding.FragmentSplitTunnelingBinding;
+import io.netbird.client.tool.SplitTunnelConfig;
+import io.netbird.client.tool.SplitTunnelStore;
+
+/**
+ * Lets the user say which applications the tunnel carries.
+ *
+ * Every change is written straight through and handed to the service, which
+ * rebuilds the tunnel in place — there is no save button and no reconnection to
+ * ask for.
+ */
+public class SplitTunnelingFragment extends Fragment
+ implements SplitTunnelModeSheet.OnModeChangedListener, AppListAdapter.OnAppToggledListener {
+
+ private static final String LOGTAG = "SplitTunnelingFragment";
+
+ private FragmentSplitTunnelingBinding binding;
+ private SplitTunnelingViewModel viewModel;
+ private AppListAdapter adapter;
+ private SplitTunnelStore store;
+ private ServiceAccessor serviceAccessor;
+
+ private SplitTunnelConfig.Mode mode = SplitTunnelConfig.Mode.OFF;
+ private final Set excluded = new HashSet<>();
+ private final Set included = new HashSet<>();
+
+ @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) {
+ binding = FragmentSplitTunnelingBinding.inflate(inflater, container, false);
+ return binding.getRoot();
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ store = new SplitTunnelStore(requireContext());
+ SplitTunnelConfig stored = store.load();
+ mode = stored.getMode();
+ excluded.addAll(stored.getExcluded());
+ included.addAll(stored.getIncluded());
+
+ adapter = new AppListAdapter(activeSelection(), this);
+ binding.appsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
+ binding.appsRecyclerView.setAdapter(adapter);
+
+ binding.rowMode.setOnClickListener(v ->
+ SplitTunnelModeSheet.newInstance(mode).show(getChildFragmentManager(), "split_tunnel_mode"));
+
+ binding.searchView.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ adapter.filterBySearchQuery(s.toString());
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ }
+ });
+
+ viewModel = new ViewModelProvider(this).get(SplitTunnelingViewModel.class);
+ viewModel.getApps().observe(getViewLifecycleOwner(), apps -> {
+ pruneUninstalled(apps);
+ adapter.submitApps(apps);
+ binding.loadingIndicator.setVisibility(View.GONE);
+ });
+ viewModel.loadApps();
+
+ renderMode();
+ }
+
+ @Override
+ public void onModeChanged(SplitTunnelConfig.Mode newMode) {
+ if (newMode == mode) {
+ return;
+ }
+ mode = newMode;
+ // Both selections are kept, so switching back and forth does not make the
+ // user pick their apps again.
+ save();
+ adapter.setSelected(activeSelection());
+ renderMode();
+ }
+
+ @Override
+ public void onAppToggled(String packageName, boolean selected) {
+ Set selection = activeSelection();
+ if (selected) {
+ selection.add(packageName);
+ } else {
+ selection.remove(packageName);
+ }
+ save();
+ renderWarning();
+ }
+
+ private Set activeSelection() {
+ return mode == SplitTunnelConfig.Mode.INCLUDE ? included : excluded;
+ }
+
+ private void save() {
+ if (persist()) {
+ serviceAccessor.applySplitTunneling();
+ }
+ }
+
+ /** @return false when the store refused the write, so nothing was changed. */
+ private boolean persist() {
+ try {
+ store.save(new SplitTunnelConfig(mode, excluded, included));
+ return true;
+ } catch (Exception e) {
+ Log.e(LOGTAG, "could not save the split tunnelling settings", e);
+ Toast.makeText(requireContext(), getString(R.string.error_generic, e.toString()),
+ Toast.LENGTH_SHORT).show();
+ return false;
+ }
+ }
+
+ /**
+ * Drops packages that were picked and later uninstalled. The tunnel already
+ * ignores them, but leaving them in storage would silently re-apply them if
+ * the app were installed again.
+ */
+ private void pruneUninstalled(java.util.List apps) {
+ Set installed = new HashSet<>();
+ for (AppEntry app : apps) {
+ installed.add(app.getPackageName());
+ }
+
+ boolean changed = excluded.retainAll(installed);
+ changed |= included.retainAll(installed);
+ if (changed) {
+ persist();
+ }
+ }
+
+ private void renderMode() {
+ int label;
+ if (mode == SplitTunnelConfig.Mode.EXCLUDE) {
+ label = R.string.split_tunneling_mode_exclude;
+ } else if (mode == SplitTunnelConfig.Mode.INCLUDE) {
+ label = R.string.split_tunneling_mode_include;
+ } else {
+ label = R.string.split_tunneling_mode_off;
+ }
+ binding.currentModeName.setText(label);
+
+ boolean listUsable = mode != SplitTunnelConfig.Mode.OFF;
+ binding.searchView.setEnabled(listUsable);
+ binding.appsRecyclerView.setVisibility(listUsable ? View.VISIBLE : View.GONE);
+ binding.modeOffHint.setVisibility(listUsable ? View.GONE : View.VISIBLE);
+
+ renderWarning();
+ }
+
+ private void renderWarning() {
+ boolean emptyAllowlist = mode == SplitTunnelConfig.Mode.INCLUDE && included.isEmpty();
+ binding.emptyIncludeWarning.setVisibility(emptyAllowlist ? View.VISIBLE : View.GONE);
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ binding = null;
+ }
+}
diff --git a/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingViewModel.java b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingViewModel.java
new file mode 100644
index 00000000..a2d98c61
--- /dev/null
+++ b/app/src/main/java/io/netbird/client/ui/splittunneling/SplitTunnelingViewModel.java
@@ -0,0 +1,92 @@
+package io.netbird.client.ui.splittunneling;
+
+import android.app.Application;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Builds the list of applications the user can pick from.
+ *
+ * Reading labels and icons hits the package manager once per app, which is slow
+ * enough to drop frames on a loaded device, so the whole list is resolved off the
+ * main thread and kept for as long as the screen lives.
+ */
+public class SplitTunnelingViewModel extends AndroidViewModel {
+
+ private final MutableLiveData> apps = new MutableLiveData<>();
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
+ private boolean loadStarted;
+
+ public SplitTunnelingViewModel(@NonNull Application application) {
+ super(application);
+ }
+
+ public LiveData> getApps() {
+ return apps;
+ }
+
+ public void loadApps() {
+ if (loadStarted) {
+ return;
+ }
+ loadStarted = true;
+ executor.execute(() -> apps.postValue(queryLaunchableApps()));
+ }
+
+ /**
+ * Only apps with a launcher entry are listed. That is what the manifest's
+ * {@code } block makes visible, and it keeps the screen clear of the
+ * package manager's long tail of services the user has no opinion about —
+ * without asking for QUERY_ALL_PACKAGES, which Play treats as sensitive.
+ */
+ private List queryLaunchableApps() {
+ PackageManager packageManager = getApplication().getPackageManager();
+
+ Intent launcherIntent = new Intent(Intent.ACTION_MAIN);
+ launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+
+ List resolved = packageManager.queryIntentActivities(launcherIntent, 0);
+ List entries = new ArrayList<>(resolved.size());
+ Set seen = new HashSet<>();
+
+ // This app is left out on purpose: an INCLUDE selection always carries it
+ // so the built-in SSH client can reach peers, so a toggle for it would
+ // claim an effect it does not have.
+ String ownPackage = getApplication().getPackageName();
+
+ for (ResolveInfo info : resolved) {
+ String packageName = info.activityInfo.packageName;
+ if (packageName.equals(ownPackage) || !seen.add(packageName)) {
+ continue;
+ }
+ entries.add(new AppEntry(
+ packageName,
+ info.loadLabel(packageManager).toString(),
+ info.loadIcon(packageManager)));
+ }
+
+ entries.sort(Comparator.comparing(entry -> entry.getLabel().toLowerCase(Locale.getDefault())));
+ return entries;
+ }
+
+ @Override
+ protected void onCleared() {
+ super.onCleared();
+ executor.shutdownNow();
+ }
+}
diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml
index 0e3f3684..2d706c7c 100644
--- a/app/src/main/res/layout/fragment_settings.xml
+++ b/app/src/main/res/layout/fragment_settings.xml
@@ -152,6 +152,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_app.xml b/app/src/main/res/layout/list_item_app.xml
new file mode 100644
index 00000000..b12d55b0
--- /dev/null
+++ b/app/src/main/res/layout/list_item_app.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/sheet_split_tunnel_mode.xml b/app/src/main/res/layout/sheet_split_tunnel_mode.xml
new file mode 100644
index 00000000..58165b24
--- /dev/null
+++ b/app/src/main/res/layout/sheet_split_tunnel_mode.xml
@@ -0,0 +1,165 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml
index 2303835a..4de576a1 100644
--- a/app/src/main/res/navigation/mobile_navigation.xml
+++ b/app/src/main/res/navigation/mobile_navigation.xml
@@ -42,6 +42,12 @@
android:label="@string/menu_advanced"
tools:layout="@layout/fragment_advanced" />
+
+
Session expires in %1$d day
- Session expires in %1$d days
+ Split tunneling
+ Mode
+ Off
+ All apps use the VPN
+ All apps currently use the VPN. Choose a mode to pick which ones do.
+ Exclude
+ Selected apps bypass the VPN
+ Include
+ Only selected apps use the VPN
+ Search apps
+ No app selected, so every app keeps using the VPN. Pick at least one.
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 137fe8ce..656aef5e 100644
--- a/tool/src/main/java/io/netbird/client/tool/IFace.java
+++ b/tool/src/main/java/io/netbird/client/tool/IFace.java
@@ -86,10 +86,7 @@ private int createTun(String ip, int prefixLength, InetNetwork addrV6, int mtu,
Log.d(LOGTAG, "add route: "+r.addr+"/"+r.prefixLength);
}
- disallowApp(builder, "com.google.android.projection.gearhead");
- disallowApp(builder, "com.google.android.apps.chromecast.app");
- disallowApp(builder, "com.google.android.apps.messaging");
- disallowApp(builder, "com.google.stadia.android");
+ applyAppFilter(builder);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setMetered(false);
@@ -138,11 +135,36 @@ private void prepareDnsSetting(VpnService.Builder builder, String dns) {
}
}
- private void disallowApp(VpnService.Builder builder, String packageName) {
- try {
- builder.addDisallowedApplication(packageName);
- } catch (PackageManager.NameNotFoundException ignored) {
+ /**
+ * Narrows the tunnel to the apps the user picked.
+ *
+ * The selection is read here rather than passed in because the tunnel is
+ * also rebuilt from VPNService without going through the Go engine, and both
+ * paths must see the same stored answer. It belongs to the active profile,
+ * so switching profile switches which applications the tunnel carries.
+ */
+ private void applyAppFilter(VpnService.Builder builder) {
+ SplitTunnelConfig.Resolution resolution = new SplitTunnelStore(vpnService)
+ .load()
+ .resolve(vpnService.getPackageName());
+
+ boolean allow = resolution.getFilter() == SplitTunnelConfig.Filter.ALLOW;
+ for (String packageName : resolution.getPackages()) {
+ try {
+ if (allow) {
+ builder.addAllowedApplication(packageName);
+ } else {
+ builder.addDisallowedApplication(packageName);
+ }
+ } catch (PackageManager.NameNotFoundException ignored) {
+ // Uninstalled since it was picked. Dropping the whole tunnel over a
+ // stale entry would be worse than ignoring it; the list screen
+ // prunes it on the next visit.
+ }
}
+
+ Log.d(LOGTAG, "app filter: " + (allow ? "allow " : "disallow ")
+ + resolution.getPackages().size() + " package(s)");
}
@SuppressLint("DefaultLocale")
diff --git a/tool/src/main/java/io/netbird/client/tool/SplitTunnelConfig.java b/tool/src/main/java/io/netbird/client/tool/SplitTunnelConfig.java
new file mode 100644
index 00000000..3f15f9f8
--- /dev/null
+++ b/tool/src/main/java/io/netbird/client/tool/SplitTunnelConfig.java
@@ -0,0 +1,134 @@
+package io.netbird.client.tool;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Which applications the tunnel carries.
+ *
+ * Android lets a VpnService name either the apps that stay out of the tunnel or
+ * the apps that are the only ones in it, never both on the same builder, so the
+ * two selections are kept apart and a mode says which one is live.
+ *
+ * Deliberately free of Android types: the rules below are the part worth testing
+ * on the JVM, away from a device.
+ */
+public final class SplitTunnelConfig {
+
+ public enum Mode {
+ /** Everything but {@link #ALWAYS_EXCLUDED} goes through the tunnel. */
+ OFF,
+ /** The user's picks stay out of the tunnel; everything else goes in. */
+ EXCLUDE,
+ /** Only the user's picks go through the tunnel. */
+ INCLUDE
+ }
+
+ /** How the resolved packages are meant to be handed to VpnService.Builder. */
+ public enum Filter {
+ DISALLOW,
+ ALLOW
+ }
+
+ /**
+ * Apps that misbehave when tunnelled, kept out in every mode that can express
+ * an exclusion. They predate this feature and stay as the floor of EXCLUDE so
+ * that turning split tunnelling on never silently pulls them into the tunnel.
+ */
+ public static final Set ALWAYS_EXCLUDED = Collections.unmodifiableSet(
+ new LinkedHashSet<>(java.util.Arrays.asList(
+ "com.google.android.projection.gearhead",
+ "com.google.android.apps.chromecast.app",
+ "com.google.android.apps.messaging",
+ "com.google.stadia.android")));
+
+ private final Mode mode;
+ private final Set excluded;
+ private final Set included;
+
+ public SplitTunnelConfig(Mode mode, Collection excluded, Collection included) {
+ this.mode = mode == null ? Mode.OFF : mode;
+ this.excluded = copyOf(excluded);
+ this.included = copyOf(included);
+ }
+
+ public Mode getMode() {
+ return mode;
+ }
+
+ public Set getExcluded() {
+ return excluded;
+ }
+
+ public Set getIncluded() {
+ return included;
+ }
+
+ /**
+ * An INCLUDE selection that is empty would allow no app at all, leaving a
+ * tunnel that carries nothing and looks broken rather than configured. Such a
+ * config is reported as inactive and resolves like {@link Mode#OFF}, so the UI
+ * can warn about it with the same answer the tunnel will act on.
+ */
+ public boolean isActive() {
+ if (mode == Mode.EXCLUDE) {
+ return !excluded.isEmpty();
+ }
+ if (mode == Mode.INCLUDE) {
+ return !included.isEmpty();
+ }
+ return false;
+ }
+
+ /**
+ * @param ownPackage this app's own package name, always allowed in INCLUDE
+ * mode: the Go engine's own sockets bypass the tunnel via
+ * protectSocket, but the built-in SSH client has to reach
+ * peers through it.
+ */
+ public Resolution resolve(String ownPackage) {
+ if (mode == Mode.INCLUDE && isActive()) {
+ Set allowed = new LinkedHashSet<>(included);
+ if (ownPackage != null && !ownPackage.isEmpty()) {
+ allowed.add(ownPackage);
+ }
+ return new Resolution(Filter.ALLOW, allowed);
+ }
+
+ Set disallowed = new LinkedHashSet<>(ALWAYS_EXCLUDED);
+ if (mode == Mode.EXCLUDE) {
+ disallowed.addAll(excluded);
+ }
+ return new Resolution(Filter.DISALLOW, disallowed);
+ }
+
+ private static Set copyOf(Collection source) {
+ if (source == null || source.isEmpty()) {
+ return Collections.emptySet();
+ }
+ // SharedPreferences hands back a set that must not be touched, and the
+ // caller may keep mutating its own; copy on the way in.
+ return Collections.unmodifiableSet(new LinkedHashSet<>(source));
+ }
+
+ /** The packages to apply, and the builder method to apply them with. */
+ public static final class Resolution {
+ private final Filter filter;
+ private final Set packages;
+
+ Resolution(Filter filter, Set packages) {
+ this.filter = filter;
+ this.packages = Collections.unmodifiableSet(packages);
+ }
+
+ public Filter getFilter() {
+ return filter;
+ }
+
+ public Set getPackages() {
+ return packages;
+ }
+ }
+}
diff --git a/tool/src/main/java/io/netbird/client/tool/SplitTunnelStore.java b/tool/src/main/java/io/netbird/client/tool/SplitTunnelStore.java
new file mode 100644
index 00000000..247286d8
--- /dev/null
+++ b/tool/src/main/java/io/netbird/client/tool/SplitTunnelStore.java
@@ -0,0 +1,104 @@
+package io.netbird.client.tool;
+
+import android.content.Context;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import io.netbird.gomobile.android.Android;
+import io.netbird.gomobile.android.PackageList;
+import io.netbird.gomobile.android.SplitTunnelSettings;
+
+/**
+ * The split tunnelling selection of the active profile.
+ *
+ * The settings belong to a profile and are kept on the Go side with the rest of
+ * a profile's preferences, so switching profile switches which applications the
+ * tunnel carries. This class is only the translation layer: which packages end
+ * up on the interface, and how, stays in {@link SplitTunnelConfig}.
+ */
+public class SplitTunnelStore {
+
+ private static final String LOGTAG = "SplitTunnelStore";
+
+ private final String configDir;
+ private final ProfileManagerWrapper profileManager;
+
+ public SplitTunnelStore(Context context) {
+ this.configDir = context.getFilesDir().getPath();
+ this.profileManager = new ProfileManagerWrapper(context);
+ }
+
+ /**
+ * Reads the active profile's selection. A profile that has never stored one,
+ * an unreadable store, or no active profile all mean the same thing to the
+ * caller: carry every application.
+ */
+ public SplitTunnelConfig load() {
+ try {
+ SplitTunnelSettings settings = openStore().load();
+ return new SplitTunnelConfig(
+ toMode(settings.getMode()),
+ toList(settings.getExcluded()),
+ toList(settings.getIncluded()));
+ } catch (Exception e) {
+ Log.w(LOGTAG, "could not read the split tunnelling settings", e);
+ return new SplitTunnelConfig(SplitTunnelConfig.Mode.OFF, null, null);
+ }
+ }
+
+ public void save(SplitTunnelConfig config) throws Exception {
+ SplitTunnelSettings settings = Android.newSplitTunnelSettings();
+ settings.setMode(toGoMode(config.getMode()));
+ fill(settings.getExcluded(), config.getExcluded());
+ fill(settings.getIncluded(), config.getIncluded());
+ openStore().save(settings);
+ }
+
+ private io.netbird.gomobile.android.SplitTunnelStore openStore() throws Exception {
+ Profile active = profileManager.getActiveProfile();
+ if (active == null) {
+ throw new IllegalStateException("no active profile");
+ }
+ return Android.newSplitTunnelStore(configDir, active.getID());
+ }
+
+ private static void fill(PackageList target, Iterable packages) {
+ for (String packageName : packages) {
+ target.add(packageName);
+ }
+ }
+
+ private static List toList(PackageList list) {
+ List out = new ArrayList<>();
+ if (list == null) {
+ return out;
+ }
+ for (int i = 0; i < list.size(); i++) {
+ out.add(list.get(i));
+ }
+ return out;
+ }
+
+ private static SplitTunnelConfig.Mode toMode(long goMode) {
+ if (goMode == Android.SplitTunnelModeExclude) {
+ return SplitTunnelConfig.Mode.EXCLUDE;
+ }
+ if (goMode == Android.SplitTunnelModeInclude) {
+ return SplitTunnelConfig.Mode.INCLUDE;
+ }
+ return SplitTunnelConfig.Mode.OFF;
+ }
+
+ private static long toGoMode(SplitTunnelConfig.Mode mode) {
+ switch (mode) {
+ case EXCLUDE:
+ return Android.SplitTunnelModeExclude;
+ case INCLUDE:
+ return Android.SplitTunnelModeInclude;
+ default:
+ return Android.SplitTunnelModeOff;
+ }
+ }
+}
diff --git a/tool/src/main/java/io/netbird/client/tool/TUNCreatorLooperThread.java b/tool/src/main/java/io/netbird/client/tool/TUNCreatorLooperThread.java
index e16bca0a..f57aa512 100644
--- a/tool/src/main/java/io/netbird/client/tool/TUNCreatorLooperThread.java
+++ b/tool/src/main/java/io/netbird/client/tool/TUNCreatorLooperThread.java
@@ -7,14 +7,25 @@
import androidx.annotation.NonNull;
import java.util.Objects;
+import java.util.function.Consumer;
public class TUNCreatorLooperThread extends Thread {
private static final String TAG = TUNCreatorLooperThread.class.getSimpleName();
+
+ /** what value of the message asking for the TUN to be rebuilt. */
+ public static final int MSG_RENEW_TUN = 1;
+
+ /**
+ * arg1 value asking for the rebuild to happen even when the engine reports
+ * the same routes and search domains as before.
+ */
+ public static final int ARG_FORCE = 1;
+
private Handler handler;
- private final Runnable tunCreator;
+ private final Consumer tunCreator;
- public TUNCreatorLooperThread(Runnable tunCreator) {
+ public TUNCreatorLooperThread(Consumer tunCreator) {
this.tunCreator = tunCreator;
}
@@ -25,9 +36,10 @@ public void run() {
handler = new Handler(Objects.requireNonNull(Looper.myLooper())) {
@Override
public void handleMessage(@NonNull Message msg) {
- if (msg.what == 1) {
- Log.d(TAG, "handleMessage: renewing TUN!");
- tunCreator.run();
+ if (msg.what == MSG_RENEW_TUN) {
+ boolean force = msg.arg1 == ARG_FORCE;
+ Log.d(TAG, "handleMessage: renewing TUN!" + (force ? " (forced)" : ""));
+ tunCreator.accept(force);
}
}
};
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 fea9ae69..9695e452 100644
--- a/tool/src/main/java/io/netbird/client/tool/VPNService.java
+++ b/tool/src/main/java/io/netbird/client/tool/VPNService.java
@@ -333,6 +333,15 @@ public String debugBundle(boolean anonymize) throws Exception {
return engineRunner.debugBundle(anonymize);
}
+ /**
+ * Rebuilds the tunnel so a split tunnelling change takes hold without
+ * asking the user to disconnect. A no-op while the engine is down: the
+ * new selection is read when the tunnel is next created.
+ */
+ public void applySplitTunneling() {
+ queueTUNRenewal(true);
+ }
+
public void selectRoute(String route) throws Exception {
engineRunner.selectRoute(route);
}
@@ -502,19 +511,24 @@ public void onError(String msg) {
private TUNCreatorLooperThread tunCreator;
private void queueTUNRenewal(String ignoredPayload) {
+ queueTUNRenewal(false);
+ }
+
+ private void queueTUNRenewal(boolean force) {
if (tunCreator == null) {
tunCreator = new TUNCreatorLooperThread(this::recreateTUN);
tunCreator.setPriority(Thread.MAX_PRIORITY);
tunCreator.start();
}
- var message = tunCreator.getHandler().obtainMessage(1);
+ var message = tunCreator.getHandler().obtainMessage(TUNCreatorLooperThread.MSG_RENEW_TUN);
+ message.arg1 = force ? TUNCreatorLooperThread.ARG_FORCE : 0;
boolean isQueued = tunCreator.getHandler().sendMessage(message);
- Log.d(LOGTAG, String.format("is TUN renewal queued? %b", isQueued));
+ Log.d(LOGTAG, String.format("is TUN renewal queued? %b (forced: %b)", isQueued, force));
}
- private void recreateTUN() {
+ private void recreateTUN(boolean force) {
if (!engineRunner.isRunning()) return;
if (currentTUNParameters == null) return;
@@ -525,7 +539,9 @@ private void recreateTUN() {
String routes = settings.getRoutes();
String searchDomains = settings.getSearchDomains();
- if (!currentTUNParameters.didChange(routes, searchDomains)) {
+ // A changed app filter leaves routes and search domains untouched, so the
+ // usual guard would skip the very rebuild that applies it.
+ if (!force && !currentTUNParameters.didChange(routes, searchDomains)) {
return;
}
diff --git a/tool/src/test/java/io/netbird/client/tool/SplitTunnelConfigUnitTest.java b/tool/src/test/java/io/netbird/client/tool/SplitTunnelConfigUnitTest.java
new file mode 100644
index 00000000..7c54c4c9
--- /dev/null
+++ b/tool/src/test/java/io/netbird/client/tool/SplitTunnelConfigUnitTest.java
@@ -0,0 +1,138 @@
+package io.netbird.client.tool;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+public class SplitTunnelConfigUnitTest {
+
+ private static final String OWN = "io.netbird.client";
+
+ private static SplitTunnelConfig config(SplitTunnelConfig.Mode mode,
+ Set excluded,
+ Set included) {
+ return new SplitTunnelConfig(mode, excluded, included);
+ }
+
+ private static Set setOf(String... values) {
+ return new HashSet<>(Arrays.asList(values));
+ }
+
+ @Test
+ public void offKeepsOnlyTheHistoricExclusions() {
+ SplitTunnelConfig.Resolution r =
+ config(SplitTunnelConfig.Mode.OFF, Collections.emptySet(), Collections.emptySet())
+ .resolve(OWN);
+
+ assertEquals(SplitTunnelConfig.Filter.DISALLOW, r.getFilter());
+ assertEquals(SplitTunnelConfig.ALWAYS_EXCLUDED, r.getPackages());
+ }
+
+ @Test
+ public void offIgnoresSelectionsMadeInEitherMode() {
+ SplitTunnelConfig.Resolution r =
+ config(SplitTunnelConfig.Mode.OFF, setOf("com.example.a"), setOf("com.example.b"))
+ .resolve(OWN);
+
+ assertEquals(SplitTunnelConfig.ALWAYS_EXCLUDED, r.getPackages());
+ }
+
+ @Test
+ public void excludeAddsThePicksOnTopOfTheHistoricOnes() {
+ SplitTunnelConfig.Resolution r =
+ config(SplitTunnelConfig.Mode.EXCLUDE, setOf("com.example.a"), Collections.emptySet())
+ .resolve(OWN);
+
+ assertEquals(SplitTunnelConfig.Filter.DISALLOW, r.getFilter());
+ assertTrue(r.getPackages().containsAll(SplitTunnelConfig.ALWAYS_EXCLUDED));
+ assertTrue(r.getPackages().contains("com.example.a"));
+ }
+
+ @Test
+ public void includeAllowsOnlyThePicksPlusThisApp() {
+ SplitTunnelConfig.Resolution r =
+ config(SplitTunnelConfig.Mode.INCLUDE, Collections.emptySet(), setOf("com.example.a"))
+ .resolve(OWN);
+
+ assertEquals(SplitTunnelConfig.Filter.ALLOW, r.getFilter());
+ assertEquals(setOf("com.example.a", OWN), r.getPackages());
+ }
+
+ // The built-in SSH client has to reach peers through the tunnel, so the app
+ // must never be able to lock itself out of it.
+ @Test
+ public void includeAlwaysCarriesThisApp() {
+ SplitTunnelConfig.Resolution r =
+ config(SplitTunnelConfig.Mode.INCLUDE, Collections.emptySet(), setOf("com.example.a"))
+ .resolve(OWN);
+
+ assertTrue(r.getPackages().contains(OWN));
+ }
+
+ // An empty allowlist would leave a tunnel carrying nothing, which reads as a
+ // broken VPN rather than a configured one.
+ @Test
+ public void emptyIncludeFallsBackToCarryingEverything() {
+ SplitTunnelConfig cfg =
+ config(SplitTunnelConfig.Mode.INCLUDE, Collections.emptySet(), Collections.emptySet());
+
+ assertFalse(cfg.isActive());
+ SplitTunnelConfig.Resolution r = cfg.resolve(OWN);
+ assertEquals(SplitTunnelConfig.Filter.DISALLOW, r.getFilter());
+ assertEquals(SplitTunnelConfig.ALWAYS_EXCLUDED, r.getPackages());
+ }
+
+ @Test
+ public void emptyExcludeIsInactiveButStillDropsTheHistoricOnes() {
+ SplitTunnelConfig cfg =
+ config(SplitTunnelConfig.Mode.EXCLUDE, Collections.emptySet(), Collections.emptySet());
+
+ assertFalse(cfg.isActive());
+ assertEquals(SplitTunnelConfig.ALWAYS_EXCLUDED, cfg.resolve(OWN).getPackages());
+ }
+
+ @Test
+ public void selectionsAreActiveWhenNotEmpty() {
+ assertTrue(config(SplitTunnelConfig.Mode.EXCLUDE, setOf("com.example.a"), Collections.emptySet()).isActive());
+ assertTrue(config(SplitTunnelConfig.Mode.INCLUDE, Collections.emptySet(), setOf("com.example.a")).isActive());
+ }
+
+ @Test
+ public void nullModeAndNullSelectionsAreTreatedAsOff() {
+ SplitTunnelConfig cfg = new SplitTunnelConfig(null, null, null);
+
+ assertEquals(SplitTunnelConfig.Mode.OFF, cfg.getMode());
+ assertTrue(cfg.getExcluded().isEmpty());
+ assertTrue(cfg.getIncluded().isEmpty());
+ assertEquals(SplitTunnelConfig.ALWAYS_EXCLUDED, cfg.resolve(OWN).getPackages());
+ }
+
+ // SharedPreferences hands back a set it keeps using, so the config must not
+ // hold on to anything the caller can still change underneath it.
+ @Test
+ public void storedSelectionIsCopiedNotAliased() {
+ Set mutable = setOf("com.example.a");
+ SplitTunnelConfig cfg = config(SplitTunnelConfig.Mode.EXCLUDE, mutable, Collections.emptySet());
+
+ mutable.add("com.example.late");
+
+ assertFalse(cfg.getExcluded().contains("com.example.late"));
+ assertFalse(cfg.resolve(OWN).getPackages().contains("com.example.late"));
+ }
+
+ @Test
+ public void resolutionWithoutOwnPackageStillWorks() {
+ SplitTunnelConfig.Resolution r =
+ config(SplitTunnelConfig.Mode.INCLUDE, Collections.emptySet(), setOf("com.example.a"))
+ .resolve(null);
+
+ assertEquals(setOf("com.example.a"), r.getPackages());
+ }
+}