Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,15 @@ android {
dependencies {
implementation(project(":tool"))
implementation(project(":gomobile"))
implementation(libs.androidx.navigation.fragment)
implementation(libs.androidx.navigation.ui)
implementation(libs.appcompat)
implementation(libs.material)
implementation(libs.constraintlayout)
implementation(libs.lifecycle.livedata.ktx)
implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.navigation.fragment)
implementation(libs.navigation.ui)
implementation(libs.androidx.fragment)
implementation(libs.androidx.recyclerview)
testImplementation(libs.junit)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@
android:name="android.hardware.touchscreen"
android:required="false" />

<queries>
<!-- Apps with launcher icons - what users typically want to select -->
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>

<!-- Browser apps (common split-tunneling target) -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>

<application
android:name=".MyApplication"
android:allowBackup="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.fragment.app.Fragment;
import androidx.navigation.NavController;
import androidx.navigation.Navigation;

import io.netbird.client.R;
import io.netbird.client.databinding.ComponentSwitchBinding;
Expand Down Expand Up @@ -185,6 +187,11 @@ public View onCreateView(@NonNull LayoutInflater inflater,
AppCompatDelegate.setDefaultNightMode(mode);
});

binding.layoutSplitTunneling.setOnClickListener(v -> {
NavController navController = Navigation.findNavController(v);
navController.navigate(R.id.nav_split_tunneling);
});

return root;
}

Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/io/netbird/client/ui/splittunneling/AppInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.netbird.client.ui.splittunneling;

import android.graphics.drawable.Drawable;

public class AppInfo {
public final String name;
public final String packageName;
public final Drawable icon;
public boolean isSelected;

public AppInfo(String name, String packageName, Drawable icon, boolean isSelected) {
this.name = name;
this.packageName = packageName;
this.icon = icon;
this.isSelected = isSelected;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package io.netbird.client.ui.splittunneling;

import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.TextView;

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;

public class SplitTunnelingAdapter extends RecyclerView.Adapter<SplitTunnelingAdapter.ViewHolder> {

private List<AppInfo> allApps = new ArrayList<>();
private List<AppInfo> filteredApps = new ArrayList<>();
private final OnAppClickListener listener;

public interface OnAppClickListener {
void onAppClick(AppInfo app);
}

public SplitTunnelingAdapter(OnAppClickListener listener) {
this.listener = listener;
}

public void setApps(List<AppInfo> apps) {
this.allApps = apps;
this.filteredApps = new ArrayList<>(apps);
notifyDataSetChanged();
}

public void filter(String query) {
if (query.isEmpty()) {
filteredApps = new ArrayList<>(allApps);
} else {
String lowerQuery = query.toLowerCase();
filteredApps = allApps.stream()
.filter(app -> app.name.toLowerCase().contains(lowerQuery) || app.packageName.toLowerCase().contains(lowerQuery))
.collect(Collectors.toList());
}
notifyDataSetChanged();
}

@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_app, parent, false);
return new ViewHolder(view);
}

@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
AppInfo app = filteredApps.get(position);
holder.textAppName.setText(app.name);
holder.textPackageName.setText(app.packageName);
holder.imgAppIcon.setImageDrawable(app.icon);
holder.checkApp.setChecked(app.isSelected);
holder.itemView.setOnClickListener(v -> listener.onAppClick(app));
}

@Override
public int getItemCount() {
return filteredApps.size();
}

public static class ViewHolder extends RecyclerView.ViewHolder {
ImageView imgAppIcon;
TextView textAppName;
TextView textPackageName;
CheckBox checkApp;

public ViewHolder(@NonNull View itemView) {
super(itemView);
imgAppIcon = itemView.findViewById(R.id.img_app_icon);
textAppName = itemView.findViewById(R.id.text_app_name);
textPackageName = itemView.findViewById(R.id.text_package_name);
checkApp = itemView.findViewById(R.id.check_app);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package io.netbird.client.ui.splittunneling;

import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;

import io.netbird.client.R;
import io.netbird.client.databinding.FragmentSplitTunnelingBinding;
import io.netbird.client.tool.Preferences;
import io.netbird.client.tool.Preferences.SplitTunnelingMode;

public class SplitTunnelingFragment extends Fragment {

private FragmentSplitTunnelingBinding binding;
private SplitTunnelingAdapter adapter;
private Preferences preferences;
private List<AppInfo> allApps = new ArrayList<>();
private ExecutorService backgroundExecutor;

@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
binding = FragmentSplitTunnelingBinding.inflate(inflater, container, false);
preferences = new Preferences(requireContext());
backgroundExecutor = Executors.newSingleThreadExecutor();
setupUI();
loadApps();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return binding.getRoot();
}

private void setupUI() {
adapter = new SplitTunnelingAdapter(app -> {
app.isSelected = !app.isSelected;
saveApps();
adapter.notifyDataSetChanged();
});

binding.recyclerApps.setLayoutManager(new LinearLayoutManager(requireContext()));
binding.recyclerApps.setAdapter(adapter);

Preferences.SplitTunnelingMode mode = preferences.getSplitTunnelingMode();
switch (mode) {
case NONE:
binding.radioGroupMode.check(R.id.radio_mode_none);
break;
case EXCLUDE:
binding.radioGroupMode.check(R.id.radio_mode_exclude);
break;
case INCLUDE:
binding.radioGroupMode.check(R.id.radio_mode_include);
break;
}

binding.radioGroupMode.setOnCheckedChangeListener((group, checkedId) -> {
Preferences.SplitTunnelingMode newMode;
if (checkedId == R.id.radio_mode_none) {
newMode = Preferences.SplitTunnelingMode.NONE;
} else if (checkedId == R.id.radio_mode_exclude) {
newMode = Preferences.SplitTunnelingMode.EXCLUDE;
} else if (checkedId == R.id.radio_mode_include) {
newMode = Preferences.SplitTunnelingMode.INCLUDE;
} else {
newMode = Preferences.SplitTunnelingMode.NONE;
}
preferences.setSplitTunnelingMode(newMode);
});

binding.editSearch.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.filter(s.toString());
}

@Override
public void afterTextChanged(Editable s) {}
});
}

private void loadApps() {
if (backgroundExecutor == null) return;
Context context = getContext();
if (context == null) return;

PackageManager pm = context.getPackageManager();
String myPackageName = context.getPackageName();
binding.progressBar.setVisibility(View.VISIBLE);

backgroundExecutor.execute(() -> {
List<ApplicationInfo> apps = pm.getInstalledApplications(PackageManager.GET_META_DATA);
Set<String> selectedApps = preferences.getSplitTunnelingApps();

List<AppInfo> loadedApps = apps.stream()
.filter(app -> !app.packageName.equals(myPackageName))
.map(app -> {
String name = pm.getApplicationLabel(app).toString();
Drawable icon = pm.getApplicationIcon(app);
return new AppInfo(name, app.packageName, icon, selectedApps.contains(app.packageName));
})
.sorted((a, b) -> a.name.compareToIgnoreCase(b.name))
.collect(Collectors.toList());

if (binding != null) {
binding.getRoot().post(() -> {
if (binding != null) {
allApps = loadedApps;
adapter.setApps(allApps);
binding.progressBar.setVisibility(View.GONE);
}
});
}
});
}

private void saveApps() {
if (backgroundExecutor == null) return;
List<AppInfo> appsSnapshot = new ArrayList<>(allApps);
backgroundExecutor.execute(() -> {
Set<String> selectedApps = appsSnapshot.stream()
.filter(app -> app.isSelected)
.map(app -> app.packageName)
.collect(Collectors.toSet());
preferences.setSplitTunnelingApps(selectedApps);
});
}

@Override
public void onDestroyView() {
super.onDestroyView();
if (backgroundExecutor != null) {
backgroundExecutor.shutdown();
backgroundExecutor = null;
}
binding = null;
}
}
10 changes: 10 additions & 0 deletions app/src/main/res/drawable/ic_chevron_right.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#FF000000"
android:pathData="M8.59,16.59L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.59Z" />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</vector>
Loading