Improve payload export

- HTTP body is now exported in binary form, with file name
- fix export when HTTP body is empty
- set "body" as the default HTTP export action
- add new "Raw bytes" export option for non-HTTP

See #362
This commit is contained in:
emanuele-f 2024-02-05 12:37:50 +01:00
parent a0a3430f19
commit 2bb955ab90
5 changed files with 142 additions and 22 deletions

View File

@ -40,6 +40,7 @@ public class HTTPReassembly {
private boolean mChunkedEncoding;
private ContentEncoding mContentEncoding;
private String mContentType;
private String mPath;
private int mContentLength;
private int mHeadersSize;
private final ArrayList<PayloadChunk> mHeaders = new ArrayList<>();
@ -68,6 +69,7 @@ public class HTTPReassembly {
mChunkedEncoding = false;
mContentLength = -1;
mContentType = null;
mPath = null;
mHeadersSize = 0;
mHeaders.clear();
mBody.clear();
@ -100,10 +102,26 @@ public class HTTPReassembly {
// Reading the HTTP headers
int headers_end = Utils.getEndOfHTTPHeaders(payload);
int headers_size = (headers_end == 0) ? payload.length : headers_end;
boolean is_first_line = (mHeadersSize == 0);
mHeadersSize += headers_size;
try(BufferedReader reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(payload, 0, headers_size)))) {
String line = reader.readLine();
if (is_first_line && (line != null)) {
if (line.startsWith("GET ") || line.startsWith("POST ")
|| line.startsWith("HEAD ") || line.startsWith("PUT ")) {
int first_space = line.indexOf(' ');
int second_space = line.indexOf(' ', first_space + 1);
if ((first_space > 0) && (second_space > 0)) {
mPath = line.substring(first_space + 1, second_space);
log_d("Path: " + mPath);
}
}
is_first_line = false;
}
while((line != null) && (line.length() > 0)) {
line = line.toLowerCase();
//log_d("[HEADER] " + line);
@ -256,6 +274,7 @@ public class HTTPReassembly {
to_add.type = PayloadChunk.ChunkType.RAW;
to_add.contentType = mContentType;
to_add.path = mPath;
mListener.onChunkReassembled(to_add);
reset(); // mReadingHeaders = true
}

View File

@ -69,7 +69,8 @@ public class ConnectionDetailsActivity extends BaseActivity implements Connectio
private boolean mHasPayload;
private boolean mHasHttpTab;
private boolean mHasWsTab;
private String mPayloadToExport;
private String mStringPayloadToExport;
private byte[] mRawPayloadToExport;
private final ArrayList<ConnUpdateListener> mListeners = new ArrayList<>();
private static final int POS_OVERVIEW = 0;
@ -345,35 +346,72 @@ public class ConnectionDetailsActivity extends BaseActivity implements Connectio
@Override
public void exportPayload(String payload) {
mPayloadToExport = payload;
mStringPayloadToExport = payload;
mRawPayloadToExport = null;
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TITLE, Utils.getUniqueFileName(this, "txt"));
Log.d(TAG, "exportPayload: launching dialog");
Utils.launchFileDialog(this, intent, payloadExportLauncher);
}
@Override
public void exportPayload(byte[] payload, String contentType, String fname) {
mStringPayloadToExport = null;
mRawPayloadToExport = payload;
if (fname.isEmpty()) {
String ext;
switch (contentType) {
case "text/html":
ext = "html";
break;
case "application/octet-stream":
ext = "bin";
break;
default:
ext = "txt";
}
fname = Utils.getUniqueFileName(this, ext);
}
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(contentType);
intent.putExtra(Intent.EXTRA_TITLE, fname);
Utils.launchFileDialog(this, intent, payloadExportLauncher);
}
private void payloadExportResult(final ActivityResult result) {
Log.d(TAG, "payloadExportResult");
if (mPayloadToExport == null)
if ((mRawPayloadToExport == null) && (mStringPayloadToExport == null))
return;
if((result.getResultCode() == RESULT_OK) && (result.getData() != null) && (result.getData().getData() != null)) {
try(OutputStream out = getContentResolver().openOutputStream(result.getData().getData(), "rwt")) {
try(OutputStreamWriter writer = new OutputStreamWriter(out)) {
writer.write(mPayloadToExport);
}
Utils.showToast(this, R.string.save_ok);
if (out != null) {
if (mStringPayloadToExport != null) {
try (OutputStreamWriter writer = new OutputStreamWriter(out)) {
writer.write(mStringPayloadToExport);
}
} else
out.write(mRawPayloadToExport);
Utils.showToast(this, R.string.save_ok);
} else
Utils.showToastLong(this, R.string.export_failed);
} catch (IOException e) {
e.printStackTrace();
Utils.showToastLong(this, R.string.export_failed);
}
}
mPayloadToExport = null;
mRawPayloadToExport = null;
mStringPayloadToExport = null;
}
}

View File

@ -46,7 +46,9 @@ import com.google.android.material.button.MaterialButton;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Locale;
/* An adapter to show PayloadChunk items.
@ -72,6 +74,7 @@ public class PayloadAdapter extends RecyclerView.Adapter<PayloadAdapter.PayloadV
public interface ExportPayloadHandler {
void exportPayload(String payload);
void exportPayload(byte[] payload, String contentType, String fname);
}
public PayloadAdapter(Context context, ConnectionDescriptor conn, ChunkType mode, boolean showAsPrintable) {
@ -267,13 +270,49 @@ public class PayloadAdapter extends RecyclerView.Adapter<PayloadAdapter.PayloadV
int title = is_export ? R.string.export_ellipsis : R.string.copy_action;
int positive_action = is_export ? R.string.export_action : R.string.copy_to_clipboard;
AdapterChunk chunk = getItem(payload_pos).adaptChunk;
if (chunk == null)
return;
if(mMode == ChunkType.HTTP) {
String payload = getItem(payload_pos).adaptChunk.getExpandedText(true);
String payload = chunk.getExpandedText(true);
int crlf_pos = payload.indexOf("\r\n\r\n");
String content_type = ((chunk.mChunk.contentType != null) && (!chunk.mChunk.contentType.isEmpty())) ?
chunk.mChunk.contentType : "text/plain";
Log.d(TAG, "Export body content type: " + content_type);
String fname = "";
if (chunk.mChunk.is_sent && (chunk.mChunk.path != null))
fname = chunk.mChunk.path;
else if (payload_pos > 0) {
// Try to match the HTTP request, to determine the file name
AdapterChunk req_chunk = getItem(payload_pos - 1).adaptChunk;
if (req_chunk.mChunk.is_sent && (req_chunk.mChunk.path != null))
fname = req_chunk.mChunk.path;
}
if (!fname.isEmpty()) {
int last_slash = fname.lastIndexOf('/');
if (last_slash > 0)
fname = fname.substring(last_slash + 1);
}
if (fname.contains("."))
Log.d(TAG, "File name: " + fname);
else
fname = "";
String filename = fname;
boolean has_body = (crlf_pos > 0) && (crlf_pos < (payload.length() - 4));
if (!has_body) {
Utils.copyToClipboard(mContext, payload);
// only HTTP headers
if (is_export) {
if (mExportHandler != null)
mExportHandler.exportPayload(payload);
} else
Utils.copyToClipboard(mContext, payload);
return;
}
@ -285,7 +324,7 @@ public class PayloadAdapter extends RecyclerView.Adapter<PayloadAdapter.PayloadV
AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
builder.setTitle(title);
builder.setSingleChoiceItems(choices, 2, (dialogInterface, i) -> {});
builder.setSingleChoiceItems(choices, 1, (dialogInterface, i) -> {});
builder.setNeutralButton(R.string.cancel_action, (dialogInterface, i) -> {});
builder.setPositiveButton(positive_action, (dialogInterface, i) -> {
int choice = ((AlertDialog)dialogInterface).getListView().getCheckedItemPosition();
@ -299,32 +338,54 @@ public class PayloadAdapter extends RecyclerView.Adapter<PayloadAdapter.PayloadV
}
if (is_export) {
if (mExportHandler != null)
mExportHandler.exportPayload(to_copy);
if (mExportHandler != null) {
boolean only_body = (choice == 1);
if (only_body) {
// export the raw body bytes
byte[] payload_bytes = chunk.mChunk.payload;
if (crlf_pos < (payload_bytes.length - 4))
payload_bytes = Arrays.copyOfRange(payload_bytes, crlf_pos + 4, payload_bytes.length);
mExportHandler.exportPayload(payload_bytes, content_type, filename);
} else
mExportHandler.exportPayload(to_copy);
}
} else
Utils.copyToClipboard(mContext, to_copy);
});
builder.create().show();
} else {
String[] choices = {
List<String> choices = new ArrayList<>(Arrays.asList(
mContext.getString(R.string.printable_text),
mContext.getString(R.string.hexdump)
};
));
if (is_export)
choices.add(mContext.getString(R.string.raw_bytes));
AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
builder.setTitle(title);
builder.setSingleChoiceItems(choices, mShowAsPrintable ? 0 : 1, (dialogInterface, i) -> {});
builder.setSingleChoiceItems(choices.toArray(new String[]{}), mShowAsPrintable ? 0 : 1, (dialogInterface, i) -> {});
builder.setNeutralButton(R.string.cancel_action, (dialogInterface, i) -> {});
builder.setPositiveButton(positive_action, (dialogInterface, i) -> {
int choice = ((AlertDialog)dialogInterface).getListView().getCheckedItemPosition();
String payload = getItem(payload_pos).adaptChunk.getExpandedText(choice == 0);
if (is_export) {
if (choice == 2 /* raw bytes */) {
assert (is_export);
if (mExportHandler != null)
mExportHandler.exportPayload(payload);
} else
Utils.copyToClipboard(mContext, payload);
mExportHandler.exportPayload(chunk.mChunk.payload, "application/octet-stream", "");
} else {
String payload = getItem(payload_pos).adaptChunk.getExpandedText(choice == 0);
if (is_export) {
if (mExportHandler != null)
mExportHandler.exportPayload(payload);
} else
Utils.copyToClipboard(mContext, payload);
}
});
builder.create().show();
}

View File

@ -28,6 +28,7 @@ public class PayloadChunk implements Serializable {
public long timestamp;
public ChunkType type;
public String contentType;
public String path;
// Serializable need in ConnectionPayload fragment
public enum ChunkType implements Serializable {

View File

@ -504,4 +504,5 @@
<string name="body">Body</string>
<string name="both">Both</string>
<string name="whats_new">What\'s new</string>
<string name="raw_bytes">Raw bytes</string>
</resources>