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.