mirror of
https://github.com/emanuele-f/PCAPdroid.git
synced 2026-06-16 21:10:57 +08:00
Rework whitelist
The whitelist editor is now a separate activity. Whitelist removed from the ConnectionRegister.
This commit is contained in:
parent
9b7675d6e2
commit
f49c34ddec
@ -62,6 +62,10 @@
|
||||
android:name=".activities.LogviewActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:parentActivityName=".activities.MainActivity" />
|
||||
<activity
|
||||
android:name=".activities.WhitelistActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:parentActivityName=".activities.MainActivity" />
|
||||
|
||||
<service
|
||||
android:name=".CaptureService"
|
||||
|
||||
@ -196,7 +196,7 @@ public class CaptureService extends VpnService implements Runnable {
|
||||
last_bytes = 0;
|
||||
last_connections = 0;
|
||||
root_capture = Prefs.isRootCaptureEnabled(prefs);
|
||||
conn_reg = new ConnectionsRegister(CONNECTIONS_LOG_SIZE, this, prefs);
|
||||
conn_reg = new ConnectionsRegister(CONNECTIONS_LOG_SIZE);
|
||||
|
||||
if(dump_mode != Prefs.DumpMode.HTTP_SERVER)
|
||||
mHttpServer = null;
|
||||
@ -432,9 +432,6 @@ public class CaptureService extends VpnService implements Runnable {
|
||||
private void stop() {
|
||||
stopPacketLoop();
|
||||
|
||||
if(conn_reg != null)
|
||||
conn_reg.saveWhitelist();
|
||||
|
||||
while((mThread != null) && (mThread.isAlive())) {
|
||||
try {
|
||||
Log.d(TAG, "Joining native thread...");
|
||||
|
||||
@ -19,8 +19,6 @@
|
||||
|
||||
package com.emanuelef.remote_capture;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
@ -28,8 +26,6 @@ import androidx.annotation.Nullable;
|
||||
import com.emanuelef.remote_capture.interfaces.ConnectionsListener;
|
||||
import com.emanuelef.remote_capture.model.AppStats;
|
||||
import com.emanuelef.remote_capture.model.ConnectionDescriptor;
|
||||
import com.emanuelef.remote_capture.model.ConnectionsMatcher;
|
||||
import com.emanuelef.remote_capture.model.Prefs;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@ -49,11 +45,8 @@ public class ConnectionsRegister {
|
||||
private int mUntrackedItems;
|
||||
private final Map<Integer, AppStats> mAppsStats;
|
||||
private final ArrayList<ConnectionsListener> mListeners;
|
||||
private final SharedPreferences mPrefs;
|
||||
public final ConnectionsMatcher mWhitelist;
|
||||
public boolean mWhitelistEnabled;
|
||||
|
||||
public ConnectionsRegister(int _size, Context context, SharedPreferences prefs) {
|
||||
public ConnectionsRegister(int _size) {
|
||||
mTail = 0;
|
||||
mNumItems = 0;
|
||||
mUntrackedItems = 0;
|
||||
@ -61,15 +54,6 @@ public class ConnectionsRegister {
|
||||
mItemsRing = new ConnectionDescriptor[mSize];
|
||||
mListeners = new ArrayList<>();
|
||||
mAppsStats = new HashMap<>(); // uid -> AppStats
|
||||
mWhitelistEnabled = true;
|
||||
mPrefs = prefs;
|
||||
mWhitelist = new ConnectionsMatcher(context);
|
||||
|
||||
// Try to restore the whitelist
|
||||
String serialized = prefs.getString(Prefs.PREF_WHITELIST, "");
|
||||
|
||||
if(!serialized.isEmpty())
|
||||
mWhitelist.fromJson(serialized);
|
||||
}
|
||||
|
||||
private int firstPos() {
|
||||
@ -220,10 +204,6 @@ public class ConnectionsRegister {
|
||||
return mUntrackedItems;
|
||||
}
|
||||
|
||||
public boolean hasWhitelistFilter() {
|
||||
return(mWhitelistEnabled && !mWhitelist.isEmpty());
|
||||
}
|
||||
|
||||
public @Nullable ConnectionDescriptor getConn(int i) {
|
||||
if(i >= mNumItems)
|
||||
return null;
|
||||
@ -269,10 +249,4 @@ public class ConnectionsRegister {
|
||||
|
||||
return rv;
|
||||
}
|
||||
|
||||
public void saveWhitelist() {
|
||||
mPrefs.edit()
|
||||
.putString(Prefs.PREF_WHITELIST, mWhitelist.toJson())
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
|
||||
@ -347,6 +347,9 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
|
||||
startActivity(intent);
|
||||
} else
|
||||
Utils.showToast(this, R.string.capture_not_started);
|
||||
} else if(id == R.id.edit_whitelist) {
|
||||
Intent intent = new Intent(MainActivity.this, WhitelistActivity.class);
|
||||
startActivity(intent);
|
||||
} else if(id == R.id.open_root_log) {
|
||||
Intent intent = new Intent(MainActivity.this, LogviewActivity.class);
|
||||
startActivity(intent);
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.activities;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.emanuelef.remote_capture.R;
|
||||
import com.emanuelef.remote_capture.fragments.AppsFragment;
|
||||
import com.emanuelef.remote_capture.fragments.WhitelistFragment;
|
||||
|
||||
public class WhitelistActivity extends BaseActivity {
|
||||
private static final String TAG = "WhitelistActivity";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
setTitle(R.string.whitelist);
|
||||
setContentView(R.layout.whitelist_activity);
|
||||
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.whitelist_fragment, new WhitelistFragment())
|
||||
.commit();
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,7 @@ import com.emanuelef.remote_capture.model.ConnectionDescriptor;
|
||||
import com.emanuelef.remote_capture.ConnectionsRegister;
|
||||
import com.emanuelef.remote_capture.R;
|
||||
import com.emanuelef.remote_capture.Utils;
|
||||
import com.emanuelef.remote_capture.model.Whitelist;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
@ -57,8 +58,10 @@ public class ConnectionsAdapter extends RecyclerView.Adapter<ConnectionsAdapter.
|
||||
private int mClickedPosition;
|
||||
private int mUidFilter;
|
||||
private int mNumRemovedItems;
|
||||
public boolean mWhitelistEnabled;
|
||||
private final HashMap<Integer, Integer> mIdToFilteredPos;
|
||||
private ArrayList<ConnectionDescriptor> mFilteredConn;
|
||||
public final Whitelist mWhitelist;
|
||||
|
||||
public static class ViewHolder extends RecyclerView.ViewHolder {
|
||||
ImageView icon;
|
||||
@ -135,7 +138,11 @@ public class ConnectionsAdapter extends RecyclerView.Adapter<ConnectionsAdapter.
|
||||
mNumRemovedItems = 0;
|
||||
mIdToFilteredPos = new HashMap<>();
|
||||
mUidFilter = Utils.UID_NO_FILTER;
|
||||
mWhitelistEnabled = true;
|
||||
mWhitelist = new Whitelist(context);
|
||||
setHasStableIds(true);
|
||||
|
||||
mWhitelist.reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -181,10 +188,10 @@ public class ConnectionsAdapter extends RecyclerView.Adapter<ConnectionsAdapter.
|
||||
return ((conn != null) ? conn.incr_id : Utils.UID_UNKNOWN);
|
||||
}
|
||||
|
||||
private boolean matches(ConnectionDescriptor conn, ConnectionsRegister reg) {
|
||||
private boolean matches(ConnectionDescriptor conn) {
|
||||
return((conn != null)
|
||||
&& ((mUidFilter == Utils.UID_NO_FILTER) || (conn.uid == mUidFilter))
|
||||
&& (!reg.mWhitelistEnabled || !reg.mWhitelist.matches(conn)));
|
||||
&& (!mWhitelistEnabled || !mWhitelist.matches(conn)));
|
||||
}
|
||||
|
||||
private int getFilteredItemPos(int incrId) {
|
||||
@ -211,13 +218,12 @@ public class ConnectionsAdapter extends RecyclerView.Adapter<ConnectionsAdapter.
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectionsRegister reg = CaptureService.requireConnsRegister();
|
||||
int numNew = 0;
|
||||
int vpos = mNumRemovedItems + mFilteredConn.size();
|
||||
|
||||
// Assume that connections are only added at the end of the dataset
|
||||
for(ConnectionDescriptor conn : conns) {
|
||||
if(matches(conn, reg)) {
|
||||
if(matches(conn)) {
|
||||
mIdToFilteredPos.put(conn.incr_id, vpos++);
|
||||
mFilteredConn.add(conn);
|
||||
numNew++;
|
||||
@ -288,14 +294,14 @@ public class ConnectionsAdapter extends RecyclerView.Adapter<ConnectionsAdapter.
|
||||
mIdToFilteredPos.clear();
|
||||
mNumRemovedItems = 0;
|
||||
|
||||
if((mUidFilter != Utils.UID_NO_FILTER) || reg.hasWhitelistFilter()) {
|
||||
if((mUidFilter != Utils.UID_NO_FILTER) || hasWhitelistFilter()) {
|
||||
int vpos = 0;
|
||||
mFilteredConn = new ArrayList<>();
|
||||
|
||||
for(int i=0; i<mUnfilteredItemsCount; i++) {
|
||||
ConnectionDescriptor conn = reg.getConn(i);
|
||||
|
||||
if(matches(conn, reg)) {
|
||||
if(matches(conn)) {
|
||||
mFilteredConn.add(conn);
|
||||
mIdToFilteredPos.put(conn.incr_id, vpos++);
|
||||
}
|
||||
@ -336,6 +342,10 @@ public class ConnectionsAdapter extends RecyclerView.Adapter<ConnectionsAdapter.
|
||||
return getItem(mClickedPosition);
|
||||
}
|
||||
|
||||
public boolean hasWhitelistFilter() {
|
||||
return (mWhitelistEnabled && !mWhitelist.isEmpty());
|
||||
}
|
||||
|
||||
public synchronized String dumpConnectionsCsv() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
AppsResolver resolver = new AppsResolver(mContext);
|
||||
|
||||
@ -36,52 +36,26 @@ import java.util.Iterator;
|
||||
|
||||
public class WhitelistEditAdapter extends ArrayAdapter<ConnectionsMatcher.Item> {
|
||||
private final LayoutInflater mLayoutInflater;
|
||||
private boolean mShowTrash;
|
||||
private final int mResId;
|
||||
|
||||
public WhitelistEditAdapter(Context context, int res, Iterator<ConnectionsMatcher.Item> items) {
|
||||
super(context, res);
|
||||
|
||||
mResId = res;
|
||||
mShowTrash = true;
|
||||
public WhitelistEditAdapter(Context context, Iterator<ConnectionsMatcher.Item> items) {
|
||||
super(context, R.layout.whitelist_item);
|
||||
mLayoutInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
||||
|
||||
while(items.hasNext()) {
|
||||
ConnectionsMatcher.Item item = items.next();
|
||||
add(item);
|
||||
}
|
||||
|
||||
recheckSize();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
|
||||
if(convertView == null)
|
||||
convertView = mLayoutInflater.inflate(mResId, parent, false);
|
||||
convertView = mLayoutInflater.inflate(R.layout.whitelist_item, parent, false);
|
||||
|
||||
ConnectionsMatcher.Item item = getItem(position);
|
||||
((TextView)convertView.findViewById(R.id.item_label)).setText(item.getLabel());
|
||||
convertView.findViewById(R.id.item_icon).setVisibility(mShowTrash ? View.VISIBLE : View.INVISIBLE);
|
||||
|
||||
return convertView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(@Nullable ConnectionsMatcher.Item object) {
|
||||
super.remove(object);
|
||||
|
||||
recheckSize();
|
||||
}
|
||||
|
||||
private void recheckSize() {
|
||||
if(getCount() == 1) {
|
||||
// Prevent an empty view
|
||||
mShowTrash = false;
|
||||
notifyDataSetChanged();
|
||||
} else if(!mShowTrash) {
|
||||
mShowTrash = true;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,7 +38,6 @@ import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
@ -47,7 +46,6 @@ import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
@ -61,7 +59,6 @@ import com.emanuelef.remote_capture.ConnectionsRegister;
|
||||
import com.emanuelef.remote_capture.R;
|
||||
import com.emanuelef.remote_capture.Utils;
|
||||
import com.emanuelef.remote_capture.activities.MainActivity;
|
||||
import com.emanuelef.remote_capture.adapters.WhitelistEditAdapter;
|
||||
import com.emanuelef.remote_capture.model.AppDescriptor;
|
||||
import com.emanuelef.remote_capture.model.AppState;
|
||||
import com.emanuelef.remote_capture.model.ConnectionDescriptor;
|
||||
@ -77,7 +74,6 @@ import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@ -92,7 +88,6 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
|
||||
private boolean autoScroll;
|
||||
private boolean listenerSet;
|
||||
private MenuItem mMenuItemAppSel;
|
||||
private MenuItem mMenuItemWhitelist;
|
||||
private MenuItem mMenuItemEnableWhitelist;
|
||||
private MenuItem mMenuItemDisableWhitelist;
|
||||
private MenuItem mSave;
|
||||
@ -110,11 +105,11 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
registerConnsListener();
|
||||
// Reload the whitelist as it could modified in WhitelistActivity
|
||||
mAdapter.mWhitelist.reload();
|
||||
|
||||
// reg.mWhitelistEnabled may have changed (e.g. when filtering from the AppsActivity
|
||||
if(mMenuItemWhitelist != null)
|
||||
refreshWhitelistMenu();
|
||||
registerConnsListener();
|
||||
refreshMenuIcons();
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -129,6 +124,7 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
|
||||
super.onSaveInstanceState(outState);
|
||||
|
||||
outState.putInt("uidFilter", mAdapter.getUidFilter());
|
||||
outState.putBoolean("whitelistEnabled", mAdapter.mWhitelistEnabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -232,22 +228,23 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
|
||||
if(uidFilter != Utils.UID_NO_FILTER) {
|
||||
// "consume" it
|
||||
intent.removeExtra(MainActivity.UID_FILTER_EXTRA);
|
||||
|
||||
// disable the whitelist to prevent an empty view
|
||||
ConnectionsRegister reg = CaptureService.getConnsRegister();
|
||||
|
||||
if(reg != null)
|
||||
reg.mWhitelistEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ((uidFilter == Utils.UID_NO_FILTER) && (savedInstanceState != null)) {
|
||||
uidFilter = savedInstanceState.getInt("uidFilter", Utils.UID_NO_FILTER);
|
||||
if(savedInstanceState != null) {
|
||||
if(uidFilter == Utils.UID_NO_FILTER)
|
||||
uidFilter = savedInstanceState.getInt("uidFilter", Utils.UID_NO_FILTER);
|
||||
|
||||
mAdapter.mWhitelistEnabled = savedInstanceState.getBoolean("whitelistEnabled", true);
|
||||
}
|
||||
|
||||
if(uidFilter != Utils.UID_NO_FILTER)
|
||||
if(uidFilter != Utils.UID_NO_FILTER) {
|
||||
setUidFilter(uidFilter);
|
||||
|
||||
// Avoid hiding the interesting items
|
||||
mAdapter.mWhitelistEnabled = false;
|
||||
}
|
||||
|
||||
// Register for service status
|
||||
mReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
@ -330,29 +327,29 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
|
||||
|
||||
@Override
|
||||
public boolean onContextItemSelected(@NonNull MenuItem item) {
|
||||
ConnectionsRegister reg = CaptureService.getConnsRegister();
|
||||
ConnectionDescriptor conn = mAdapter.getClickedItem();
|
||||
|
||||
if((reg == null) || (conn == null))
|
||||
if(conn == null)
|
||||
return super.onContextItemSelected(item);
|
||||
|
||||
int id = item.getItemId();
|
||||
String label = item.getTitle().toString();
|
||||
|
||||
if(id == R.id.exclude_app)
|
||||
reg.mWhitelist.addApp(conn.uid, label);
|
||||
mAdapter.mWhitelist.addApp(conn.uid, label);
|
||||
else if(id == R.id.exclude_host)
|
||||
reg.mWhitelist.addHost(conn.info, label);
|
||||
mAdapter.mWhitelist.addHost(conn.info, label);
|
||||
else if(id == R.id.exclude_ip)
|
||||
reg.mWhitelist.addIp(conn.dst_ip, label);
|
||||
mAdapter.mWhitelist.addIp(conn.dst_ip, label);
|
||||
else if(id == R.id.exclude_proto)
|
||||
reg.mWhitelist.addProto(conn.l7proto, label);
|
||||
mAdapter.mWhitelist.addProto(conn.l7proto, label);
|
||||
else if(id == R.id.exclude_root_domain)
|
||||
reg.mWhitelist.addRootDomain(Utils.getRootDomain(conn.info), label);
|
||||
mAdapter.mWhitelist.addRootDomain(Utils.getRootDomain(conn.info), label);
|
||||
else
|
||||
return super.onContextItemSelected(item);
|
||||
|
||||
reg.mWhitelistEnabled = true;
|
||||
mAdapter.mWhitelist.save();
|
||||
mAdapter.mWhitelistEnabled = true;
|
||||
refreshFilteredConnections();
|
||||
return true;
|
||||
}
|
||||
@ -402,7 +399,7 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
|
||||
// This performs an unoptimized adapter refresh
|
||||
private void refreshFilteredConnections() {
|
||||
mAdapter.refreshFilteredConnections();
|
||||
refreshWhitelistMenu();
|
||||
refreshMenuIcons();
|
||||
recheckScroll();
|
||||
}
|
||||
|
||||
@ -465,14 +462,12 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
|
||||
|
||||
mSave = menu.findItem(R.id.save);
|
||||
mMenuItemAppSel = menu.findItem(R.id.action_show_app_filter);
|
||||
mMenuItemWhitelist = menu.findItem(R.id.whitelist);
|
||||
mMenuItemEnableWhitelist = menu.findItem(R.id.enable_whitelist);
|
||||
mMenuItemDisableWhitelist = menu.findItem(R.id.disable_whitelist);
|
||||
mMenuItemEnableWhitelist = menu.findItem(R.id.hide_whitelist);
|
||||
mMenuItemDisableWhitelist = menu.findItem(R.id.show_whitelist);
|
||||
mFilterIcon = mMenuItemAppSel.getIcon();
|
||||
|
||||
refreshFilterIcon();
|
||||
refreshMenuIcons();
|
||||
refreshWhitelistMenu();
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -489,15 +484,12 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
|
||||
} else if(id == R.id.save) {
|
||||
openFileSelector();
|
||||
return true;
|
||||
} else if(id == R.id.edit_whitelist) {
|
||||
showWhitelistEditor();
|
||||
return true;
|
||||
} else if((id == R.id.enable_whitelist) || (id == R.id.disable_whitelist)) {
|
||||
} else if((id == R.id.hide_whitelist) || (id == R.id.show_whitelist)) {
|
||||
ConnectionsRegister reg = CaptureService.getConnsRegister();
|
||||
if(reg == null)
|
||||
return false;
|
||||
|
||||
reg.mWhitelistEnabled = !reg.mWhitelistEnabled;
|
||||
mAdapter.mWhitelistEnabled = !mAdapter.mWhitelistEnabled;
|
||||
|
||||
// Delay the refresh to wait for the menu to be closed
|
||||
(new Handler(requireActivity().getMainLooper())).postDelayed(this::refreshFilteredConnections, 50);
|
||||
@ -576,25 +568,14 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
|
||||
|
||||
mMenuItemAppSel.setEnabled(is_enabled);
|
||||
mSave.setEnabled(is_enabled);
|
||||
}
|
||||
|
||||
private void refreshWhitelistMenu() {
|
||||
ConnectionsRegister reg = CaptureService.getConnsRegister();
|
||||
|
||||
if(reg == null) {
|
||||
mMenuItemWhitelist.setVisible(false);
|
||||
return;
|
||||
if((mAdapter == null) || mAdapter.mWhitelist.isEmpty()) {
|
||||
mMenuItemDisableWhitelist.setVisible(false);
|
||||
mMenuItemEnableWhitelist.setVisible(false);
|
||||
} else {
|
||||
mMenuItemDisableWhitelist.setVisible(mAdapter.mWhitelistEnabled);
|
||||
mMenuItemEnableWhitelist.setVisible(!mAdapter.mWhitelistEnabled);
|
||||
}
|
||||
|
||||
// Update the icon only if something changed
|
||||
// NOTE: getApplicationContext required to properly style the tint
|
||||
mMenuItemWhitelist.setIcon(
|
||||
ContextCompat.getDrawable(requireContext().getApplicationContext(),
|
||||
reg.mWhitelistEnabled ? R.drawable.ic_eye_slash : R.drawable.ic_eye));
|
||||
|
||||
mMenuItemWhitelist.setVisible(!reg.mWhitelist.isEmpty());
|
||||
mMenuItemDisableWhitelist.setVisible(reg.mWhitelistEnabled);
|
||||
mMenuItemEnableWhitelist.setVisible(!reg.mWhitelistEnabled);
|
||||
}
|
||||
|
||||
private void dumpCsv() {
|
||||
@ -663,59 +644,6 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
|
||||
}
|
||||
}
|
||||
|
||||
private void showWhitelistEditor() {
|
||||
ConnectionsRegister reg = CaptureService.getConnsRegister();
|
||||
|
||||
if(reg == null)
|
||||
return;
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(requireActivity());
|
||||
WhitelistEditAdapter adapter = new WhitelistEditAdapter(requireContext(),
|
||||
R.layout.whitelist_item, reg.mWhitelist.iterItems());
|
||||
View exclListView = requireActivity().getLayoutInflater().inflate(R.layout.whitelist, null);
|
||||
|
||||
ListView whitelist = exclListView.findViewById(R.id.list);
|
||||
whitelist.setAdapter(adapter);
|
||||
whitelist.setOnItemClickListener((parent, view, position, id) -> {
|
||||
if(adapter.getCount() > 1)
|
||||
adapter.remove(adapter.getItem(position));
|
||||
});
|
||||
|
||||
builder.setTitle(R.string.edit_whitelist);
|
||||
builder.setView(exclListView);
|
||||
builder.setPositiveButton(R.string.ok, (dialog, which) -> updateWhitelist(adapter));
|
||||
builder.setNeutralButton(R.string.cancel, (dialog, which) -> dialog.dismiss());
|
||||
|
||||
final AlertDialog alert = builder.create();
|
||||
alert.setCanceledOnTouchOutside(true);
|
||||
|
||||
alert.show();
|
||||
}
|
||||
|
||||
private void updateWhitelist(WhitelistEditAdapter adapter) {
|
||||
ConnectionsRegister reg = CaptureService.getConnsRegister();
|
||||
ArrayList<ConnectionsMatcher.Item> toRemove = new ArrayList<>();
|
||||
|
||||
if(reg == null)
|
||||
return;
|
||||
|
||||
Iterator<ConnectionsMatcher.Item> iter = reg.mWhitelist.iterItems();
|
||||
boolean changed = false;
|
||||
|
||||
// Remove the whitelisted items which are not in the adapter dataset
|
||||
while(iter.hasNext()) {
|
||||
ConnectionsMatcher.Item item = iter.next();
|
||||
|
||||
if(adapter.getPosition(item) < 0)
|
||||
toRemove.add(item);
|
||||
}
|
||||
|
||||
if(toRemove.size() > 0) {
|
||||
reg.mWhitelist.removeItems(toRemove);
|
||||
refreshFilteredConnections();
|
||||
}
|
||||
}
|
||||
|
||||
private void csvFileResult(final ActivityResult result) {
|
||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
|
||||
mCsvFname = result.getData().getData();
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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.fragments;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.os.Bundle;
|
||||
import android.view.ActionMode;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.AbsListView;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import com.emanuelef.remote_capture.R;
|
||||
import com.emanuelef.remote_capture.adapters.WhitelistEditAdapter;
|
||||
import com.emanuelef.remote_capture.model.ConnectionsMatcher;
|
||||
import com.emanuelef.remote_capture.model.Whitelist;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
|
||||
public class WhitelistFragment extends Fragment {
|
||||
private WhitelistEditAdapter mAdapter;
|
||||
private TextView mEmptyText;
|
||||
private ArrayList<ConnectionsMatcher.Item> mSelected = new ArrayList<>();
|
||||
private Whitelist mWhitelist;
|
||||
private ListView mWhitelistView;
|
||||
private static final String TAG = "WhitelistFragment";
|
||||
|
||||
@Override
|
||||
public View onCreateView(LayoutInflater inflater,
|
||||
ViewGroup container, Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.whitelist_fragment, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
mWhitelistView = view.findViewById(R.id.whitelist);
|
||||
mEmptyText = view.findViewById(R.id.whitelist_empty);
|
||||
mWhitelist = new Whitelist(view.getContext());
|
||||
mWhitelist.reload();
|
||||
|
||||
mAdapter = new WhitelistEditAdapter(requireContext(), mWhitelist.iterItems());
|
||||
mWhitelistView.setAdapter(mAdapter);
|
||||
mWhitelistView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL);
|
||||
mWhitelistView.setMultiChoiceModeListener(new AbsListView.MultiChoiceModeListener() {
|
||||
@Override
|
||||
public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) {
|
||||
ConnectionsMatcher.Item item = mAdapter.getItem(position);
|
||||
|
||||
if(checked)
|
||||
mSelected.add(item);
|
||||
else
|
||||
mSelected.remove(item);
|
||||
|
||||
mode.setTitle(getString(R.string.n_selected, mSelected.size()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
||||
MenuInflater inflater = requireActivity().getMenuInflater();
|
||||
inflater.inflate(R.menu.whitelist_cab, menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) {
|
||||
int id = menuItem.getItemId();
|
||||
|
||||
if(id == R.id.delete_entry) {
|
||||
if(mSelected.size() >= mAdapter.getCount()) {
|
||||
mAdapter.clear();
|
||||
mWhitelist.clear();
|
||||
mWhitelist.save();
|
||||
} else {
|
||||
for(ConnectionsMatcher.Item item : mSelected)
|
||||
mAdapter.remove(item);
|
||||
updateWhitelist();
|
||||
}
|
||||
|
||||
mode.finish();
|
||||
recheckWhitelistSize();
|
||||
return true;
|
||||
} else if(id == R.id.select_all) {
|
||||
if(mSelected.size() >= mAdapter.getCount())
|
||||
mode.finish();
|
||||
else {
|
||||
for(int i=0; i<mAdapter.getCount(); i++) {
|
||||
if(!mWhitelistView.isItemChecked(i))
|
||||
mWhitelistView.setItemChecked(i, true);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} else
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyActionMode(ActionMode mode) {
|
||||
mSelected = new ArrayList<>();
|
||||
}
|
||||
});
|
||||
|
||||
recheckWhitelistSize();
|
||||
}
|
||||
|
||||
private void recheckWhitelistSize() {
|
||||
mEmptyText.setVisibility((mAdapter.getCount() == 0) ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private void updateWhitelist() {
|
||||
ArrayList<ConnectionsMatcher.Item> toRemove = new ArrayList<>();
|
||||
|
||||
Iterator<ConnectionsMatcher.Item> iter = mWhitelist.iterItems();
|
||||
|
||||
// Remove the whitelisted items which are not in the adapter dataset
|
||||
while(iter.hasNext()) {
|
||||
ConnectionsMatcher.Item item = iter.next();
|
||||
|
||||
if (mAdapter.getPosition(item) < 0)
|
||||
toRemove.add(item);
|
||||
}
|
||||
|
||||
if(toRemove.size() > 0) {
|
||||
mWhitelist.removeItems(toRemove);
|
||||
mWhitelist.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -22,7 +22,6 @@ package com.emanuelef.remote_capture.model;
|
||||
import android.content.Context;
|
||||
import android.graphics.Typeface;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
@ -135,6 +134,8 @@ public class ConnectionsMatcher {
|
||||
|
||||
private void deserialize(JsonObject object) {
|
||||
mItems = new ArrayList<>();
|
||||
mMatches.clear();
|
||||
|
||||
JsonArray itemArray = object.getAsJsonArray("items");
|
||||
AppsResolver resolver = new AppsResolver(mContext);
|
||||
|
||||
@ -176,7 +177,6 @@ public class ConnectionsMatcher {
|
||||
|
||||
private void addItem(Item item) {
|
||||
String key = matchKey(item.getType(), item.getValue().toString());
|
||||
Log.d(TAG, key);
|
||||
|
||||
if(!mMatches.containsKey(key)) {
|
||||
mItems.add(item);
|
||||
@ -217,7 +217,7 @@ public class ConnectionsMatcher {
|
||||
}
|
||||
|
||||
public String toJson() {
|
||||
Gson gson = new GsonBuilder().registerTypeAdapter(ConnectionsMatcher.class, new Serializer())
|
||||
Gson gson = new GsonBuilder().registerTypeAdapter(getClass(), new Serializer())
|
||||
.create();
|
||||
|
||||
String serialized = gson.toJson(this);
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.model;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
public class Whitelist extends ConnectionsMatcher {
|
||||
private final SharedPreferences mPrefs;
|
||||
|
||||
public Whitelist(Context ctx) {
|
||||
super(ctx);
|
||||
|
||||
mPrefs = PreferenceManager.getDefaultSharedPreferences(ctx);
|
||||
}
|
||||
|
||||
public void reload() {
|
||||
// Try to restore the whitelist
|
||||
String serialized = mPrefs.getString(Prefs.PREF_WHITELIST, "");
|
||||
|
||||
if(!serialized.isEmpty())
|
||||
fromJson(serialized);
|
||||
}
|
||||
|
||||
public void save() {
|
||||
mPrefs.edit()
|
||||
.putString(Prefs.PREF_WHITELIST, toJson())
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
@ -83,6 +83,8 @@ public class EmptyRecyclerView extends RecyclerView {
|
||||
if (adapter != null) {
|
||||
adapter.registerAdapterDataObserver(observer);
|
||||
}
|
||||
|
||||
initEmptyView();
|
||||
}
|
||||
|
||||
public void setEmptyView(View view) {
|
||||
|
||||
5
app/src/main/res/drawable/ic_checklist.xml
Normal file
5
app/src/main/res/drawable/ic_checklist.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M22,7h-9v2h9V7zM22,15h-9v2h9V15zM5.54,11L2,7.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,11zM5.54,19L2,15.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,19z"/>
|
||||
</vector>
|
||||
5
app/src/main/res/drawable/ic_select_all.xml
Normal file
5
app/src/main/res/drawable/ic_select_all.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M3,5h2L5,3c-1.1,0 -2,0.9 -2,2zM3,13h2v-2L3,11v2zM7,21h2v-2L7,19v2zM3,9h2L5,7L3,7v2zM13,3h-2v2h2L13,3zM19,3v2h2c0,-1.1 -0.9,-2 -2,-2zM5,21v-2L3,19c0,1.1 0.9,2 2,2zM3,17h2v-2L3,15v2zM9,3L7,3v2h2L9,3zM11,21h2v-2h-2v2zM19,13h2v-2h-2v2zM19,21c1.1,0 2,-0.9 2,-2h-2v2zM19,9h2L21,7h-2v2zM19,17h2v-2h-2v2zM15,21h2v-2h-2v2zM15,5h2L17,3h-2v2zM7,17h10L17,7L7,7v10zM9,9h6v6L9,15L9,9z"/>
|
||||
</vector>
|
||||
@ -4,11 +4,11 @@
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.emanuelef.remote_capture.views.EmptyRecyclerView
|
||||
android:id="@+id/apps_stats_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical"
|
||||
android:scrollbarStyle="outsideOverlay"
|
||||
android:id="@+id/apps_stats_view"
|
||||
android:fillViewport="true" />
|
||||
|
||||
<TextView
|
||||
|
||||
@ -2,11 +2,10 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp">
|
||||
android:orientation="vertical">
|
||||
|
||||
<ListView
|
||||
android:id="@+id/list"
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/whitelist_fragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
22
app/src/main/res/layout/whitelist_fragment.xml
Normal file
22
app/src/main/res/layout/whitelist_fragment.xml
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ListView
|
||||
android:id="@+id/whitelist"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbarStyle="outsideOverlay" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/whitelist_empty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_horizontal"
|
||||
android:layout_marginTop="40dp"
|
||||
android:textStyle="italic"
|
||||
android:textSize="15sp"
|
||||
android:text="@string/whitelist_empty">
|
||||
</TextView>
|
||||
</RelativeLayout>
|
||||
@ -1,25 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- For some unknown reason, on some devices using a LinearLayout causes each row not to take all
|
||||
the available space when the scrollbar is shown. -->
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<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:background="?attr/selectableItemBackground"
|
||||
android:padding="12sp">
|
||||
android:background="?android:attr/activatedBackgroundIndicator"
|
||||
android:orientation="vertical"
|
||||
android:padding="15sp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/item_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="App: example app"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_toStartOf="@id/item_icon" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/item_icon"
|
||||
android:src="@drawable/ic_delete"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"/>
|
||||
</RelativeLayout>
|
||||
android:gravity="center_vertical" />
|
||||
</LinearLayout>
|
||||
|
||||
@ -11,23 +11,19 @@
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/whitelist"
|
||||
android:title="@string/whitelist"
|
||||
android:id="@+id/hide_whitelist"
|
||||
android:title="@string/hide_whitelisted"
|
||||
android:icon="@drawable/ic_eye"
|
||||
android:orderInCategory="20"
|
||||
app:showAsAction="ifRoom"/>
|
||||
|
||||
<item
|
||||
android:id="@+id/show_whitelist"
|
||||
android:title="@string/show_whitelisted"
|
||||
android:icon="@drawable/ic_eye_slash"
|
||||
app:showAsAction="ifRoom">
|
||||
<menu>
|
||||
<item
|
||||
android:title="@string/hide_whitelisted"
|
||||
android:id="@+id/enable_whitelist" />
|
||||
<item
|
||||
android:title="@string/show_whitelisted"
|
||||
android:id="@+id/disable_whitelist" />
|
||||
<item
|
||||
android:title="@string/edit"
|
||||
android:id="@+id/edit_whitelist" />
|
||||
</menu>
|
||||
</item>
|
||||
android:orderInCategory="20"
|
||||
app:showAsAction="ifRoom"
|
||||
android:visible="false" />
|
||||
|
||||
<item
|
||||
android:id="@+id/save"
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
android:id="@+id/action_stats"
|
||||
android:title="@string/stats"
|
||||
android:icon="@drawable/ic_list" />
|
||||
<item
|
||||
android:id="@+id/edit_whitelist"
|
||||
android:title="@string/whitelist"
|
||||
android:icon="@drawable/ic_checklist" />
|
||||
<item
|
||||
android:id="@+id/open_root_log"
|
||||
android:title="@string/root_log"
|
||||
|
||||
19
app/src/main/res/menu/whitelist_cab.xml
Normal file
19
app/src/main/res/menu/whitelist_cab.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/select_all"
|
||||
android:title="@string/ic_select_all"
|
||||
android:orderInCategory="10"
|
||||
android:icon="@drawable/ic_select_all"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/delete_entry"
|
||||
android:title="@string/delete"
|
||||
android:orderInCategory="20"
|
||||
android:icon="@android:drawable/ic_menu_delete"
|
||||
app:showAsAction="ifRoom" />
|
||||
</menu>
|
||||
@ -135,5 +135,8 @@
|
||||
<string name="whitelist">Esclusioni</string>
|
||||
<string name="edit_whitelist">Modifica Esclusioni</string>
|
||||
<string name="whitelist_action">Escludi …</string>
|
||||
<string name="whitelist_empty">Lista esclusioni vuota.\nPuoi escludere una connessione tenendo premuto su di essa.</string>
|
||||
<string name="n_selected">%1$d selezionati</string>
|
||||
<string name="ic_select_all">Seleziona tutto</string>
|
||||
</resources>
|
||||
|
||||
|
||||
@ -139,10 +139,13 @@
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="edit">Edit</string>
|
||||
<string name="delete_all">Delete all</string>
|
||||
<string name="show_whitelisted">Show whitelisted</string>
|
||||
<string name="hide_whitelisted">Hide whitelisted</string>
|
||||
<string name="show_whitelisted">Show whitelisted connections</string>
|
||||
<string name="hide_whitelisted">Hide whitelisted connections</string>
|
||||
<string name="whitelist">Whitelist</string>
|
||||
<string name="edit_whitelist">Edit Whitelist</string>
|
||||
<string name="whitelist_action">Whitelist …</string>
|
||||
<string name="whitelist_empty">The whitelist is empty.\nYou can long press a connection to whitelist it.</string>
|
||||
<string name="n_selected">%1$d selected</string>
|
||||
<string name="ic_select_all">Select all</string>
|
||||
</resources>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user