diff --git a/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java b/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java
index b3f0628b..3428690f 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java
@@ -302,7 +302,6 @@ public class CaptureService extends VpnService implements Runnable {
last_connections = 0;
mLowMemory = false;
conn_reg = new ConnectionsRegister(this, CONNECTIONS_LOG_SIZE);
- mPcapUri = null;
mDumper = null;
mDumpQueue = null;
mPendingUpdates.clear();
@@ -311,10 +310,17 @@ public class CaptureService extends VpnService implements Runnable {
if(mSettings.dump_mode == Prefs.DumpMode.HTTP_SERVER)
mDumper = new HTTPServer(this, mSettings.http_server_port);
else if(mSettings.dump_mode == Prefs.DumpMode.PCAP_FILE) {
- if(mSettings.pcap_uri != null) {
+ if(!mSettings.pcap_uri.isEmpty())
mPcapUri = Uri.parse(mSettings.pcap_uri);
- mDumper = new FileDumper(this, mPcapUri);
+ else {
+ String fname = !mSettings.pcap_name.isEmpty() ? mSettings.pcap_name : Utils.getUniquePcapFileName(this);
+ mPcapUri = Utils.getDownloadsUri(this, fname);
}
+
+ if(mPcapUri == null)
+ return abortStart();
+
+ mDumper = new FileDumper(this, mPcapUri);
} else if(mSettings.dump_mode == Prefs.DumpMode.UDP_EXPORTER) {
InetAddress addr;
@@ -566,7 +572,9 @@ public class CaptureService extends VpnService implements Runnable {
//INSTANCE = null;
unregisterNetworkCallbacks();
- mBlacklists.abortUpdate();
+
+ if(mBlacklists != null)
+ mBlacklists.abortUpdate();
if(mCaptureThread != null)
mCaptureThread.interrupt();
@@ -928,6 +936,10 @@ public class CaptureService extends VpnService implements Runnable {
return ((INSTANCE != null) ? INSTANCE.mPcapUri : null);
}
+ public static boolean isUserDefinedPcapUri() {
+ return (INSTANCE == null || !INSTANCE.mSettings.pcap_uri.isEmpty());
+ }
+
public static long getBytes() {
return((INSTANCE != null) ? INSTANCE.last_bytes : 0);
}
diff --git a/app/src/main/java/com/emanuelef/remote_capture/PersistableUriPermission.java b/app/src/main/java/com/emanuelef/remote_capture/PersistableUriPermission.java
new file mode 100644
index 00000000..faf516c2
--- /dev/null
+++ b/app/src/main/java/com/emanuelef/remote_capture/PersistableUriPermission.java
@@ -0,0 +1,152 @@
+/*
+ * 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 .
+ *
+ * Copyright 2020-21 - Emanuele Faranda
+ */
+
+package com.emanuelef.remote_capture;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.UriPermission;
+import android.net.Uri;
+
+import androidx.activity.ComponentActivity;
+import androidx.activity.result.ActivityResult;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.preference.PreferenceManager;
+
+// If an app asks to dump to a PCAP file, ensure that PCAPdroid has such a permission.
+// When dumping to the external storage, the first time it's necessary to get a persistable
+// permission, which requires the user to manually select the output file from the UI.
+public class PersistableUriPermission {
+ private static final String TAG = "PersistableUriPermission";
+ private static final String PREF_KEY = "persistable_uri";
+
+ /* FLAG_GRANT_READ_URI_PERMISSION required for showPcapActionDialog (e.g. when auto-started at boot) */
+ private static int PERSIST_MODE = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
+
+ public String key = "";
+ public Uri persistableUri;
+ private String mNewKey;
+ private final Context mCtx;
+ private final SharedPreferences mPrefs;
+ private PupListener mListener;
+ private final ActivityResultLauncher mPcapLauncher;
+
+ public interface PupListener {
+ void onUriChecked(Uri grantedUri);
+ }
+
+ public PersistableUriPermission(ComponentActivity activity) {
+ mCtx = activity;
+ mPrefs = PreferenceManager.getDefaultSharedPreferences(mCtx);
+ mPcapLauncher = activity.registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), this::pcapFileResult);
+ reload();
+ }
+
+ public void reload() {
+ String k = mPrefs.getString(PREF_KEY, "");
+ int sep = k.indexOf("|");
+ if(sep < 0)
+ return;
+
+ key = k.substring(0, sep);
+ persistableUri = Uri.parse(k.substring(sep + 1));
+ }
+
+ public void save() {
+ String serialized = key + "|" + persistableUri;
+ mPrefs.edit().putString(PREF_KEY, serialized).apply();
+ }
+
+ public void checkPermission(String perm_key, PupListener listener) {
+ boolean hasPermission = false;
+ boolean keyChanged = !perm_key.equals(key);
+ mNewKey = perm_key;
+ mListener = listener;
+
+ // Revoke the previous permissions and check
+ for(UriPermission permission : mCtx.getContentResolver().getPersistedUriPermissions()) {
+ if(keyChanged || !permission.getUri().equals(persistableUri)) {
+ Log.d(TAG, "Releasing URI permission: " + permission.getUri().toString());
+ mCtx.getContentResolver().releasePersistableUriPermission(permission.getUri(), PERSIST_MODE);
+ } else
+ hasPermission = true;
+ }
+
+ if(!hasPermission)
+ openFileSelector();
+ else
+ mListener.onUriChecked(persistableUri);
+ }
+
+ private void openFileSelector() {
+ boolean noFileDialog = false;
+ String fname = Utils.getUniquePcapFileName(mCtx);
+ Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("*/*");
+ intent.putExtra(Intent.EXTRA_TITLE, fname);
+
+ if(Utils.supportsFileDialog(mCtx, intent)) {
+ try {
+ mPcapLauncher.launch(intent);
+ } catch (ActivityNotFoundException e) {
+ noFileDialog = true;
+ }
+ } else
+ noFileDialog = true;
+
+ if(noFileDialog) {
+ Log.w(TAG, "No app found to handle file selection");
+ Utils.showToastLong(mCtx, R.string.no_activity_file_selection);
+ mListener.onUriChecked(null);
+ }
+ }
+
+ private void pcapFileResult(final ActivityResult result) {
+ if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+ Uri uri = result.getData().getData();
+ boolean persistable = (result.getData().getFlags() & Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0;
+
+ /* Request a persistent permission to write this URI without invoking the system picker.
+ * This is needed to write to the URI when invoking PCAPdroid from other apps via Intents
+ * or when starting the capture at boot. */
+ if(persistable) {
+ try {
+ mCtx.getContentResolver().takePersistableUriPermission(uri, PERSIST_MODE);
+
+ // save the persistable uri to use it for the next capture
+ persistableUri = uri;
+ key = mNewKey;
+ save();
+ } catch (SecurityException e) {
+ // This should never occur
+ Log.e(TAG, "Could not get PersistableUriPermission");
+ e.printStackTrace();
+ }
+ }
+
+ mListener.onUriChecked(uri);
+ } else
+ mListener.onUriChecked(null);
+ }
+}
diff --git a/app/src/main/java/com/emanuelef/remote_capture/Utils.java b/app/src/main/java/com/emanuelef/remote_capture/Utils.java
index dd1079c3..e2be40b3 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/Utils.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/Utils.java
@@ -30,6 +30,7 @@ import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.ComponentCallbacks2;
import android.content.ComponentName;
+import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
@@ -731,58 +732,53 @@ public class Utils {
return false;
}
- @SuppressWarnings("deprecation")
- public static Uri getInternalStorageFile(Context context, String fname) {
+ // Get a URI to write a file into the downloads folder, into a folder named "PCAPdroid"
+ // If the file exists, it's overwritten
+ public static Uri getDownloadsUri(Context context, String fname) {
ContentValues values = new ContentValues();
//values.put(MediaStore.MediaColumns.MIME_TYPE, "text/plain");
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fname);
+ String selectQuery = "";
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// On Android Q+ cannot directly access the external dir. Must use RELATIVE_PATH instead.
- values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
+ // Important: trailing "/" required for the selectQuery
+ String relPath = Environment.DIRECTORY_DOWNLOADS + "/PCAPdroid/";
+ selectQuery = MediaStore.MediaColumns.RELATIVE_PATH + "='" + relPath + "' AND " +
+ MediaStore.MediaColumns.DISPLAY_NAME + "='" + fname + "'";
+ values.put(MediaStore.MediaColumns.RELATIVE_PATH, relPath);
} else {
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if(context.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
- Log.w("getInternalStorageFile", "external storage permission was denied");
+ Utils.showToastLong(context, R.string.external_storage_perm_required);
return(null);
}
}
// NOTE: context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) returns an app internal folder
- String path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/" + fname;
- Log.d("getInternalStorageFile", path);
+ String path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/PCAPdroid/" + fname;
+ Log.d(TAG, "getDownloadsUri: path=" + path);
+ selectQuery = MediaStore.MediaColumns.DATA + "='" + path + "'";
values.put(MediaStore.MediaColumns.DATA, path);
}
- return context.getContentResolver().insert(
- MediaStore.Files.getContentUri("external"), values);
- }
+ Uri externalUri = MediaStore.Files.getContentUri("external");
- public static String getUriFname(Context context, Uri uri) {
- Cursor cursor;
- String fname;
+ // if the file with given name already exists, overwrite it
+ try (Cursor cursor = context.getContentResolver().query(externalUri, new String[]{MediaStore.MediaColumns._ID}, selectQuery, null, null)) {
+ if ((cursor != null) && cursor.moveToFirst()) {
+ long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
+ Uri existingUri = ContentUris.withAppendedId(externalUri, id);
- try {
- String []projection = {OpenableColumns.DISPLAY_NAME};
- cursor = context.getContentResolver().query(uri, projection, null, null, null);
- } catch (Exception e) {
- return null;
- }
+ Log.d(TAG, "getDownloadsUri: overwriting file " + existingUri);
+ return existingUri;
+ }
+ } catch (Exception ignored) {}
- if((cursor == null) || !cursor.moveToFirst())
- return null;
-
- try {
- int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
- if(idx < 0)
- return null;
- fname = cursor.getString(idx);
- } finally {
- cursor.close();
- }
-
- return fname;
+ Uri newUri = context.getContentResolver().insert(externalUri, values);
+ Log.d(TAG, "getDownloadsUri: new file " + newUri);
+ return newUri;
}
public static boolean isRootAvailable() {
@@ -1462,4 +1458,60 @@ public class Utils {
return false;
return true;
}
+
+ public static String uriToFilePath(Context ctx, Uri uri) {
+ String[] proj = { MediaStore.Images.Media.DATA };
+ try(Cursor cursor = ctx.getContentResolver().query(uri, proj, null, null, null)) {
+ int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
+ if(cursor.moveToFirst())
+ return cursor.getString(column_index);
+ } catch (Exception ignored) {}
+
+ return null;
+ }
+
+ public static class UriStat {
+ public String name;
+ public long size;
+ }
+
+ public static UriStat getUriStat(Context ctx, Uri uri) {
+ String uri_str = uri.toString();
+ File uriFile = null;
+
+ // in some devices, cursor.isNull(sizeIndex) is true, which causes size to be 0
+ // try to resolve the original path and access it as a file
+ String fpath = uriToFilePath(ctx, uri);
+ if(fpath != null) {
+ Log.d(TAG, "getUriStat: resolved to file " + fpath);
+ uriFile = new File(fpath);
+ } else if(uri_str.startsWith("file://"))
+ uriFile = new File(uri_str.substring(7));
+
+ if((uriFile != null) && (uriFile.exists())) {
+ // retrieve via file
+ UriStat info = new UriStat();
+ info.name = uriFile.getName();
+ info.size = uriFile.length();
+ return info;
+ }
+
+ // retrieve via content uri
+ // https://developer.android.com/training/secure-file-sharing/retrieve-info.html#RetrieveFileInfo
+ try(Cursor cursor = ctx.getContentResolver().query(uri, null, null, null, null)) {
+ if((cursor == null) || !cursor.moveToFirst())
+ return null;
+
+ UriStat info = new UriStat();
+
+ int sizeIndex = cursor.getColumnIndexOrThrow(OpenableColumns.SIZE);
+ int idx = cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME);
+ info.name = (idx >= 0) ? cursor.getString(idx) : "*unknown*";
+ info.size = !cursor.isNull(sizeIndex) ? cursor.getLong(sizeIndex) : -1;
+
+ return info;
+ } catch (Exception e) {
+ return null;
+ }
+ }
}
diff --git a/app/src/main/java/com/emanuelef/remote_capture/activities/CaptureCtrl.java b/app/src/main/java/com/emanuelef/remote_capture/activities/CaptureCtrl.java
index cc05774c..b92a4079 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/activities/CaptureCtrl.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/activities/CaptureCtrl.java
@@ -49,6 +49,7 @@ import com.emanuelef.remote_capture.CaptureHelper;
import com.emanuelef.remote_capture.CaptureService;
import com.emanuelef.remote_capture.Log;
import com.emanuelef.remote_capture.PCAPdroid;
+import com.emanuelef.remote_capture.PersistableUriPermission;
import com.emanuelef.remote_capture.R;
import com.emanuelef.remote_capture.Utils;
import com.emanuelef.remote_capture.model.AppDescriptor;
@@ -71,6 +72,8 @@ public class CaptureCtrl extends AppCompatActivity {
private CaptureHelper mCapHelper;
private CtrlPermissions mPermissions;
+ private PersistableUriPermission persistableUriPermission;
+
@Override
@SuppressWarnings("deprecation")
protected void onCreate(@Nullable Bundle savedInstanceState) {
@@ -79,6 +82,9 @@ public class CaptureCtrl extends AppCompatActivity {
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.ctrl_consent);
+ // define here since it calls registerForActivityResult
+ persistableUriPermission = new PersistableUriPermission(this);
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
final WindowInsetsController insetsController = getWindow().getInsetsController();
if (insetsController != null)
@@ -238,8 +244,19 @@ public class CaptureCtrl extends AppCompatActivity {
return;
}
- // will call the mCapHelper listener
- mCapHelper.startCapture(settings);
+ if(!settings.pcap_uri.isEmpty()) {
+ persistableUriPermission.checkPermission(settings.pcap_uri, granted_uri -> {
+ Log.d(TAG, "persistable uri granted? " + granted_uri);
+
+ if(granted_uri != null) {
+ settings.pcap_uri = granted_uri.toString();
+ mCapHelper.startCapture(settings);
+ } else
+ abort();
+ });
+ } else
+ // will call the mCapHelper listener
+ mCapHelper.startCapture(settings);
return;
} else if(action.equals(ACTION_STOP)) {
Log.d(TAG, "Stopping capture");
diff --git a/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java b/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java
index 8bf28359..6e57bd6a 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/activities/MainActivity.java
@@ -21,12 +21,11 @@ package com.emanuelef.remote_capture.activities;
import android.Manifest;
import android.content.ActivityNotFoundException;
+import android.content.ClipData;
import android.content.Intent;
import android.content.SharedPreferences;
-import android.content.UriPermission;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
-import android.database.Cursor;
import android.net.Uri;
import androidx.activity.result.ActivityResult;
@@ -50,8 +49,6 @@ import androidx.viewpager2.widget.ViewPager2;
import android.os.Build;
import android.os.Bundle;
-import android.provider.DocumentsContract;
-import android.provider.OpenableColumns;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
@@ -81,7 +78,6 @@ import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator;
import java.io.File;
-import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.util.HashSet;
@@ -91,14 +87,11 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
private ViewPager2 mPager;
private AppState mState;
private AppStateListener mListener;
- private Uri mPcapUri;
private File mKeylogFile;
- private String mPcapFname;
private DrawerLayout mDrawer;
private SharedPreferences mPrefs;
private NavigationView mNavView;
private CaptureHelper mCapHelper;
- private boolean usingMediaStore;
private static final String TAG = "Main";
@@ -116,8 +109,6 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
public static final String FIREWALL_DOCS_URL = PAID_FEATURES_URL + "#51-firewall";
public static final String MALWARE_DETECTION_DOCS_URL = PAID_FEATURES_URL + "#52-malware-detection";
- private final ActivityResultLauncher pcapFileLauncher =
- registerForActivityResult(new StartActivityForResult(), this::pcapFileResult);
private final ActivityResultLauncher sslkeyfileExportLauncher =
registerForActivityResult(new StartActivityForResult(), this::sslkeyfileExportResult);
private final ActivityResultLauncher requestPermissionLauncher =
@@ -152,7 +143,6 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
initAppState();
checkPermissions();
- mPcapUri = CaptureService.isServiceActive() ? CaptureService.getPcapUri() : null;
mCapHelper = new CaptureHelper(this);
mCapHelper.setListener(success -> {
if(!success) {
@@ -181,10 +171,8 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
Log.d(TAG, "sslkeylog? " + (mKeylogFile != null));
- if((mPcapUri != null) && (Prefs.getDumpMode(mPrefs) == Prefs.DumpMode.PCAP_FILE)) {
- showPcapActionDialog(mPcapUri);
- mPcapUri = null;
- mPcapFname = null;
+ if((Prefs.getDumpMode(mPrefs) == Prefs.DumpMode.PCAP_FILE)) {
+ showPcapActionDialog();
// will export the keylogfile after saving/sharing pcap
} else if(mKeylogFile != null)
@@ -274,22 +262,14 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
}
private void checkPermissions() {
- String fname = "test.pcap";
- Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
- intent.addCategory(Intent.CATEGORY_OPENABLE);
- intent.setType("*/*");
- intent.putExtra(Intent.EXTRA_TITLE, fname);
-
- if(!Utils.supportsFileDialog(this, intent)) {
- if(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
- if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- // Needed to write file on devices which do not support ACTION_CREATE_DOCUMENT
- if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
- try {
- requestPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE);
- } catch (ActivityNotFoundException e) {
- Utils.showToastLong(this, R.string.no_intent_handler_found);
- }
+ if(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ // Needed to write PCAP files
+ if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
+ try {
+ requestPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ } catch (ActivityNotFoundException e) {
+ Utils.showToastLong(this, R.string.no_intent_handler_found);
}
}
}
@@ -617,54 +597,6 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
return super.onOptionsItemSelected(item);
}
- private void pcapFileResult(final ActivityResult result) {
- if (result.getResultCode() == RESULT_OK && result.getData() != null) {
- startWithPcapFile(result.getData().getData(),
- (result.getData().getFlags() & Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) != 0);
- } else {
- mPcapUri = null;
- }
- }
-
- private void startWithPcapFile(Uri uri, boolean persistable) {
- mPcapUri = uri;
- mPcapFname = null;
- boolean hasPermission = false;
-
- /* FLAG_GRANT_READ_URI_PERMISSION required for showPcapActionDialog (e.g. when auto-started at boot) */
- int peristMode = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
-
- // Revoke the previous permissions
- for(UriPermission permission : getContentResolver().getPersistedUriPermissions()) {
- if(!permission.getUri().equals(uri)) {
- Log.d(TAG, "Releasing URI permission: " + permission.getUri().toString());
- getContentResolver().releasePersistableUriPermission(permission.getUri(), peristMode);
- } else
- hasPermission = true;
- }
-
- /* Request a persistent permission to write this URI without invoking the system picker.
- * This is needed to write to the URI when invoking PCAPdroid from other apps via Intents
- * or when starting the capture at boot. */
- if(persistable && !hasPermission) {
- try {
- getContentResolver().takePersistableUriPermission(uri, peristMode);
- } catch (SecurityException e) {
- // This should never occur
- Log.e(TAG, "Could not get PersistableUriPermission");
- e.printStackTrace();
- persistable = false;
- }
- }
-
- // Save the URI as a preference
- mPrefs.edit().putString(Prefs.PREF_PCAP_URI, mPcapUri.toString()).apply();
-
- // NOTE: part of app_api.md
- Log.d(TAG, "PCAP URI to write [persistable=" + persistable + "]: " + mPcapUri.toString());
- startCapture();
- }
-
private void initAppState() {
boolean is_active = CaptureService.isServiceActive();
@@ -689,11 +621,6 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
return;
}
- if((mPcapUri == null) && (Prefs.getDumpMode(mPrefs) == Prefs.DumpMode.PCAP_FILE)) {
- openFileSelector();
- return;
- }
-
if(!Prefs.isRootCaptureEnabled(mPrefs) && Utils.hasVPNRunning(this)) {
new AlertDialog.Builder(this)
.setMessage(R.string.disconnect_vpn_confirm)
@@ -730,77 +657,24 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
return false;
}
- private void openFileSelector() {
- boolean noFileDialog = false;
- String fname = Utils.getUniquePcapFileName(this);
- Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
- intent.addCategory(Intent.CATEGORY_OPENABLE);
- intent.setType("*/*");
- intent.putExtra(Intent.EXTRA_TITLE, fname);
+ public void showPcapActionDialog() {
+ Log.d(TAG, "showPcapActionDialog called");
- if(Utils.supportsFileDialog(this, intent)) {
- try {
- pcapFileLauncher.launch(intent);
- } catch (ActivityNotFoundException e) {
- noFileDialog = true;
- }
- } else
- noFileDialog = true;
+ if(CaptureService.isUserDefinedPcapUri())
+ return;
- if(noFileDialog) {
- Log.w(TAG, "No app found to handle file selection");
+ Uri pcapUri = CaptureService.getPcapUri();
+ if(pcapUri == null)
+ return;
- // Pick default path
- Uri uri = Utils.getInternalStorageFile(this, fname);
-
- if(uri != null) {
- usingMediaStore = true;
-
- // NOTE: cannot be persisted as it was not invoked via Intent
- startWithPcapFile(uri, false);
- } else
- Utils.showToastLong(this, R.string.no_activity_file_selection);
- }
- }
-
- public void showPcapActionDialog(Uri pcapUri) {
- Cursor cursor;
-
- Log.d(TAG, "showPcapActionDialog: " + pcapUri.toString());
-
- try {
- cursor = getContentResolver().query(pcapUri, null, null, null, null);
- } catch (Exception e) {
+ Utils.UriStat pcapStat = Utils.getUriStat(this, pcapUri);
+ if((pcapStat == null) || (pcapStat.size == 0)) {
+ if(pcapStat != null)
+ deletePcapFile(pcapUri); // empty file, delete
return;
}
- if((cursor == null) || !cursor.moveToFirst())
- return;
-
- int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
- long file_size = !cursor.isNull(sizeIndex) ? cursor.getLong(sizeIndex) : -1;
- int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
- String fname = (idx >= 0) ? cursor.getString(idx) : "*unknown*";
- cursor.close();
-
- // If file is empty, delete it
- // NOTE: the user may want to get a PersistableUriPermission, so don't auto delete the file
- /*if(file_size == 0) {
- Log.d(TAG, "PCAP file is empty, deleting");
-
- try {
- if(usingMediaStore)
- getContentResolver().delete(pcapUri, null, null);
- else
- DocumentsContract.deleteDocument(getContentResolver(), pcapUri);
- } catch (FileNotFoundException | UnsupportedOperationException e) {
- e.printStackTrace();
- }
-
- return;
- }*/
-
- String message = String.format(getResources().getString(R.string.pcap_file_action), fname, Utils.formatBytes(file_size));
+ String message = String.format(getResources().getString(R.string.pcap_file_action), pcapStat.name, Utils.formatBytes(pcapStat.size));
AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this);
builder.setMessage(message);
@@ -809,28 +683,15 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
Intent sendIntent = new Intent(Intent.ACTION_SEND);
sendIntent.setType("application/cap");
sendIntent.putExtra(Intent.EXTRA_STREAM, pcapUri);
+ sendIntent.setClipData(ClipData.newRawUri("", pcapUri));
+ sendIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+
Utils.startActivity(this, Intent.createChooser(sendIntent, getResources().getString(R.string.share)));
});
- builder.setNegativeButton(R.string.delete, (dialog, which) -> {
- Log.d(TAG, "Deleting PCAP file" + pcapUri.getPath());
- boolean deleted = false;
-
- try {
- if(usingMediaStore)
- deleted = (getContentResolver().delete(pcapUri, null, null) == 1);
- else
- deleted = DocumentsContract.deleteDocument(getContentResolver(), pcapUri);
- } catch (FileNotFoundException | UnsupportedOperationException | SecurityException e) {
- e.printStackTrace();
- }
-
- if(!deleted)
- Utils.showToast(MainActivity.this, R.string.delete_error);
-
- dialog.cancel();
- });
- builder.setNeutralButton(R.string.ok, (dialog, which) -> dialog.cancel());
+ builder.setNegativeButton(R.string.delete, (dialog, which) -> deletePcapFile(pcapUri));
+ builder.setNeutralButton(R.string.ok, (dialog, which) -> {});
builder.setOnDismissListener(dialogInterface -> {
+ // also export the keylog
if(mKeylogFile != null)
startExportSslkeylogfile();
});
@@ -838,35 +699,22 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
builder.create().show();
}
- public AppState getState() {
- return(mState);
- }
+ private void deletePcapFile(Uri pcapUri) {
+ Log.d(TAG, "Deleting PCAP file" + pcapUri.getPath());
+ boolean deleted = false;
- public String getPcapFname() {
- if((mState == AppState.running) && (mPcapUri != null)) {
- if(mPcapFname != null)
- return mPcapFname;
-
- Cursor cursor;
-
- try {
- cursor = getContentResolver().query(mPcapUri, null, null, null, null);
- } catch (Exception e) {
- return null;
- }
-
- if((cursor == null) || !cursor.moveToFirst())
- return null;
-
- int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
- String fname = (idx >= 0) ? cursor.getString(idx) : "*unknown*";
- cursor.close();
-
- mPcapFname = fname;
- return fname;
+ try {
+ deleted = (getContentResolver().delete(pcapUri, null, null) == 1);
+ } catch (UnsupportedOperationException | SecurityException e) {
+ e.printStackTrace();
}
- return null;
+ if(!deleted)
+ Utils.showToast(MainActivity.this, R.string.delete_error);
+ }
+
+ public AppState getState() {
+ return(mState);
}
private void startExportSslkeylogfile() {
@@ -889,7 +737,6 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
Utils.showToastLong(this, R.string.export_failed);
}
}
-
mKeylogFile = null;
}
}
diff --git a/app/src/main/java/com/emanuelef/remote_capture/fragments/ConnectionsFragment.java b/app/src/main/java/com/emanuelef/remote_capture/fragments/ConnectionsFragment.java
index d2bd80a6..ef0cfb47 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/fragments/ConnectionsFragment.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/fragments/ConnectionsFragment.java
@@ -801,10 +801,10 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
stream.close();
}
- String fname = Utils.getUriFname(requireContext(), mCsvFname);
+ Utils.UriStat stat = Utils.getUriStat(requireContext(), mCsvFname);
- if(fname != null) {
- String msg = String.format(getString(R.string.file_saved_with_name), fname);
+ if(stat != null) {
+ String msg = String.format(getString(R.string.file_saved_with_name), stat.name);
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show();
} else
Utils.showToast(requireContext(), R.string.save_ok);
@@ -842,7 +842,7 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
Log.w(TAG, "No app found to handle file selection");
// Pick default path
- Uri uri = Utils.getInternalStorageFile(requireContext(), fname);
+ Uri uri = Utils.getDownloadsUri(requireContext(), fname);
if(uri != null) {
mCsvFname = uri;
diff --git a/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java b/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java
index a9dbaaf7..22200188 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java
@@ -23,6 +23,7 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.drawable.Drawable;
+import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
@@ -273,10 +274,12 @@ public class StatusFragment extends Fragment implements AppStateListener, MenuPr
info = getString(R.string.pcap_file_info);
if(mActivity != null) {
- String fname = mActivity.getPcapFname();
-
- if(fname != null)
- info = fname;
+ Uri pcap_uri = CaptureService.getPcapUri();
+ if(pcap_uri != null) {
+ Utils.UriStat uri_stat = Utils.getUriStat(mActivity, pcap_uri);
+ if(uri_stat != null)
+ info = uri_stat.name;
+ }
}
break;
case UDP_EXPORTER:
diff --git a/app/src/main/java/com/emanuelef/remote_capture/model/CaptureSettings.java b/app/src/main/java/com/emanuelef/remote_capture/model/CaptureSettings.java
index 9bdccc31..261bc8a7 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/model/CaptureSettings.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/model/CaptureSettings.java
@@ -23,7 +23,8 @@ public class CaptureSettings implements Serializable {
public boolean block_quic;
public boolean auto_block_private_dns;
public String capture_interface;
- public String pcap_uri;
+ public String pcap_uri = "";
+ public String pcap_name = "";
public int snaplen = 0;
public int max_pkts_per_flow = 0;
public int max_dump_size = 0;
@@ -42,7 +43,6 @@ public class CaptureSettings implements Serializable {
root_capture = Prefs.isRootCaptureEnabled(prefs);
pcapdroid_trailer = Prefs.isPcapdroidTrailerEnabled(prefs);
capture_interface = Prefs.getCaptureInterface(prefs);
- pcap_uri = Prefs.getPCAPUri(prefs);
tls_decryption = Prefs.getTlsDecryptionEnabled(prefs);
full_payload = Prefs.getFullPayloadMode(prefs);
block_quic = Prefs.blockQuic(prefs);
@@ -63,7 +63,8 @@ public class CaptureSettings implements Serializable {
root_capture = getBool(intent, Prefs.PREF_ROOT_CAPTURE, false);
pcapdroid_trailer = getBool(intent, Prefs.PREF_PCAPDROID_TRAILER, false);
capture_interface = getString(intent, Prefs.PREF_CAPTURE_INTERFACE, "@inet");
- pcap_uri = getString(intent, Prefs.PREF_PCAP_URI, "");
+ pcap_uri = getString(intent, "pcap_uri", "");
+ pcap_name = getString(intent, "pcap_name", "");
snaplen = getInt(intent, Prefs.PREF_SNAPLEN, 0);
max_pkts_per_flow = getInt(intent, Prefs.PREF_MAX_PKTS_PER_FLOW, 0);
max_dump_size = getInt(intent, Prefs.PREF_MAX_DUMP_SIZE, 0);
diff --git a/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java b/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java
index 2ff7d6c6..065cd346 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java
@@ -60,7 +60,6 @@ public class Prefs {
public static final String PREF_APP_FILTER = "app_filter";
public static final String PREF_HTTP_SERVER_PORT = "http_server_port";
public static final String PREF_PCAP_DUMP_MODE = "pcap_dump_mode_v2";
- public static final String PREF_PCAP_URI = "pcap_uri";
public static final String PREF_IP_MODE = "ip_mode";
public static final String PREF_APP_LANGUAGE = "app_language";
public static final String PREF_APP_THEME = "app_theme";
@@ -185,7 +184,6 @@ public class Prefs {
&& p.getBoolean(PREF_FIREWALL, true));
}
public static boolean startAtBoot(SharedPreferences p) { return(p.getBoolean(PREF_START_AT_BOOT, false)); }
- public static String getPCAPUri(SharedPreferences p) { return(p.getString(PREF_PCAP_URI, "")); }
public static boolean isTLSDecryptionSetupDone(SharedPreferences p) { return(p.getBoolean(PREF_TLS_DECRYPTION_SETUP_DONE, false)); }
public static boolean getFullPayloadMode(SharedPreferences p) { return(p.getBoolean(PREF_FULL_PAYLOAD, false)); }
public static boolean blockQuic(SharedPreferences p) { return(p.getBoolean(PREF_BLOCK_QUIC, false)); }
diff --git a/app/src/main/java/com/emanuelef/remote_capture/pcap_dump/FileDumper.java b/app/src/main/java/com/emanuelef/remote_capture/pcap_dump/FileDumper.java
index 75efb99e..80afcc54 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/pcap_dump/FileDumper.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/pcap_dump/FileDumper.java
@@ -5,7 +5,6 @@ import android.net.Uri;
import com.emanuelef.remote_capture.CaptureService;
import com.emanuelef.remote_capture.Log;
-import com.emanuelef.remote_capture.Utils;
import com.emanuelef.remote_capture.interfaces.PcapDumper;
import java.io.IOException;
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9251bc84..664e3535 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -447,4 +447,5 @@
Decryption whitelist rules
List of rules to exclude specific connections from the TLS decryption. Useful to avoid breaking apps/SDKs employing certificate pinning. Host matching only works if a prior DNS reply is seen
Decryption whitelist
+ The external storage permission is required
diff --git a/docs/app_api.md b/docs/app_api.md
index 8318f845..96b317cb 100644
--- a/docs/app_api.md
+++ b/docs/app_api.md
@@ -16,6 +16,13 @@ where ACTION is one of:
- `get_status`: get the capture status
The capture parameters are specified via Intent extras, which are discussed below.
+
+For example, you can use the following command to start the capture and write the traffic dump to the PCAP file `Download/PCAPdroid/traffic.pcap`:
+
+```bash
+adb shell am start -e action start -e pcap_dump_mode pcap_file -e pcap_name traffic.pcap -n com.emanuelef.remote_capture/.activities.CaptureCtrl
+```
+
A common task is to capture the traffic of a specific app to analyze it into your app. This can be easily accomplished by running PCAPdroid in the
[UDP Exporter mode](https://emanuele-f.github.io/PCAPdroid/dump_modes#24-udp-exporter):
@@ -76,7 +83,7 @@ As shown above, the capture settings can be specified by using intent extras. Th
| collector_ip_address | string | | | the IP address of the collector in udp_exporter mode |
| collector_port | int | | | the UDP port of the collector in udp_exporter mode |
| http_server_port | int | | | the HTTP server port in http_server mode |
-| pcap_uri | string | | | the URI for the PCAP dump in pcap_file mode |
+| pcap_uri | string | | | the URI for the PCAP dump in pcap_file mode (overrides pcap_name) |
| socks5_enabled | bool | | vpn | true to redirect the TCP connections to a SOCKS5 proxy |
| socks5_proxy_ip_address | string | | vpn | the IP address of the SOCKS5 proxy |
| socks5_proxy_port | int | | vpn | the TCP port of the SOCKS5 proxy |
@@ -91,13 +98,14 @@ As shown above, the capture settings can be specified by using intent extras. Th
| auto_block_private_dns | bool | 51 | vpn | true to detect and possibly block private DNS to inspect traffic |
| ip_mode | string | 56 | vpn | which IP addresses to use for the VPN: ipv4 \| ipv6 \| both |
| mitmproxy_opts | string | 62 | | additional options to provide to mitmproxy in decryption mode |
+| pcap_name | string | 62 | | write the PCAP to Download/PCAPdroid/*pcap_name* in pcap_file mode |
The `Ver` column indicates the minimum PCAPdroid version required to use the given parameter. The PCAPdroid version can be queried via the `get_status` action as explained below.
The `Mode` column indicates if the option applies to any mode or only to the VPN or root mode.
*NOTE*: for security reasons, since version 1.5.3 you cannot specify a remote server IP address in `collector_ip_address` or in `socks5_proxy_ip_address`. If you really want to do this, you should first set such a remote IP address via the PCAPdroid gui and only then invoke the API.
-*NOTE*: due to [file storage restrictions](https://developer.android.com/about/versions/11/privacy/storage), the `pcap_uri` must point to an app internal directory, e.g. `file:///data/user/0/com.emanuelef.remote_capture/cache/dump.pcap`.
+*NOTE*: since version 1.6.0, the `pcap_uri` behavior is changed as described in the `Dumping PCAP to file` section below
## Query the Capture Status
@@ -147,17 +155,13 @@ In the result of the `stop` and `get_status` actions and in the broadcast of `Ca
## Dumping PCAP to file
-Due to the restrictions introduced via the [scoped storage](https://developer.android.com/about/versions/11/privacy/storage), PCAPdroid can only create files inside its private directory, which is not accessible to you as a user. To dump the PCAP file to a publicly available directory, you must first perform the following steps:
+[Scoped storage](https://developer.android.com/about/versions/11/privacy/storage) restrictions apply to PCAPdroid, which limits in which paths the PCAP file can be stored and how
+other apps can access it.
-1. Open PCAPdroid and select the "PCAP File" dump mode
-2. Start the capture and select the path of the file to write. In this example I assume you select the `/sdcard/test.pcap` file
-3. Stop the capture and choose to keep the generated PCAP file (don't delete it!)
-4. Retrieve the internal URL which Android uses to reference this file. You can find this in the logcat output of PCAPdroid:
+Since version 1.6.0, PCAPdroid dumps the PCAP file to the `Download/PCAPdroid` directory. By using the `pcap_name` parameter, you can get a predictable PCAP file path. The PCAP
+file is overwritten if another file with the same `pcap_name` exists.
-```
-D/Main: PCAP URI to write [persistable=true]: content://com.android.externalstorage.documents/document/primary%3Atest.pcap
-```
-
-You should now be able to write the `test.pcap` file by setting the `pcap_uri` to this URI. You must repeat the steps above if you delete the file.
-
-*NOTE*: if the messages shows `[persistable=false]` then it was not possible to get the permissions on the URI, so the `pcap_uri` paramter won't work. This occurs on devices without a file manager to select the PCAP destination path (e.g. on Android TV).
+You can use the `pcap_uri` parameter to write the PCAP file to an arbitrary path. The URI specified in `pcap_uri`, however, is only used as an identifier, it's not the actual file
+path. This choice has been made to cope with the scoped storage limitations. This means that, the first time the `pcap_uri` is set and every time it's changed, a file dialog is
+displayed to the user to pick the actual file path. On subsequent runs, the same `pcap_uri` identifier can be used to write the selected file without the file dialog to be
+presented to the user.