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()); + } +}