Implement HAR export of HTTP and WebSocket data
Some checks failed
Debug build / build (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
Test native code / test (push) Has been cancelled
Windows build / build (push) Has been cancelled

Implement the ability to export the HTTP log/a single HTTP request
to the HAR 1.2 format. The export happens in a separate thread to
avoid blocking the UI, with the ability to cancel.

WebSocket data is exported in the Chrome DevTools extension format.

See #184
This commit is contained in:
emanuele-f 2026-02-02 19:10:35 +01:00
parent de3f604422
commit 9d821b7931
14 changed files with 3538 additions and 11 deletions

View File

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright 2022 - Emanuele Faranda
* Copyright 2022-26 - Emanuele Faranda
*/
package com.emanuelef.remote_capture;
@ -163,6 +163,7 @@ public class HTTPReassembly {
if ((first_space > 0) && (second_space > 0)) {
mFirstChunk.httpMethod = line.substring(0, first_space).toUpperCase();
mFirstChunk.httpVersion = line.substring(second_space + 1);
String path = line.substring(first_space + 1, second_space);
if (!path.startsWith("/")) {
@ -192,6 +193,8 @@ public class HTTPReassembly {
} else if (line.startsWith("HTTP/")) {
int first_space = line.indexOf(' ');
if (first_space > 0) {
mFirstChunk.httpVersion = line.substring(0, first_space);
try {
// NOTE: the response status may be missing when the response is reconstructed by the ushark HTTP2 reassembly
int second_space = line.indexOf(' ', first_space + 1);
@ -212,6 +215,7 @@ public class HTTPReassembly {
if(line.startsWith("content-encoding: ")) {
String contentEncoding = line.substring(18);
log_d("Content-Encoding: " + contentEncoding);
switch (contentEncoding) {
@ -356,7 +360,6 @@ public class HTTPReassembly {
//log_d("mContentLength=" + mContentLength + ", mReassembleChunks=" + mReassembleChunks + ", mChunkedEncoding=" + mChunkedEncoding);
// Decode body
if ((body != null) && (mContentEncoding != ContentEncoding.UNKNOWN))
decodeBody(body);
@ -382,6 +385,7 @@ public class HTTPReassembly {
to_add.httpHost = mFirstChunk.httpHost;
to_add.httpPath = mFirstChunk.httpPath;
to_add.httpQuery = mFirstChunk.httpQuery;
to_add.httpVersion = mFirstChunk.httpVersion;
to_add.httpBodyLength = mBodySize;
if (httpRst)

View File

@ -0,0 +1,620 @@
/*
* 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 2026 - Emanuele Faranda
*/
package com.emanuelef.remote_capture;
import android.content.Context;
import android.util.Base64;
import com.emanuelef.remote_capture.model.ConnectionDescriptor;
import com.emanuelef.remote_capture.model.PayloadChunk;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* Writes HTTP log data to HAR 1.2 format using streaming output.
* See: http://www.softwareishard.com/blog/har-12-spec/
*/
public class HarWriter {
private static final String TAG = "HarWriter";
private final Context mContext;
private final HttpLog mHttpLog;
private final HttpLog.HttpRequest mSingleRequest;
public HarWriter(Context context, HttpLog httpLog) {
mContext = context;
mHttpLog = httpLog;
mSingleRequest = null;
}
public HarWriter(Context context, HttpLog.HttpRequest request) {
mContext = context;
mHttpLog = null;
mSingleRequest = request;
}
public void write(OutputStream out) throws IOException {
JsonWriter writer = new JsonWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
writer.setIndent(" ");
writer.beginObject();
writer.name("log");
writeLog(writer);
writer.endObject();
writer.flush();
}
private void writeLog(JsonWriter writer) throws IOException {
writer.beginObject();
writer.name("version").value("1.2");
writer.name("creator");
writeCreator(writer);
writer.name("entries");
writeEntries(writer);
writer.endObject();
}
private void writeCreator(JsonWriter writer) throws IOException {
writer.beginObject();
writer.name("name").value("PCAPdroid");
writer.name("version").value(Utils.getAppVersion(mContext));
writer.endObject();
}
private void writeEntries(JsonWriter writer) throws IOException {
writer.beginArray();
if (mSingleRequest != null) {
try {
writeEntry(writer, mSingleRequest);
} catch (Exception e) {
Log.w(TAG, "Failed to serialize single entry: " + e.getMessage());
e.printStackTrace();
}
} else if (mHttpLog != null) {
int size = mHttpLog.getSize();
// NOTE: don't synchronize on the HTTP log, to avoid blocking the UI
for (int i = 0; i < size; i++) {
if (Thread.interrupted())
throw new InterruptedIOException("Export cancelled");
HttpLog.HttpRequest req = mHttpLog.getRequest(i);
if (req == null)
continue;
try {
writeEntry(writer, req);
} catch (Exception e) {
Log.w(TAG, "Failed to serialize entry " + i + ": " + e.getMessage());
e.printStackTrace();
}
}
}
writer.endArray();
}
private void writeEntry(JsonWriter writer, HttpLog.HttpRequest req) throws IOException {
ConnectionDescriptor conn = req.conn;
writer.beginObject();
writer.name("startedDateTime").value(Utils.formatMillisIso8601(mContext, req.timestamp));
// time (total elapsed time in ms)
long time = -1;
if (req.reply != null) {
PayloadChunk replyChunk = conn.getHttpResponseChunk(req.reply.firstChunkPos);
if (replyChunk != null)
time = replyChunk.timestamp - req.timestamp;
}
writer.name("time").value(time);
writer.name("serverIPAddress").value(conn.dst_ip);
writer.name("connection").value(String.valueOf(conn.incr_id));
writer.name("request");
writeRequest(writer, req);
writer.name("response");
writeResponse(writer, req);
// cache (required, but empty as we don't track caching)
writer.name("cache");
writer.beginObject();
writer.endObject();
writer.name("timings");
writeTimings(writer);
writeWebSocketMessages(writer, conn, req);
writer.endObject();
}
private void writeRequest(JsonWriter writer, HttpLog.HttpRequest req) throws IOException {
ConnectionDescriptor conn = req.conn;
writer.beginObject();
writer.name("method").value(req.method != null ? req.method : "");
writer.name("url").value(req.getUrl());
PayloadChunk reqChunk = conn.getHttpRequestChunk(req.firstChunkPos);
String httpVersion = "HTTP/1.1";
List<String[]> requestHeaders = new ArrayList<>();
int headersSize = -1;
if (reqChunk != null) {
if (!reqChunk.httpVersion.isEmpty())
httpVersion = reqChunk.httpVersion;
if (reqChunk.payload != null) {
String httpText = new String(reqChunk.payload, StandardCharsets.UTF_8);
requestHeaders = parseHeaders(httpText);
headersSize = getHeadersSize(reqChunk.payload);
}
}
writer.name("httpVersion").value(httpVersion);
writer.name("cookies");
writeRequestCookies(writer, requestHeaders);
writer.name("headers");
writeHeaders(writer, requestHeaders);
writer.name("queryString");
writeQueryString(writer, req.query);
if ((req.method != null) && (req.method.equals("POST") || req.method.equals("PUT") || req.method.equals("PATCH"))) {
writer.name("postData");
writePostData(writer, reqChunk, requestHeaders);
}
writer.name("headersSize").value(headersSize);
writer.name("bodySize").value(req.bodyLength);
writer.endObject();
}
private void writeResponse(JsonWriter writer, HttpLog.HttpRequest req) throws IOException {
HttpLog.HttpReply reply = req.reply;
ConnectionDescriptor conn = req.conn;
writer.beginObject();
if ((reply == null) || req.httpRst) {
// No response available - return minimal response object
writer.name("status").value(0);
writer.name("statusText").value("");
writer.name("httpVersion").value("");
writer.name("cookies");
writer.beginArray();
writer.endArray();
writer.name("headers");
writer.beginArray();
writer.endArray();
writer.name("content");
writer.beginObject();
writer.endObject();
writer.name("redirectURL").value("");
writer.name("headersSize").value(-1);
writer.name("bodySize").value(-1);
writer.endObject();
return;
}
writer.name("status").value(reply.responseCode);
writer.name("statusText").value(reply.responseStatus != null ? reply.responseStatus : "");
PayloadChunk respChunk = conn.getHttpResponseChunk(reply.firstChunkPos);
String httpVersion = "HTTP/1.1";
List<String[]> responseHeaders = new ArrayList<>();
int headersSize = -1;
if (respChunk != null) {
if (!respChunk.httpVersion.isEmpty())
httpVersion = respChunk.httpVersion;
if (respChunk.payload != null) {
String httpText = new String(respChunk.payload, StandardCharsets.UTF_8);
responseHeaders = parseHeaders(httpText);
headersSize = getHeadersSize(respChunk.payload);
}
}
writer.name("httpVersion").value(httpVersion);
writer.name("cookies");
writeResponseCookies(writer, responseHeaders);
writer.name("headers");
writeHeaders(writer, responseHeaders);
writer.name("content");
writeContent(writer, reply, respChunk);
String redirectURL = getHeaderValue(responseHeaders, "location");
writer.name("redirectURL").value(redirectURL != null ? redirectURL : "");
writer.name("headersSize").value(headersSize);
writer.name("bodySize").value(reply.bodyLength);
writer.endObject();
}
private void writeContent(JsonWriter writer, HttpLog.HttpReply reply, PayloadChunk respChunk) throws IOException {
writer.beginObject();
writer.name("size").value(reply.bodyLength);
String mimeType = reply.contentType != null ? reply.contentType : "application/octet-stream";
writer.name("mimeType").value(mimeType);
if ((respChunk != null) && (respChunk.payload != null)) {
byte[] body = extractBody(respChunk.payload);
if ((body != null) && (body.length > 0)) {
if (isTextContent(body, reply.contentType))
writer.name("text").value(new String(body, StandardCharsets.UTF_8));
else {
writer.name("text").value(Base64.encodeToString(body, Base64.NO_WRAP));
writer.name("encoding").value("base64");
}
}
}
writer.endObject();
}
private void writePostData(JsonWriter writer, PayloadChunk reqChunk, List<String[]> requestHeaders) throws IOException {
writer.beginObject();
String contentType = getHeaderValue(requestHeaders, "content-type");
writer.name("mimeType").value(contentType != null ? contentType : "");
if ((reqChunk != null) && (reqChunk.payload != null)) {
byte[] body = extractBody(reqChunk.payload);
if ((body != null) && (body.length > 0))
writer.name("text").value(new String(body, StandardCharsets.UTF_8));
}
// params empty - we don't parse form data
writer.name("params");
writer.beginArray();
writer.endArray();
writer.endObject();
}
private void writeTimings(JsonWriter writer) throws IOException {
writer.beginObject();
writer.name("send").value(-1);
writer.name("wait").value(-1);
writer.name("receive").value(-1);
// Optional fields
writer.name("blocked").value(-1);
writer.name("dns").value(-1);
writer.name("connect").value(-1);
writer.name("ssl").value(-1);
writer.endObject();
}
private void writeHeaders(JsonWriter writer, List<String[]> headers) throws IOException {
writer.beginArray();
for (String[] header : headers) {
writer.beginObject();
writer.name("name").value(header[0]);
writer.name("value").value(header[1]);
writer.endObject();
}
writer.endArray();
}
private void writeQueryString(JsonWriter writer, String query) throws IOException {
writer.beginArray();
if ((query != null) && !query.isEmpty()) {
// Remove leading '?'
if (query.startsWith("?"))
query = query.substring(1);
String[] pairs = query.split("&");
for (String pair : pairs) {
int eqPos = pair.indexOf('=');
// Decode first, then write - to avoid leaving JSON in invalid state on exception
String name, value;
try {
if (eqPos > 0) {
name = URLDecoder.decode(pair.substring(0, eqPos), "UTF-8");
value = URLDecoder.decode(pair.substring(eqPos + 1), "UTF-8");
} else {
name = URLDecoder.decode(pair, "UTF-8");
value = "";
}
} catch (Exception e) {
// Skip malformed parameter
continue;
}
writer.beginObject();
writer.name("name").value(name);
writer.name("value").value(value);
writer.endObject();
}
}
writer.endArray();
}
private void writeRequestCookies(JsonWriter writer, List<String[]> headers) throws IOException {
writer.beginArray();
for (String[] header : headers) {
if (header[0].equalsIgnoreCase("Cookie")) {
String value = header[1];
// Parse: name1=value1; name2=value2
String[] pairs = value.split(";");
for (String pair : pairs) {
pair = pair.trim();
int eqPos = pair.indexOf('=');
if (eqPos > 0) {
writer.beginObject();
writer.name("name").value(pair.substring(0, eqPos));
writer.name("value").value(pair.substring(eqPos + 1));
writer.endObject();
}
}
}
}
writer.endArray();
}
private void writeResponseCookies(JsonWriter writer, List<String[]> headers) throws IOException {
writer.beginArray();
for (String[] header : headers) {
if (header[0].equalsIgnoreCase("Set-Cookie")) {
String value = header[1];
// Parse: name=value; Path=/; Domain=.example.com; HttpOnly; Secure; SameSite=Lax
String[] parts = value.split(";");
// First part is name=value
if (parts.length > 0) {
int eqPos = parts[0].indexOf('=');
if (eqPos > 0) {
writer.beginObject();
writer.name("name").value(parts[0].substring(0, eqPos).trim());
writer.name("value").value(parts[0].substring(eqPos + 1).trim());
// Parse attributes with defaults
String path = "/";
String domain = "";
boolean httpOnly = false;
boolean secure = false;
String sameSite = null;
String expires = null;
for (int i = 1; i < parts.length; i++) {
String attr = parts[i].trim();
String attrLower = attr.toLowerCase();
if (attrLower.startsWith("path="))
path = attr.substring(5);
else if (attrLower.startsWith("domain="))
domain = attr.substring(7);
else if (attrLower.equals("httponly"))
httpOnly = true;
else if (attrLower.equals("secure"))
secure = true;
else if (attrLower.startsWith("samesite="))
sameSite = attr.substring(9);
else if (attrLower.startsWith("expires="))
expires = attr.substring(8);
}
writer.name("path").value(path);
writer.name("domain").value(domain);
writer.name("httpOnly").value(httpOnly);
writer.name("secure").value(secure);
if (sameSite != null)
writer.name("sameSite").value(sameSite);
if (expires != null) {
String isoExpires = Utils.httpDateToIso8601(expires);
if (isoExpires != null)
writer.name("expires").value(isoExpires);
}
writer.endObject();
}
}
}
}
writer.endArray();
}
private List<String[]> parseHeaders(String httpText) {
List<String[]> headers = new ArrayList<>();
int headerEnd = Utils.getEndOfHTTPHeaders(httpText.getBytes(StandardCharsets.UTF_8));
if (headerEnd == 0) headerEnd = httpText.length();
String headerSection = httpText.substring(0, Math.min(headerEnd, httpText.length()));
String[] lines = headerSection.split("\r\n");
// Skip first line (request line or status line)
for (int i = 1; i < lines.length; i++) {
int colonPos = lines[i].indexOf(':');
if (colonPos > 0) {
headers.add(new String[]{
lines[i].substring(0, colonPos),
lines[i].substring(colonPos + 1).trim()
});
}
}
return headers;
}
private byte[] extractBody(byte[] payload) {
int headerEnd = Utils.getEndOfHTTPHeaders(payload);
if ((headerEnd <= 0) || (headerEnd >= payload.length))
return null;
byte[] body = new byte[payload.length - headerEnd];
System.arraycopy(payload, headerEnd, body, 0, body.length);
return body;
}
private int getHeadersSize(byte[] payload) {
int headerEnd = Utils.getEndOfHTTPHeaders(payload);
return headerEnd > 0 ? headerEnd : -1;
}
private boolean isTextContent(byte[] body, String contentType) {
if (contentType != null) {
String ct = contentType.toLowerCase();
if (ct.startsWith("text/") || ct.contains("json") ||
ct.contains("xml") || ct.contains("javascript") || ct.contains("html"))
return true;
if (ct.startsWith("image/") || ct.startsWith("audio/") ||
ct.startsWith("video/") || ct.equals("application/octet-stream"))
return false;
}
// Check first bytes using Utils.isPrintable()
int checkLen = Math.min(body.length, 16);
for (int i = 0; i < checkLen; i++) {
if (!Utils.isPrintable(body[i]))
return false;
}
return true;
}
private String getHeaderValue(List<String[]> headers, String name) {
for (String[] header : headers) {
if (header[0].equalsIgnoreCase(name))
return header[1];
}
return null;
}
/**
* Add WebSocket messages to entry if this is a WebSocket connection.
* Uses Chrome DevTools extension format.
*/
private void writeWebSocketMessages(JsonWriter writer, ConnectionDescriptor conn, HttpLog.HttpRequest req) throws IOException {
if ((req.reply == null) || !req.hasWebsocketData())
return;
ArrayList<PayloadChunk> wsChunks;
if (CaptureService.isReadingFromPcapFile()) {
// When reading from PCAP, chunks contain raw data that must be processed
// through HTTPReassembly to decode WebSocket frames
wsChunks = new ArrayList<>();
HTTPReassembly.ReassemblyListener listener = chunk -> {
if ((chunk.type == PayloadChunk.ChunkType.WEBSOCKET) &&
!WebSocketDecoder.isControlOpcode(chunk.wsOpcode))
wsChunks.add(chunk);
};
HTTPReassembly httpReq = new HTTPReassembly(true, listener);
HTTPReassembly httpRes = new HTTPReassembly(true, listener);
int startPos = req.firstChunkPos;
synchronized (conn) {
for (int i = startPos; i < conn.getNumPayloadChunks(); i++) {
PayloadChunk chunk = conn.getPayloadChunk(i);
if ((chunk == null) || (chunk.type == PayloadChunk.ChunkType.RAW))
continue;
if (chunk.is_sent)
httpReq.handleChunk(chunk);
else
httpRes.handleChunk(chunk);
}
}
} else {
// Live capture: chunks are already decoded by mitmproxy
wsChunks = new ArrayList<>();
int startPos = req.reply.firstChunkPos + 1;
synchronized (conn) {
for (int i = startPos; i < conn.getNumPayloadChunks(); i++) {
PayloadChunk chunk = conn.getPayloadChunk(i);
if ((chunk != null) && (chunk.type == PayloadChunk.ChunkType.WEBSOCKET))
wsChunks.add(chunk);
}
}
}
if (wsChunks.isEmpty())
return;
// Chrome DevTools extension
writer.name("_resourceType").value("websocket");
writer.name("_webSocketMessages");
writer.beginArray();
for (PayloadChunk chunk : wsChunks) {
writer.beginObject();
writer.name("type").value(chunk.is_sent ? "send" : "receive");
writer.name("time").value(chunk.timestamp / 1000.0);
// Use wsOpcode if available, otherwise guess from content
int opcode;
if (chunk.wsOpcode > 0)
opcode = chunk.wsOpcode;
else {
boolean isText = (chunk.payload == null) || (chunk.payload.length == 0) ||
isTextContent(chunk.payload, null);
opcode = isText ? WebSocketDecoder.OPCODE_TEXT : WebSocketDecoder.OPCODE_BINARY;
}
writer.name("opcode").value(opcode);
if ((chunk.payload != null) && (chunk.payload.length > 0)) {
if (opcode == WebSocketDecoder.OPCODE_TEXT)
writer.name("data").value(new String(chunk.payload, StandardCharsets.UTF_8));
else
writer.name("data").value(Base64.encodeToString(chunk.payload, Base64.NO_WRAP));
} else
writer.name("data").value("");
writer.endObject();
}
writer.endArray();
}
}

View File

@ -62,6 +62,7 @@ public class PCAPdroid extends Application {
private Context mLocalizedContext;
private boolean mIsDecryptingPcap = false;
private boolean mIsUsharkAvailable = false;
private String mLoadedPcapBasename = null;
private static WeakReference<PCAPdroid> mInstance;
protected static boolean isUnderTest = false;
@ -252,4 +253,12 @@ public class PCAPdroid extends Application {
public boolean isUsharkAvailable() {
return mIsUsharkAvailable;
}
public void setLoadedPcapBasename(String basename) {
mLoadedPcapBasename = basename;
}
public String getLoadedPcapBasename() {
return mLoadedPcapBasename;
}
}

View File

@ -142,6 +142,7 @@ import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
@ -152,6 +153,7 @@ import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import java.util.TimeZone;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -331,15 +333,44 @@ public class Utils {
String rv = fmt.format(new Date(millis));
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.N) {
// convert RFC 822 (+0100) -> ISO 8601 timezone (+01:00)
// convert RFC 822 (+0100 or -0500) -> ISO 8601 timezone (+01:00 or -05:00)
int l = rv.length();
if ((l > 5) && (rv.charAt(l - 5) == '+'))
if ((l > 5) && ((rv.charAt(l - 5) == '+') || (rv.charAt(l - 5) == '-')))
rv = rv.substring(0, l - 2) + ":" + rv.substring(l - 2);
}
return rv;
}
public static String httpDateToIso8601(String httpDate) {
if (httpDate == null)
return null;
String[] patterns = {
"EEE, dd-MMM-yyyy HH:mm:ss zzz", // Fri, 05-Feb-2027 15:30:34 GMT
"EEE, dd MMM yyyy HH:mm:ss zzz", // RFC 1123: Fri, 05 Feb 2027 15:30:34 GMT
"EEEE, dd-MMM-yy HH:mm:ss zzz", // RFC 850: Friday, 05-Feb-27 15:30:34 GMT
"EEE MMM d HH:mm:ss yyyy" // ANSI C asctime: Fri Feb 5 15:30:34 2027
};
Date date = null;
for (String pattern : patterns) {
try {
SimpleDateFormat parser = new SimpleDateFormat(pattern, Locale.US);
parser.setTimeZone(TimeZone.getTimeZone("GMT"));
date = parser.parse(httpDate);
break;
} catch (ParseException ignored) {}
}
if (date == null)
return null;
SimpleDateFormat iso8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
iso8601.setTimeZone(TimeZone.getTimeZone("UTC"));
return iso8601.format(date);
}
public static String formatEpochMillis(Context context, long millis) {
Locale locale = getPrimaryLocale(context);
DateFormat fmt = new SimpleDateFormat("MM/dd/yy HH:mm:ss.SSS", locale);
@ -732,6 +763,16 @@ public class Utils {
return(Utils.getUniqueFileName(context, pcapng_format ? "pcapng" : "pcap"));
}
// Returns the export filename with the given extension.
// If a PCAP file was loaded by the user, uses that filename as base.
// Otherwise, generates a unique filename based on date/time.
public static String getExportFileName(Context context, String ext) {
String loadedBasename = PCAPdroid.getInstance().getLoadedPcapBasename();
if (loadedBasename != null)
return loadedBasename + "." + ext;
return getUniqueFileName(context, ext);
}
public static @Nullable BitmapDrawable scaleDrawable(Resources res, Drawable drawable, int new_x, int new_y) {
if((new_x <= 0) || (new_y <= 0))
return null;

View File

@ -19,6 +19,9 @@
package com.emanuelef.remote_capture.activities;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
@ -26,6 +29,10 @@ import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import androidx.activity.result.ActivityResult;
import androidx.appcompat.app.AlertDialog;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
@ -34,6 +41,7 @@ import androidx.viewpager2.widget.ViewPager2;
import com.emanuelef.remote_capture.CaptureService;
import com.emanuelef.remote_capture.ConnectionsRegister;
import com.emanuelef.remote_capture.HarWriter;
import com.emanuelef.remote_capture.HttpLog;
import com.emanuelef.remote_capture.Log;
import com.emanuelef.remote_capture.R;
@ -47,7 +55,11 @@ import com.emanuelef.remote_capture.model.PayloadChunk;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HttpDetailsActivity extends PayloadExportActivity implements ConnectionsListener, PayloadHostActivity {
private static final String TAG = "HttpRequestDetailsActivity";
@ -63,6 +75,11 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec
private MenuItem mMenuNext;
private MenuItem mMenuDisplayAs;
private Boolean mDisplayMode;
private Uri mHarFname;
private AlertDialog mAlertDialog;
private final ActivityResultLauncher<Intent> harFileLauncher =
registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), this::harFileResult);
private static final int POS_REQUEST = 0;
private static final int POS_REPLY = 1;
@ -132,6 +149,14 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec
unregisterConnsListener();
}
@Override
protected void onDestroy() {
if(mAlertDialog != null)
mAlertDialog.dismiss();
super.onDestroy();
}
private void setupTabs() {
mPagerAdapter = new StateAdapter(this);
mPager.setAdapter(mPagerAdapter);
@ -205,11 +230,10 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec
payloadFragment.setDisplayMode(mDisplayMode);
if(mDisplayMode) {
if(mDisplayMode)
mMenuDisplayAs.setTitle(R.string.display_as_hexdump);
} else {
else
mMenuDisplayAs.setTitle(R.string.display_as_text);
}
} else if(currentFragment instanceof ConnectionPayload wsFragment) {
if(mDisplayMode == null)
mDisplayMode = true;
@ -245,6 +269,9 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec
updateMenuVisibility();
}
return true;
} else if(itemId == R.id.save_as_har) {
openHarFileSelector();
return true;
}
return super.onOptionsItemSelected(item);
@ -393,6 +420,120 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec
unregisterConnsListener();
}
private void openHarFileSelector() {
if (mHttpReq == null)
return;
boolean noFileDialog = false;
String fname = Utils.getExportFileName(this, "har");
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)) {
try {
harFileLauncher.launch(intent);
} catch (ActivityNotFoundException e) {
noFileDialog = true;
}
} else
noFileDialog = true;
if(noFileDialog) {
Log.d(TAG, "No app found to handle file selection");
Uri uri = Utils.getDownloadsUri(this, fname);
if(uri != null) {
mHarFname = uri;
exportHar();
} else
Utils.showToastLong(this, R.string.no_activity_file_selection);
}
}
private void exportHar() {
if(mHarFname == null || mHttpReq == null)
return;
Log.d(TAG, "Writing HAR file: " + mHarFname);
ExecutorService executor = Executors.newSingleThreadExecutor();
Handler handler = new Handler(Looper.getMainLooper());
final boolean[] cancelled = {false};
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setTitle(R.string.exporting);
builder.setMessage(R.string.export_in_progress);
builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
Log.i(TAG, "Abort HAR export");
cancelled[0] = true;
executor.shutdownNow();
});
mAlertDialog = builder.create();
mAlertDialog.setCanceledOnTouchOutside(false);
mAlertDialog.show();
mAlertDialog.setOnCancelListener(dialog -> {
Log.i(TAG, "Abort HAR export (back button)");
cancelled[0] = true;
executor.shutdownNow();
});
mAlertDialog.setOnDismissListener(dialog -> mAlertDialog = null);
final Uri harFname = mHarFname;
final HttpLog.HttpRequest httpReq = mHttpReq;
mHarFname = null;
executor.execute(() -> {
boolean success = false;
try {
OutputStream stream = getContentResolver().openOutputStream(harFname, "rwt");
if(stream != null) {
HarWriter writer = new HarWriter(HttpDetailsActivity.this, httpReq);
writer.write(stream);
stream.close();
success = true;
}
} catch (IOException e) {
if(!cancelled[0])
e.printStackTrace();
}
if(cancelled[0])
return;
final boolean result = success;
final Utils.UriStat stat = result ? Utils.getUriStat(HttpDetailsActivity.this, harFname) : null;
handler.post(() -> {
if(mAlertDialog != null)
mAlertDialog.dismiss();
if(result) {
if(stat != null)
Utils.showToast(HttpDetailsActivity.this, R.string.file_saved_with_name, stat.name);
else
Utils.showToast(HttpDetailsActivity.this, R.string.save_ok);
} else
Utils.showToast(HttpDetailsActivity.this, R.string.cannot_write_file);
});
});
}
private void harFileResult(final ActivityResult result) {
if((result.getResultCode() == RESULT_OK) && (result.getData() != null)) {
mHarFname = result.getData().getData();
exportHar();
} else {
mHarFname = null;
}
}
private class StateAdapter extends FragmentStateAdapter {
StateAdapter(final FragmentActivity fa) { super(fa); }

View File

@ -826,6 +826,10 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
appStateStarting();
// Clear loaded basename if this is a new capture (not from loaded file)
if (input_pcap_path == null)
PCAPdroid.getInstance().setLoadedPcapBasename(null);
PCAPdroid.getInstance().setIsDecryptingPcap(mDecryptPcap);
mDecryptPcap = false;
@ -1069,6 +1073,17 @@ public class MainActivity extends BaseActivity implements NavigationView.OnNavig
}
private void startOpenPcap(Uri pcap_uri, Uri keylog_uri) {
// Extract and store the base filename (without extension)
Utils.UriStat stat = Utils.getUriStat(this, pcap_uri);
if (stat != null && stat.name != null) {
String name = stat.name;
int dotIndex = name.lastIndexOf('.');
String basename = (dotIndex > 0) ? name.substring(0, dotIndex) : name;
PCAPdroid.getInstance().setLoadedPcapBasename(basename);
} else {
PCAPdroid.getInstance().setLoadedPcapBasename(null);
}
mPcapExecutor = Executors.newSingleThreadExecutor();
AlertDialog.Builder builder = new AlertDialog.Builder(this);

View File

@ -1041,7 +1041,7 @@ public class ConnectionsFragment extends Fragment implements ConnectionsListener
public void openFileSelector() {
boolean noFileDialog = false;
String fname = Utils.getUniqueFileName(requireContext(), "csv");
String fname = Utils.getExportFileName(requireContext(), "csv");
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");

View File

@ -40,6 +40,7 @@ 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.appcompat.widget.SearchView;
import androidx.core.graphics.Insets;
import androidx.core.view.MenuProvider;
@ -52,6 +53,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.emanuelef.remote_capture.AppsResolver;
import com.emanuelef.remote_capture.CaptureService;
import com.emanuelef.remote_capture.HarWriter;
import com.emanuelef.remote_capture.HttpLog;
import com.emanuelef.remote_capture.Log;
import com.emanuelef.remote_capture.R;
@ -69,6 +71,8 @@ import com.google.android.material.slider.Slider;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuProvider, SearchView.OnQueryTextListener {
private static final String TAG = "HttpLogFragment";
@ -79,12 +83,15 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
private int mFabDownMargin = 0;
private MenuItem mMenuItemSearch;
private MenuItem mSave;
private MenuItem mSaveAsHar;
private SearchView mSearchView;
private Handler mHandler;
private ChipGroup mActiveFilter;
private Slider mSizeSlider;
private boolean mSizeSliderActive = false;
private Uri mTxtFname;
private Uri mHarFname;
private AlertDialog mAlertDialog;
private String mQueryToApply;
private AppsResolver mApps;
@ -95,6 +102,8 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
registerForActivityResult(new StartActivityForResult(), this::filterResult);
private final ActivityResultLauncher<Intent> txtFileLauncher =
registerForActivityResult(new StartActivityForResult(), this::txtFileResult);
private final ActivityResultLauncher<Intent> harFileLauncher =
registerForActivityResult(new StartActivityForResult(), this::harFileResult);
@Override
public void onResume() {
@ -129,6 +138,14 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
mQueryToApply = mSearchView.getQuery().toString();
}
@Override
public void onDestroyView() {
if(mAlertDialog != null)
mAlertDialog.dismiss();
super.onDestroyView();
}
@Override
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
@ -336,6 +353,7 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
menuInflater.inflate(R.menu.http_log_menu, menu);
mSave = menu.findItem(R.id.save);
mSaveAsHar = menu.findItem(R.id.save_as_har);
mMenuItemSearch = menu.findItem(R.id.search);
mSearchView = (SearchView) mMenuItemSearch.getActionView();
@ -357,6 +375,9 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
if(id == R.id.save) {
openFileSelector();
return true;
} else if(id == R.id.save_as_har) {
openHarFileSelector();
return true;
} else if(id == R.id.edit_filter) {
Intent intent = new Intent(requireContext(), HttpLogFilterActivity.class);
intent.putExtra(HttpLogFilterActivity.FILTER_DESCRIPTOR, mAdapter.mFilter);
@ -542,11 +563,13 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
mMenuItemSearch.setVisible(is_enabled);
mSave.setEnabled(is_enabled);
if(mSaveAsHar != null)
mSaveAsHar.setEnabled(is_enabled);
}
public void openFileSelector() {
boolean noFileDialog = false;
String fname = Utils.getUniqueFileName(requireContext(), "txt");
String fname = Utils.getExportFileName(requireContext(), "txt");
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
@ -654,6 +677,121 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
}
}
public void openHarFileSelector() {
boolean noFileDialog = false;
String fname = Utils.getExportFileName(requireContext(), "har");
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_TITLE, fname);
if(Utils.supportsFileDialog(requireContext(), intent)) {
try {
harFileLauncher.launch(intent);
} catch (ActivityNotFoundException e) {
noFileDialog = true;
}
} else
noFileDialog = true;
if(noFileDialog) {
Log.d(TAG, "No app found to handle file selection");
Uri uri = Utils.getDownloadsUri(requireContext(), fname);
if(uri != null) {
mHarFname = uri;
exportHttpLogHar();
} else
Utils.showToastLong(requireContext(), R.string.no_activity_file_selection);
}
}
private void exportHttpLogHar() {
if(mHarFname == null)
return;
HttpLog httpLog = CaptureService.getHttpLog();
if(httpLog == null)
return;
Log.d(TAG, "Writing HAR file: " + mHarFname);
ExecutorService executor = Executors.newSingleThreadExecutor();
Handler handler = new Handler(Looper.getMainLooper());
final boolean[] cancelled = {false};
AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());
builder.setTitle(R.string.exporting);
builder.setMessage(R.string.export_in_progress);
builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
Log.i(TAG, "Abort HAR export");
cancelled[0] = true;
executor.shutdownNow();
});
mAlertDialog = builder.create();
mAlertDialog.setCanceledOnTouchOutside(false);
mAlertDialog.show();
mAlertDialog.setOnCancelListener(dialog -> {
Log.i(TAG, "Abort HAR export (back button)");
cancelled[0] = true;
executor.shutdownNow();
});
mAlertDialog.setOnDismissListener(dialog -> mAlertDialog = null);
final Uri harFname = mHarFname;
mHarFname = null;
executor.execute(() -> {
boolean success = false;
try {
OutputStream stream = requireActivity().getContentResolver().openOutputStream(harFname, "rwt");
if(stream != null) {
HarWriter writer = new HarWriter(requireContext(), httpLog);
writer.write(stream);
stream.close();
success = true;
}
} catch (IOException e) {
if(!cancelled[0])
e.printStackTrace();
}
if(cancelled[0])
return;
final boolean result = success;
final Utils.UriStat stat = result ? Utils.getUriStat(requireContext(), harFname) : null;
handler.post(() -> {
if(mAlertDialog != null)
mAlertDialog.dismiss();
if(result) {
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);
} else
Utils.showToast(requireContext(), R.string.cannot_write_file);
});
});
}
private void harFileResult(final ActivityResult result) {
if((result.getResultCode() == Activity.RESULT_OK) && (result.getData() != null)) {
mHarFname = result.getData().getData();
exportHttpLogHar();
} else {
mHarFname = null;
}
}
public void clearFilters() {
if(mAdapter != null) {
mAdapter.mFilter = new HttpLogFilterDescriptor();

View File

@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright 2020-22 - Emanuele Faranda
* Copyright 2020-26 - Emanuele Faranda
*/
package com.emanuelef.remote_capture.model;
@ -37,6 +37,7 @@ public class PayloadChunk implements Serializable {
public String httpPath = "";
public String httpQuery = "";
public String httpContentType = "";
public String httpVersion = "";
public int httpBodyLength = 0;
private boolean mHttpRst = false;

View File

@ -24,4 +24,10 @@
android:icon="@drawable/ic_short_text"
app:showAsAction="never"
android:visible="false" />
<item
android:id="@+id/save_as_har"
android:title="@string/save_as_har"
android:orderInCategory="40"
app:showAsAction="never" />
</menu>

View File

@ -24,11 +24,17 @@
<item
android:id="@+id/save"
android:title="@string/save_to_file"
android:title="@string/save_as_text"
android:orderInCategory="30"
android:icon="@drawable/ic_save"
app:showAsAction="never" />
<item
android:id="@+id/save_as_har"
android:title="@string/save_as_har"
android:orderInCategory="31"
app:showAsAction="never" />
<item
android:id="@+id/switch_to_connections"
android:visible="false" />

View File

@ -350,6 +350,8 @@
<string name="download_in_progress">Download in progress, please wait</string>
<string name="pcap_load_in_progress">Capture file loading in progress, please wait</string>
<string name="download_failed">Download failed</string>
<string name="exporting">Exporting…</string>
<string name="export_in_progress">Export in progress, please wait</string>
<string name="geo_db_not_found">Database not found. Geolocation is disabled</string>
<string name="geo_db_missing">Geolocation database missing</string>
<string name="country_rules_warning">To use country-based firewall rules, download the geolocation database from the PCAPdroid settings, otherwise country-based rules will be ineffective</string>
@ -587,4 +589,6 @@
<string name="decryption_error">Decryption error</string>
<string name="previous">Previous</string>
<string name="next">Next</string>
<string name="save_as_text">Save as text</string>
<string name="save_as_har">Save as HAR</string>
</resources>

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,7 @@ import org.junit.Test;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
public class UtilsTest {
@Test
@ -46,4 +47,38 @@ public class UtilsTest {
assertFalse(Utils.subnetContains("2001:0db8:85a3::8a2e:0370:7334", 120, "2001:0db8:85a3::8a2e:0370:0001"));
}
@Test
public void testTimezoneConversionLogic() {
// Test the RFC 822 -> ISO 8601 timezone conversion logic used in formatMillisIso8601
// This simulates what the code does on Android < N
// Test positive timezone (was already working)
String positiveInput = "2026-01-16T17:15:15.123+0100";
String positiveExpected = "2026-01-16T17:15:15.123+01:00";
assertEquals(positiveExpected, convertTimezone(positiveInput));
// Test negative timezone (was broken before the fix)
String negativeInput = "2026-01-16T10:15:15.123-0500";
String negativeExpected = "2026-01-16T10:15:15.123-05:00";
assertEquals(negativeExpected, convertTimezone(negativeInput));
// Test UTC (edge case with +0000)
String utcInput = "2026-01-16T15:15:15.123+0000";
String utcExpected = "2026-01-16T15:15:15.123+00:00";
assertEquals(utcExpected, convertTimezone(utcInput));
// Test negative offset at international date line
String idlInput = "2026-01-16T03:15:15.123-1200";
String idlExpected = "2026-01-16T03:15:15.123-12:00";
assertEquals(idlExpected, convertTimezone(idlInput));
}
// Helper that replicates the conversion logic from Utils.formatMillisIso8601
private String convertTimezone(String rv) {
int l = rv.length();
if ((l > 5) && ((rv.charAt(l - 5) == '+') || (rv.charAt(l - 5) == '-')))
rv = rv.substring(0, l - 2) + ":" + rv.substring(l - 2);
return rv;
}
}