Add ability to exclude apps from the VPN

Closes #229
This commit is contained in:
emanuele-f 2022-07-05 16:36:13 +02:00
parent fd61a8178a
commit ec4cda0a94
14 changed files with 493 additions and 17 deletions

View File

@ -79,6 +79,10 @@
android:name=".activities.SettingsActivity"
android:launchMode="singleTop"
android:parentActivityName=".activities.MainActivity" />
<activity
android:name=".activities.VpnExceptionsActivity"
android:launchMode="singleTop"
android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.AboutActivity"
android:launchMode="singleTop"

View File

@ -81,7 +81,9 @@ import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
@ -413,13 +415,25 @@ public class CaptureService extends VpnService implements Runnable {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
return abortStart();
}
} else if(mSettings.tls_decryption) {
// Exclude the mitm addon traffic in case system-wide decryption is performed
// Important: cannot call addDisallowedApplication with addAllowedApplication
try {
builder.addDisallowedApplication(MitmAPI.PACKAGE_NAME);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
} else {
// VPN exceptions
Set<String> exceptions = mPrefs.getStringSet(Prefs.PREF_VPN_EXCEPTIONS, new HashSet<>());
for(String packageName: exceptions) {
try {
builder.addDisallowedApplication(packageName);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
}
if(mSettings.tls_decryption) {
// Exclude the mitm addon traffic in case system-wide decryption is performed
// Important: cannot call addDisallowedApplication with addAllowedApplication
try {
builder.addDisallowedApplication(MitmAPI.PACKAGE_NAME);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
}
}

View File

@ -79,9 +79,11 @@ public class SettingsActivity extends BaseActivity implements PreferenceFragment
@Override
public boolean onPreferenceStartFragment(@NonNull PreferenceFragmentCompat caller, @NonNull Preference pref) {
PreferenceFragmentCompat targetFragment = null;
Log.d(TAG, "startFragment: " + pref.getKey());
String prefKey = pref.getKey();
if(pref.getKey().equals("geolocation")) {
Log.d(TAG, "startFragment: " + prefKey);
if(prefKey.equals("geolocation")) {
targetFragment = new GeoipSettings();
setTitle(R.string.geolocation);
}
@ -130,6 +132,7 @@ public class SettingsActivity extends BaseActivity implements PreferenceFragment
private EditTextPreference mSocks5ProxyPort;
private DropDownPreference mIpMode;
private DropDownPreference mCapInterface;
private Preference mVpnExceptions;
private SwitchPreference mMalwareDetectionEnabled;
private Billing mIab;
private boolean mHasStartedMitmWizard;
@ -246,7 +249,14 @@ public class SettingsActivity extends BaseActivity implements PreferenceFragment
private void setupCapturePrefs() {
mCapInterface = requirePreference(Prefs.PREF_CAPTURE_INTERFACE);
mVpnExceptions = requirePreference(Prefs.PREF_VPN_EXCEPTIONS);
refreshInterfaces();
mVpnExceptions.setOnPreferenceClickListener(preference -> {
Intent intent = new Intent(requireContext(), VpnExceptionsActivity.class);
startActivity(intent);
return true;
});
}
private void setupSecurityPrefs() {
@ -391,6 +401,7 @@ public class SettingsActivity extends BaseActivity implements PreferenceFragment
mIpMode.setVisible(!enabled);
mCapInterface.setVisible(enabled);
mVpnExceptions.setVisible(!enabled);
}
}
}

View File

@ -0,0 +1,102 @@
/*
* This file is part of PCAPdroid.
*
* PCAPdroid is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PCAPdroid is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright 2020-22 - Emanuele Faranda
*/
package com.emanuelef.remote_capture.activities;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.PreferenceManager;
import com.emanuelef.remote_capture.R;
import com.emanuelef.remote_capture.fragments.AppsToggles;
import com.emanuelef.remote_capture.model.AppDescriptor;
import com.emanuelef.remote_capture.model.Prefs;
import java.util.HashSet;
import java.util.Set;
public class VpnExceptionsActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setTitle(R.string.vpn_exceptions);
setContentView(R.layout.fragment_activity);
getSupportFragmentManager().beginTransaction()
.replace(R.id.fragment, new VpnExceptionsFragment())
.commit();
}
public static class VpnExceptionsFragment extends AppsToggles {
private static final String TAG = "VpnExceptions";
private final Set<String> mExcludedApps = new HashSet<>();
private @Nullable SharedPreferences mPrefs;
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
mPrefs = PreferenceManager.getDefaultSharedPreferences(context);
assert mPrefs != null;
mExcludedApps.clear();
Set<String> saved = mPrefs.getStringSet(Prefs.PREF_VPN_EXCEPTIONS, null);
if(saved != null) {
Log.d(TAG, "Loading " + saved.size() + " exceptions");
mExcludedApps.addAll(saved);
}
}
@Override
public void onDetach() {
super.onDetach();
mPrefs = null;
}
@Override
protected Set<String> getCheckedApps() {
return mExcludedApps;
}
@Override
public void onAppToggled(AppDescriptor app, boolean checked) {
String packageName = app.getPackageName();
if(mExcludedApps.contains(packageName) == checked)
return; // nothing to do
if(checked)
mExcludedApps.add(packageName);
else
mExcludedApps.remove(packageName);
Log.d(TAG, "Saving " + mExcludedApps.size() + " exceptions");
if(mPrefs == null)
return;
mPrefs.edit()
.putStringSet(Prefs.PREF_VPN_EXCEPTIONS, mExcludedApps)
.apply();
}
}
}

View File

@ -53,7 +53,7 @@ public class AppsAdapter extends RecyclerView.Adapter<AppsAdapter.AppViewHolder>
public AppViewHolder(View view) {
super(view);
textInListView = view.findViewById(R.id.list_app_name);
textInListView = view.findViewById(R.id.app_name);
imageInListView = view.findViewById(R.id.app_icon);
packageInListView= view.findViewById(R.id.app_package);
}

View File

@ -0,0 +1,209 @@
/*
* This file is part of PCAPdroid.
*
* PCAPdroid is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PCAPdroid is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright 2020-21 - Emanuele Faranda
*/
package com.emanuelef.remote_capture.adapters;
import android.annotation.SuppressLint;
import android.content.Context;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SwitchCompat;
import androidx.recyclerview.widget.RecyclerView;
import com.emanuelef.remote_capture.R;
import com.emanuelef.remote_capture.model.AppDescriptor;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class AppsTogglesAdapter extends RecyclerView.Adapter<AppsTogglesAdapter.AppViewHolder> {
private static final String TAG = "AppToggleAdapter";
private final LayoutInflater mLayoutInflater;
private final Set<String> mCheckedItems;
private AppToggleListener mListener;
private List<AppDescriptor> mApps = new ArrayList<>();
private @Nullable RecyclerView mRecyclerView;
public AppsTogglesAdapter(Context context, Set<String> checkedItems) {
mLayoutInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
mCheckedItems = new HashSet<>(checkedItems);
mListener = null;
}
public interface AppToggleListener {
void onAppToggled(AppDescriptor app, boolean checked);
}
public static class AppViewHolder extends RecyclerView.ViewHolder {
TextView appName;
TextView packageName;
ImageView icon;
SwitchCompat toggle;
public AppViewHolder(View view) {
super(view);
appName = view.findViewById(R.id.app_name);
icon = view.findViewById(R.id.icon);
packageName = view.findViewById(R.id.app_package);
toggle = view.findViewById(R.id.toggle_btn);
}
}
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
super.onAttachedToRecyclerView(recyclerView);
mRecyclerView = recyclerView;
}
@Override
public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
super.onDetachedFromRecyclerView(recyclerView);
mRecyclerView = null;
}
@NonNull
@Override
public AppsTogglesAdapter.AppViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = mLayoutInflater.inflate(R.layout.app_selection_item, parent, false);
AppViewHolder recyclerViewHolder = new AppViewHolder(view);
view.setOnClickListener((v) -> {
if(mRecyclerView != null) {
int pos = recyclerViewHolder.getAbsoluteAdapterPosition();
boolean checked = mCheckedItems.contains(getItem(pos).getPackageName());
handleToggle(pos, !checked);
}
});
recyclerViewHolder.toggle.setOnClickListener((v) -> {
if(mRecyclerView != null) {
int pos = recyclerViewHolder.getAbsoluteAdapterPosition();
boolean checked = ((SwitchCompat)v).isChecked();
handleToggle(pos, checked);
}
});
return(recyclerViewHolder);
}
@Override
public void onBindViewHolder(@NonNull AppViewHolder holder, int position) {
AppDescriptor app = getItem(position);
holder.appName.setText(app.getName());
holder.packageName.setText(app.getPackageName());
holder.toggle.setChecked(mCheckedItems.contains(app.getPackageName()));
if(app.getIcon() != null)
holder.icon.setImageDrawable(app.getIcon());
}
@Override
public int getItemCount() {
return mApps.size();
}
public AppDescriptor getItem(int pos) {
if((pos < 0) || (pos > mApps.size()))
return null;
return mApps.get(pos);
}
private void handleToggle(int old_pos, boolean checked) {
AppDescriptor app = getItem(old_pos);
String packageName = app.getPackageName();
if(checked == mCheckedItems.contains(packageName))
return; // nothing changed
if(checked)
mCheckedItems.add(packageName);
else
mCheckedItems.remove(packageName);
if(mListener != null)
mListener.onAppToggled(app, checked);
// determine the new item position
int new_pos = old_pos;
for(int i=0; i<mApps.size(); i++) {
AppDescriptor other = mApps.get(i);
if((i != old_pos) && compareCheckedFirst(app, other) <= 0) {
new_pos = i;
break;
}
}
if(new_pos > old_pos)
new_pos--;
Log.d(TAG, "Item @" + old_pos + ": " + (checked ? "checked" : "unchecked") + " -> " + new_pos);
notifyItemChanged(old_pos);
if(new_pos != old_pos) {
mApps.remove(old_pos);
mApps.add(new_pos, app);
notifyItemMoved(old_pos, new_pos);
if(mRecyclerView != null) {
if(checked)
mRecyclerView.scrollToPosition(new_pos);
else
mRecyclerView.scrollToPosition(old_pos);
}
}
}
// sort apps so that checked items always appear first
private int compareCheckedFirst(AppDescriptor a, AppDescriptor b) {
boolean aChecked = mCheckedItems.contains(a.getPackageName());
boolean bChecked = mCheckedItems.contains(b.getPackageName());
if(aChecked && !bChecked)
return -1;
else if(!aChecked && bChecked)
return 1;
return a.compareTo(b);
}
@SuppressLint("NotifyDataSetChanged")
public void setApps(List<AppDescriptor> apps) {
mApps = apps;
Collections.sort(mApps, this::compareCheckedFirst);
notifyDataSetChanged();
}
public void setAppToggleListener(final AppToggleListener listener) {
mListener = listener;
}
}

View File

@ -40,7 +40,6 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.emanuelef.remote_capture.Billing;
import com.emanuelef.remote_capture.CaptureService;
import com.emanuelef.remote_capture.ConnectionsRegister;
import com.emanuelef.remote_capture.PCAPdroid;
@ -84,7 +83,7 @@ public class AppsFragment extends Fragment implements ConnectionsListener {
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
mRecyclerView = view.findViewById(R.id.apps_stats_view);
mRecyclerView = view.findViewById(R.id.recycler_view);
mRecyclerView.setLayoutManager(new EmptyRecyclerView.MyLinearLayoutManager(getContext()));
registerForContextMenu(mRecyclerView);

View File

@ -0,0 +1,82 @@
/*
* This file is part of PCAPdroid.
*
* PCAPdroid is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PCAPdroid is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright 2020-22 - Emanuele Faranda
*/
package com.emanuelef.remote_capture.fragments;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import com.emanuelef.remote_capture.AppsLoader;
import com.emanuelef.remote_capture.R;
import com.emanuelef.remote_capture.adapters.AppsTogglesAdapter;
import com.emanuelef.remote_capture.interfaces.AppsLoadListener;
import com.emanuelef.remote_capture.model.AppDescriptor;
import com.emanuelef.remote_capture.views.EmptyRecyclerView;
import java.util.List;
import java.util.Set;
import kotlin.NotImplementedError;
public abstract class AppsToggles extends Fragment implements AppsLoadListener, AppsTogglesAdapter.AppToggleListener {
private AppsTogglesAdapter mAdapter;
@Override
public View onCreateView(LayoutInflater inflater,
ViewGroup container, Bundle savedInstanceState) {
setHasOptionsMenu(true);
return inflater.inflate(R.layout.apps_stats, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
EmptyRecyclerView recyclerView = view.findViewById(R.id.recycler_view);
recyclerView.setLayoutManager(new EmptyRecyclerView.MyLinearLayoutManager(getContext()));
mAdapter = new AppsTogglesAdapter(requireContext(), getCheckedApps());
recyclerView.setAdapter(mAdapter);
mAdapter.setAppToggleListener(this);
TextView emptyAppsView = view.findViewById(R.id.no_apps);
emptyAppsView.setText(R.string.loading_apps);
recyclerView.setEmptyView(emptyAppsView);
(new AppsLoader((AppCompatActivity) requireActivity()))
.setAppsLoadListener(this)
.loadAllApps();
}
@Override
public void onAppsInfoLoaded(List<AppDescriptor> apps) {
mAdapter.setApps(apps);
}
// Must be implemented in sub-classes
protected Set<String> getCheckedApps() {
throw new NotImplementedError();
}
}

View File

@ -74,6 +74,7 @@ public class Prefs {
public static final String PREF_AUTO_BLOCK_PRIVATE_DNS = "auto_block_private_dns";
public static final String PREF_APP_VERSION = "appver";
public static final String PREF_LOCKDOWN_VPN_NOTICE_SHOWN = "vpn_lockdown_notice";
public static final String PREF_VPN_EXCEPTIONS = "vpn_exceptions";
public enum DumpMode {
NONE,

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
@ -17,21 +18,20 @@
android:layout_height="36dp"/>
<LinearLayout
android:layout_marginLeft="3dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginStart="10dp">
<TextView
android:id="@+id/list_app_name"
android:id="@+id/app_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="14sp"
android:text="App Name"/>
tools:text="App Name"/>
<TextView
android:id="@+id/app_package"
android:text="App Package Name"
tools:text="App Package Name"
android:textSize="12sp"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:background="?attr/selectableItemBackground"
android:orientation="horizontal"
android:padding="4dp"
android:gravity="center_vertical"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/icon"
android:layout_width="40dp"
android:layout_height="40dp"
tools:src="@drawable/ic_apps"
tools:tint="@color/danger"
android:layout_margin="4dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="10dp">
<TextView
android:id="@+id/app_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="14sp"
tools:text="App Name"/>
<TextView
android:id="@+id/app_package"
tools:text="App Package Name"
android:textSize="12sp"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/toggle_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical|end"/>
</LinearLayout>

View File

@ -4,7 +4,7 @@
android:layout_height="match_parent">
<com.emanuelef.remote_capture.views.EmptyRecyclerView
android:id="@+id/apps_stats_view"
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"

View File

@ -372,4 +372,6 @@
<string name="ipv6_only">IPv6 only</string>
<string name="ip_both">IPv4 and IPv6</string>
<string name="notifications_notice">The app uses notifications to send alerts in case of anomalous events. Grant it the permission to send notifications in the next screen</string>
<string name="vpn_exceptions">VPN Exceptions</string>
<string name="vpn_exceptions_summary">Exclude some apps from the VPN connection. Their traffic will not be monitored</string>
</resources>

View File

@ -117,6 +117,13 @@
app:useSimpleSummaryProvider="true"
app:defaultValue="\@inet" />
<Preference
android:key="vpn_exceptions"
app:title="@string/vpn_exceptions"
app:summary="@string/vpn_exceptions_summary"
app:iconSpaceReserved="false"
app:fragment="com.emanuelef.remote_capture.fragments.VpnExceptions" />
<SwitchPreference
app:key="start_at_boot"
android:title="@string/start_at_boot"