diff --git a/app/src/main/java/com/emanuelef/remote_capture/HTTPReassembly.java b/app/src/main/java/com/emanuelef/remote_capture/HTTPReassembly.java
index 5d979b22..328eb614 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/HTTPReassembly.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/HTTPReassembly.java
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with PCAPdroid. If not, see .
*
- * 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)
diff --git a/app/src/main/java/com/emanuelef/remote_capture/HarWriter.java b/app/src/main/java/com/emanuelef/remote_capture/HarWriter.java
new file mode 100644
index 00000000..4ecc6a63
--- /dev/null
+++ b/app/src/main/java/com/emanuelef/remote_capture/HarWriter.java
@@ -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 .
+ *
+ * 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 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 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 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 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 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 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 parseHeaders(String httpText) {
+ List 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 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 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();
+ }
+}
diff --git a/app/src/main/java/com/emanuelef/remote_capture/PCAPdroid.java b/app/src/main/java/com/emanuelef/remote_capture/PCAPdroid.java
index ed7cc9a4..11f41f5f 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/PCAPdroid.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/PCAPdroid.java
@@ -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 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;
+ }
}
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 ce31651c..6c47ad7b 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/Utils.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/Utils.java
@@ -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;
diff --git a/app/src/main/java/com/emanuelef/remote_capture/activities/HttpDetailsActivity.java b/app/src/main/java/com/emanuelef/remote_capture/activities/HttpDetailsActivity.java
index 4fb1e9a8..69ca446d 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/activities/HttpDetailsActivity.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/activities/HttpDetailsActivity.java
@@ -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 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); }
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 ee346edd..4c73d6da 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
@@ -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);
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 8fed9b9a..a53e328e 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
@@ -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("*/*");
diff --git a/app/src/main/java/com/emanuelef/remote_capture/fragments/HttpLogFragment.java b/app/src/main/java/com/emanuelef/remote_capture/fragments/HttpLogFragment.java
index 24576198..b1d102d5 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/fragments/HttpLogFragment.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/fragments/HttpLogFragment.java
@@ -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 txtFileLauncher =
registerForActivityResult(new StartActivityForResult(), this::txtFileResult);
+ private final ActivityResultLauncher 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();
diff --git a/app/src/main/java/com/emanuelef/remote_capture/model/PayloadChunk.java b/app/src/main/java/com/emanuelef/remote_capture/model/PayloadChunk.java
index 4f8299c6..247b6b41 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/model/PayloadChunk.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/model/PayloadChunk.java
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with PCAPdroid. If not, see .
*
- * 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;
diff --git a/app/src/main/res/menu/http_details_menu.xml b/app/src/main/res/menu/http_details_menu.xml
index 6f0d83ab..231e5db0 100644
--- a/app/src/main/res/menu/http_details_menu.xml
+++ b/app/src/main/res/menu/http_details_menu.xml
@@ -24,4 +24,10 @@
android:icon="@drawable/ic_short_text"
app:showAsAction="never"
android:visible="false" />
+
+
diff --git a/app/src/main/res/menu/http_log_menu.xml b/app/src/main/res/menu/http_log_menu.xml
index 33aa5fc5..e1a67f02 100644
--- a/app/src/main/res/menu/http_log_menu.xml
+++ b/app/src/main/res/menu/http_log_menu.xml
@@ -24,11 +24,17 @@
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3cfd9749..b41698a8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -350,6 +350,8 @@
Download in progress, please wait
Capture file loading in progress, please wait
Download failed
+ Exporting…
+ Export in progress, please wait
Database not found. Geolocation is disabled
Geolocation database missing
To use country-based firewall rules, download the geolocation database from the PCAPdroid settings, otherwise country-based rules will be ineffective
@@ -587,4 +589,6 @@
Decryption error
Previous
Next
+ Save as text
+ Save as HAR
diff --git a/app/src/test/java/com/emanuelef/remote_capture/HarWriterTest.java b/app/src/test/java/com/emanuelef/remote_capture/HarWriterTest.java
new file mode 100644
index 00000000..b107cc82
--- /dev/null
+++ b/app/src/test/java/com/emanuelef/remote_capture/HarWriterTest.java
@@ -0,0 +1,2507 @@
+/*
+ * 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 2026 - Emanuele Faranda
+ */
+
+package com.emanuelef.remote_capture;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import androidx.preference.PreferenceManager;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import com.emanuelef.remote_capture.model.CaptureSettings;
+import com.emanuelef.remote_capture.model.ConnectionDescriptor;
+import com.emanuelef.remote_capture.model.PayloadChunk;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import android.util.Base64;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.zip.GZIPOutputStream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(RobolectricTestRunner.class)
+public class HarWriterTest {
+ private Context context;
+ private CaptureService service;
+ private HttpLog httpLog;
+
+ @Before
+ public void setup() {
+ context = ApplicationProvider.getApplicationContext();
+ httpLog = new HttpLog();
+
+ // Mock CaptureService
+ service = new CaptureService();
+ Whitebox.setInternalState(service, "INSTANCE", service);
+ Whitebox.setInternalState(service, "mHttpLog", httpLog);
+
+ // Create mock settings with full payload mode
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
+ CaptureSettings settings = new CaptureSettings(context, prefs);
+ settings.full_payload = true;
+ Whitebox.setInternalState(service, "mSettings", settings);
+ }
+
+ @After
+ public void tearDown() {
+ Whitebox.setInternalState(service, "INSTANCE", null);
+ }
+
+ /**
+ * Create a ConnectionDescriptor for testing
+ */
+ private ConnectionDescriptor createConnection(int incrId, String dstIp, int dstPort) {
+ return new ConnectionDescriptor(incrId, 4, 6,
+ "192.168.1.100", dstIp, "US",
+ 54321, dstPort, 0, 1000, 0, false, System.currentTimeMillis());
+ }
+
+ /**
+ * Create a PayloadChunk with HTTP data
+ */
+ private PayloadChunk createHttpChunk(String httpText, boolean isSent, long timestamp) {
+ byte[] payload = httpText.getBytes(StandardCharsets.UTF_8);
+ return new PayloadChunk(payload, PayloadChunk.ChunkType.HTTP, isSent, timestamp, 0);
+ }
+
+ /**
+ * Add a payload chunk directly to the connection without triggering HTTP logging.
+ * This allows us to control test data precisely.
+ */
+ @SuppressWarnings("unchecked")
+ private void addChunkDirect(ConnectionDescriptor conn, PayloadChunk chunk) {
+ try {
+ java.lang.reflect.Field field = ConnectionDescriptor.class.getDeclaredField("payload_chunks");
+ field.setAccessible(true);
+ java.util.ArrayList chunks = (java.util.ArrayList) field.get(conn);
+ synchronized (conn) {
+ chunks.add(chunk);
+ }
+
+ if (chunk.type == PayloadChunk.ChunkType.WEBSOCKET) {
+ java.lang.reflect.Field wsField = ConnectionDescriptor.class.getDeclaredField("has_websocket_data");
+ wsField.setAccessible(true);
+ wsField.setBoolean(conn, true);
+ }
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to add chunk directly", e);
+ }
+ }
+
+ /**
+ * Write HAR to string using HarWriter
+ */
+ private String writeHarToString() throws IOException {
+ HarWriter writer = new HarWriter(context, httpLog);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ writer.write(out);
+ return out.toString(StandardCharsets.UTF_8.name());
+ }
+
+ /**
+ * Parse HAR JSON and return the root object
+ */
+ private JsonObject parseHar(String harJson) {
+ return JsonParser.parseString(harJson).getAsJsonObject();
+ }
+
+ @Test
+ public void testBasicHarStructure() throws IOException {
+ // Create a simple HTTP request/response
+ ConnectionDescriptor conn = createConnection(1, "93.184.216.34", 80);
+ conn.info = "example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /index.html HTTP/1.1\r\n" +
+ "Host: example.com\r\n" +
+ "User-Agent: TestAgent/1.0\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/html\r\n" +
+ "Content-Length: 13\r\n" +
+ "\r\n" +
+ "Hello, World!";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 100;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "example.com";
+ httpReq.path = "/index.html";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "text/html";
+ httpReply.bodyLength = 13;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ // Write HAR
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+
+ // Verify basic structure
+ assertTrue("HAR should have 'log' object", har.has("log"));
+ JsonObject log = har.getAsJsonObject("log");
+
+ // Verify version
+ assertEquals("1.2", log.get("version").getAsString());
+
+ // Verify creator
+ assertTrue("log should have 'creator' object", log.has("creator"));
+ JsonObject creator = log.getAsJsonObject("creator");
+ assertEquals("PCAPdroid", creator.get("name").getAsString());
+ assertNotNull("creator should have version", creator.get("version"));
+
+ // Verify entries array exists
+ assertTrue("log should have 'entries' array", log.has("entries"));
+ JsonArray entries = log.getAsJsonArray("entries");
+ assertEquals(1, entries.size());
+
+ JsonObject harEntry = entries.get(0).getAsJsonObject();
+
+ // Verify serverIPAddress
+ assertEquals("93.184.216.34", harEntry.get("serverIPAddress").getAsString());
+
+ // Verify connection ID
+ assertEquals("1", harEntry.get("connection").getAsString());
+
+ // Verify cache object exists
+ assertTrue("entry should have 'cache' object", harEntry.has("cache"));
+ JsonObject cache = harEntry.getAsJsonObject("cache");
+ assertNotNull("cache object should not be null", cache);
+
+ // Verify startedDateTime
+ assertTrue("entry should have 'startedDateTime'", harEntry.has("startedDateTime"));
+ String startedDateTime = harEntry.get("startedDateTime").getAsString();
+ assertNotNull("startedDateTime should not be null", startedDateTime);
+ assertTrue("startedDateTime should contain 'T'", startedDateTime.contains("T"));
+ }
+
+ @Test
+ public void testRequestFields() throws IOException {
+ ConnectionDescriptor conn = createConnection(2, "10.0.0.1", 8080);
+ conn.info = "api.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /api/users?page=1&limit=10 HTTP/1.1\r\n" +
+ "Host: api.example.com\r\n" +
+ "Accept: application/json\r\n" +
+ "Cookie: session=abc123; user=john\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: application/json\r\n" +
+ "\r\n" +
+ "[]";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "api.example.com";
+ httpReq.path = "/api/users";
+ httpReq.query = "?page=1&limit=10";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "application/json";
+ httpReply.bodyLength = 2;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+
+ // Verify request method
+ assertEquals("GET", request.get("method").getAsString());
+
+ // Verify URL
+ String url = request.get("url").getAsString();
+ assertTrue("URL should contain host", url.contains("api.example.com"));
+ assertTrue("URL should contain path", url.contains("/api/users"));
+
+ // Verify HTTP version
+ assertEquals("HTTP/1.1", request.get("httpVersion").getAsString());
+
+ // Verify headers
+ JsonArray headers = request.getAsJsonArray("headers");
+ assertNotNull("headers should not be null", headers);
+ assertTrue("should have headers", headers.size() > 0);
+
+ // Check for Host header
+ boolean hasHost = false;
+ for (int i = 0; i < headers.size(); i++) {
+ JsonObject header = headers.get(i).getAsJsonObject();
+ if (header.get("name").getAsString().equals("Host")) {
+ assertEquals("api.example.com", header.get("value").getAsString());
+ hasHost = true;
+ }
+ }
+ assertTrue("should have Host header", hasHost);
+
+ // Verify query string
+ JsonArray queryString = request.getAsJsonArray("queryString");
+ assertNotNull("queryString should not be null", queryString);
+ assertEquals(2, queryString.size());
+
+ // Verify query parameters
+ boolean hasPage = false, hasLimit = false;
+ for (int i = 0; i < queryString.size(); i++) {
+ JsonObject param = queryString.get(i).getAsJsonObject();
+ String name = param.get("name").getAsString();
+ String value = param.get("value").getAsString();
+ if (name.equals("page") && value.equals("1")) hasPage = true;
+ if (name.equals("limit") && value.equals("10")) hasLimit = true;
+ }
+ assertTrue("should have 'page' query param", hasPage);
+ assertTrue("should have 'limit' query param", hasLimit);
+
+ // Verify cookies
+ JsonArray cookies = request.getAsJsonArray("cookies");
+ assertNotNull("cookies should not be null", cookies);
+ assertEquals(2, cookies.size());
+
+ boolean hasSession = false, hasUser = false;
+ for (int i = 0; i < cookies.size(); i++) {
+ JsonObject cookie = cookies.get(i).getAsJsonObject();
+ String name = cookie.get("name").getAsString();
+ String value = cookie.get("value").getAsString();
+ if (name.equals("session") && value.equals("abc123")) hasSession = true;
+ if (name.equals("user") && value.equals("john")) hasUser = true;
+ }
+ assertTrue("should have 'session' cookie", hasSession);
+ assertTrue("should have 'user' cookie", hasUser);
+
+ // Verify headersSize and bodySize
+ assertTrue("headersSize should be > 0", request.get("headersSize").getAsInt() > 0);
+ assertEquals(0, request.get("bodySize").getAsInt());
+ }
+
+ @Test
+ public void testResponseFields() throws IOException {
+ ConnectionDescriptor conn = createConnection(3, "203.0.113.50", 443);
+ conn.info = "secure.example.com";
+ conn.l7proto = "TLS.HTTP";
+
+ String httpRequest = "GET /secure HTTP/1.1\r\n" +
+ "Host: secure.example.com\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 301 Moved Permanently\r\n" +
+ "Content-Type: text/html\r\n" +
+ "Location: https://secure.example.com/new-path\r\n" +
+ "Set-Cookie: tracking=xyz789; Path=/; HttpOnly; Secure\r\n" +
+ "\r\n" +
+ "Redirecting...";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 75;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "secure.example.com";
+ httpReq.path = "/secure";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 301;
+ httpReply.responseStatus = "Moved Permanently";
+ httpReply.contentType = "text/html";
+ httpReply.bodyLength = 27;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+
+ // Verify status
+ assertEquals(301, response.get("status").getAsInt());
+ assertEquals("Moved Permanently", response.get("statusText").getAsString());
+
+ // Verify HTTP version
+ assertEquals("HTTP/1.1", response.get("httpVersion").getAsString());
+
+ // Verify redirect URL
+ assertEquals("https://secure.example.com/new-path", response.get("redirectURL").getAsString());
+
+ // Verify response headers
+ JsonArray headers = response.getAsJsonArray("headers");
+ assertNotNull("response headers should not be null", headers);
+ assertTrue("should have response headers", headers.size() > 0);
+
+ // Check Location header
+ boolean hasLocation = false;
+ for (int i = 0; i < headers.size(); i++) {
+ JsonObject header = headers.get(i).getAsJsonObject();
+ if (header.get("name").getAsString().equals("Location")) {
+ assertEquals("https://secure.example.com/new-path", header.get("value").getAsString());
+ hasLocation = true;
+ }
+ }
+ assertTrue("should have Location header", hasLocation);
+
+ // Verify response cookies (Set-Cookie parsing)
+ JsonArray cookies = response.getAsJsonArray("cookies");
+ assertNotNull("response cookies should not be null", cookies);
+ assertEquals(1, cookies.size());
+
+ JsonObject cookie = cookies.get(0).getAsJsonObject();
+ assertEquals("tracking", cookie.get("name").getAsString());
+ assertEquals("xyz789", cookie.get("value").getAsString());
+ assertEquals("/", cookie.get("path").getAsString());
+ assertTrue("cookie should be httpOnly", cookie.get("httpOnly").getAsBoolean());
+ assertTrue("cookie should be secure", cookie.get("secure").getAsBoolean());
+
+ // Verify content
+ JsonObject content = response.getAsJsonObject("content");
+ assertNotNull("content should not be null", content);
+ assertEquals(27, content.get("size").getAsInt());
+ assertEquals("text/html", content.get("mimeType").getAsString());
+
+ // Verify headersSize and bodySize
+ assertTrue("response headersSize should be > 0", response.get("headersSize").getAsInt() > 0);
+ assertEquals(27, response.get("bodySize").getAsInt());
+ }
+
+ @Test
+ public void testPostRequestWithBody() throws IOException {
+ ConnectionDescriptor conn = createConnection(4, "10.0.0.2", 80);
+ conn.info = "api.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "POST /api/login HTTP/1.1\r\n" +
+ "Host: api.example.com\r\n" +
+ "Content-Type: application/x-www-form-urlencoded\r\n" +
+ "Content-Length: 30\r\n" +
+ "\r\n" +
+ "username=admin&password=secret";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: application/json\r\n" +
+ "\r\n" +
+ "{\"success\":true}";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 100;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "POST";
+ httpReq.host = "api.example.com";
+ httpReq.path = "/api/login";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 30;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "application/json";
+ httpReply.bodyLength = 16;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+
+ // Verify method
+ assertEquals("POST", request.get("method").getAsString());
+
+ // Verify postData
+ assertTrue("POST request should have postData", request.has("postData"));
+ JsonObject postData = request.getAsJsonObject("postData");
+
+ assertEquals("application/x-www-form-urlencoded", postData.get("mimeType").getAsString());
+ String text = postData.get("text").getAsString();
+ // Verify the exact POST body is extracted
+ assertEquals("username=admin&password=secret", text);
+
+ // Verify bodySize
+ assertEquals(30, request.get("bodySize").getAsInt());
+ }
+
+ @Test
+ public void testNoResponseEntry() throws IOException {
+ // Test entry where no response was received
+ ConnectionDescriptor conn = createConnection(8, "10.0.0.5", 80);
+ conn.info = "timeout.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /timeout HTTP/1.1\r\n" +
+ "Host: timeout.example.com\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ addChunkDirect(conn, reqChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "timeout.example.com";
+ httpReq.path = "/timeout";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+ // No reply set
+
+ httpLog.addHttpRequest(httpReq);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+
+ // Verify response with no actual data
+ assertEquals(0, response.get("status").getAsInt());
+ assertEquals("", response.get("statusText").getAsString());
+ assertEquals(-1, response.get("headersSize").getAsInt());
+ assertEquals(-1, response.get("bodySize").getAsInt());
+ assertEquals("", response.get("redirectURL").getAsString());
+ }
+
+ @Test
+ public void testMultipleEntries() throws IOException {
+ // Test with multiple HTTP requests
+ for (int i = 0; i < 3; i++) {
+ ConnectionDescriptor conn = createConnection(10 + i, "10.0.0." + (10 + i), 80);
+ conn.info = "multi" + i + ".example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /page" + i + " HTTP/1.1\r\n" +
+ "Host: multi" + i + ".example.com\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/plain\r\n" +
+ "\r\n" +
+ "page" + i;
+
+ long reqTimestamp = System.currentTimeMillis() + i * 100;
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "multi" + i + ".example.com";
+ httpReq.path = "/page" + i;
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "text/plain";
+ httpReply.bodyLength = 5;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+ }
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonArray entries = har.getAsJsonObject("log").getAsJsonArray("entries");
+
+ // Verify we have 3 entries
+ assertEquals(3, entries.size());
+
+ // Verify each entry has the expected path
+ for (int i = 0; i < 3; i++) {
+ JsonObject entry = entries.get(i).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+ String url = request.get("url").getAsString();
+ assertTrue("Entry " + i + " should have correct path", url.contains("/page" + i));
+ }
+ }
+
+ @Test
+ public void testBinaryContentEncoding() throws IOException {
+ ConnectionDescriptor conn = createConnection(13, "10.0.0.9", 80);
+ conn.info = "binary.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /image.png HTTP/1.1\r\n" +
+ "Host: binary.example.com\r\n" +
+ "\r\n";
+
+ // Create a response with binary content (non-printable bytes)
+ String httpResponseHeaders = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: image/png\r\n" +
+ "\r\n";
+ byte[] headerBytes = httpResponseHeaders.getBytes(StandardCharsets.UTF_8);
+ byte[] binaryBody = new byte[] {(byte)0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; // PNG signature
+ byte[] fullResponse = new byte[headerBytes.length + binaryBody.length];
+ System.arraycopy(headerBytes, 0, fullResponse, 0, headerBytes.length);
+ System.arraycopy(binaryBody, 0, fullResponse, headerBytes.length, binaryBody.length);
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 100;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = new PayloadChunk(fullResponse, PayloadChunk.ChunkType.HTTP, false, respTimestamp, 0);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "binary.example.com";
+ httpReq.path = "/image.png";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "image/png";
+ httpReply.bodyLength = binaryBody.length;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+ JsonObject content = response.getAsJsonObject("content");
+
+ // Verify binary content is base64 encoded
+ assertEquals("image/png", content.get("mimeType").getAsString());
+ assertTrue("binary content should have encoding field", content.has("encoding"));
+ assertEquals("base64", content.get("encoding").getAsString());
+
+ // Verify the actual base64 text matches the binary body
+ assertTrue("content should have text field", content.has("text"));
+ String base64Text = content.get("text").getAsString();
+ byte[] decoded = Base64.decode(base64Text, Base64.NO_WRAP);
+ assertEquals("decoded length should match binary body", binaryBody.length, decoded.length);
+ for (int i = 0; i < binaryBody.length; i++) {
+ assertEquals("byte " + i + " should match", binaryBody[i], decoded[i]);
+ }
+ }
+
+ @Test
+ public void testTextContentExtraction() throws IOException {
+ ConnectionDescriptor conn = createConnection(14, "10.0.0.10", 80);
+ conn.info = "text.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /data.json HTTP/1.1\r\n" +
+ "Host: text.example.com\r\n" +
+ "\r\n";
+
+ String responseBody = "{\"name\":\"test\",\"value\":123}";
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: application/json\r\n" +
+ "Content-Length: " + responseBody.length() + "\r\n" +
+ "\r\n" +
+ responseBody;
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "text.example.com";
+ httpReq.path = "/data.json";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "application/json";
+ httpReply.bodyLength = responseBody.length();
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+ JsonObject content = response.getAsJsonObject("content");
+
+ // Verify text content is NOT base64 encoded
+ assertEquals("application/json", content.get("mimeType").getAsString());
+ assertFalse("text content should NOT have encoding field", content.has("encoding"));
+
+ // Verify the actual text content matches the response body
+ assertTrue("content should have text field", content.has("text"));
+ String textContent = content.get("text").getAsString();
+ assertEquals("text content should match response body", responseBody, textContent);
+ }
+
+ @Test
+ public void testGzipCompressedContent() throws IOException {
+ ConnectionDescriptor conn = createConnection(15, "10.0.0.11", 80);
+ conn.info = "gzip.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /app.js HTTP/1.1\r\n" +
+ "Host: gzip.example.com\r\n" +
+ "Accept-Encoding: gzip\r\n" +
+ "\r\n";
+
+ // Original JavaScript content
+ String originalJs = "function hello() { console.log('Hello, World!'); }";
+
+ // Gzip compress the JavaScript
+ ByteArrayOutputStream gzipOut = new ByteArrayOutputStream();
+ try (GZIPOutputStream gzos = new GZIPOutputStream(gzipOut)) {
+ gzos.write(originalJs.getBytes(StandardCharsets.UTF_8));
+ }
+ byte[] gzippedBody = gzipOut.toByteArray();
+
+ // Build HTTP response with gzipped content
+ String httpResponseHeaders = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: application/javascript\r\n" +
+ "Content-Encoding: gzip\r\n" +
+ "Content-Length: " + gzippedBody.length + "\r\n" +
+ "\r\n";
+ byte[] headerBytes = httpResponseHeaders.getBytes(StandardCharsets.UTF_8);
+ byte[] fullResponse = new byte[headerBytes.length + gzippedBody.length];
+ System.arraycopy(headerBytes, 0, fullResponse, 0, headerBytes.length);
+ System.arraycopy(gzippedBody, 0, fullResponse, headerBytes.length, gzippedBody.length);
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = new PayloadChunk(fullResponse, PayloadChunk.ChunkType.HTTP, false, respTimestamp, 0);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "gzip.example.com";
+ httpReq.path = "/app.js";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "application/javascript";
+ httpReply.bodyLength = gzippedBody.length;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+ JsonObject content = response.getAsJsonObject("content");
+
+ // Verify content type
+ assertEquals("application/javascript", content.get("mimeType").getAsString());
+
+ // Since the data is now decompressed before being written to HAR,
+ // the content should be plain text (not base64 encoded)
+ assertFalse("decompressed text content should NOT have encoding field", content.has("encoding"));
+
+ // Verify the actual content is the original JavaScript (decompressed)
+ assertTrue("content should have text field", content.has("text"));
+ String textContent = content.get("text").getAsString();
+ assertEquals("content should match original JavaScript", originalJs, textContent);
+ }
+
+ @Test
+ public void testWebSocketMessages() throws IOException {
+ ConnectionDescriptor conn = createConnection(20, "10.0.0.20", 80);
+ conn.info = "ws.example.com";
+ conn.l7proto = "HTTP";
+
+ // HTTP upgrade request/response
+ String httpRequest = "GET /websocket HTTP/1.1\r\n" +
+ "Host: ws.example.com\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Connection: Upgrade\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 101 Switching Protocols\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Connection: Upgrade\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ // Add WebSocket text message (sent)
+ byte[] wsTextPayload = "Hello WebSocket".getBytes(StandardCharsets.UTF_8);
+ PayloadChunk wsSend = new PayloadChunk(wsTextPayload, PayloadChunk.ChunkType.WEBSOCKET, true, respTimestamp + 100, 0);
+ addChunkDirect(conn, wsSend);
+
+ // Add WebSocket text message (received)
+ byte[] wsTextPayload2 = "Hello from server".getBytes(StandardCharsets.UTF_8);
+ PayloadChunk wsRecv = new PayloadChunk(wsTextPayload2, PayloadChunk.ChunkType.WEBSOCKET, false, respTimestamp + 200, 0);
+ addChunkDirect(conn, wsRecv);
+
+ // Add WebSocket binary message
+ byte[] wsBinaryPayload = new byte[] {(byte)0x89, 0x50, 0x4E, 0x47}; // Binary data
+ PayloadChunk wsBinary = new PayloadChunk(wsBinaryPayload, PayloadChunk.ChunkType.WEBSOCKET, false, respTimestamp + 300, 0);
+ addChunkDirect(conn, wsBinary);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "ws.example.com";
+ httpReq.path = "/websocket";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 101;
+ httpReply.responseStatus = "Switching Protocols";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+
+ // Verify WebSocket resource type
+ assertTrue("entry should have _resourceType", entry.has("_resourceType"));
+ assertEquals("websocket", entry.get("_resourceType").getAsString());
+
+ // Verify WebSocket messages array
+ assertTrue("entry should have _webSocketMessages", entry.has("_webSocketMessages"));
+ JsonArray wsMessages = entry.getAsJsonArray("_webSocketMessages");
+ assertEquals(3, wsMessages.size());
+
+ // Verify first message (text, sent)
+ JsonObject msg1 = wsMessages.get(0).getAsJsonObject();
+ assertEquals("send", msg1.get("type").getAsString());
+ assertEquals(1, msg1.get("opcode").getAsInt()); // Text
+ assertEquals("Hello WebSocket", msg1.get("data").getAsString());
+
+ // Verify second message (text, received)
+ JsonObject msg2 = wsMessages.get(1).getAsJsonObject();
+ assertEquals("receive", msg2.get("type").getAsString());
+ assertEquals(1, msg2.get("opcode").getAsInt()); // Text
+ assertEquals("Hello from server", msg2.get("data").getAsString());
+
+ // Verify third message (binary, received)
+ JsonObject msg3 = wsMessages.get(2).getAsJsonObject();
+ assertEquals("receive", msg3.get("type").getAsString());
+ assertEquals(2, msg3.get("opcode").getAsInt()); // Binary
+ // Binary data should be base64 encoded
+ assertNotNull(msg3.get("data").getAsString());
+ }
+
+ @Test
+ public void testWebSocketEmptyPayload() throws IOException {
+ ConnectionDescriptor conn = createConnection(21, "10.0.0.21", 80);
+ conn.info = "ws.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /ws HTTP/1.1\r\n" +
+ "Host: ws.example.com\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 101 Switching Protocols\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ // Add WebSocket message with empty payload
+ PayloadChunk wsEmpty = new PayloadChunk(new byte[0], PayloadChunk.ChunkType.WEBSOCKET, true, respTimestamp + 100, 0);
+ addChunkDirect(conn, wsEmpty);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "ws.example.com";
+ httpReq.path = "/ws";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 101;
+ httpReply.responseStatus = "Switching Protocols";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+
+ JsonArray wsMessages = entry.getAsJsonArray("_webSocketMessages");
+ assertEquals(1, wsMessages.size());
+
+ JsonObject msg = wsMessages.get(0).getAsJsonObject();
+ assertEquals("", msg.get("data").getAsString()); // Empty payload
+ }
+
+ @Test
+ public void testHttpRstHandling() throws IOException {
+ ConnectionDescriptor conn = createConnection(22, "10.0.0.22", 80);
+ conn.info = "rst.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /reset HTTP/1.1\r\n" +
+ "Host: rst.example.com\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/plain\r\n" +
+ "\r\n" +
+ "data";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "rst.example.com";
+ httpReq.path = "/reset";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+ httpReq.httpRst = true; // HTTP RST flag
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "text/plain";
+ httpReply.bodyLength = 4;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+
+ // When httpRst is true, response should be empty like no-response case
+ assertEquals(0, response.get("status").getAsInt());
+ assertEquals("", response.get("statusText").getAsString());
+ assertEquals(-1, response.get("headersSize").getAsInt());
+ assertEquals(-1, response.get("bodySize").getAsInt());
+ assertEquals("", response.get("redirectURL").getAsString());
+ }
+
+ @Test
+ public void testPutAndPatchMethodsWithBody() throws IOException {
+ // Test that PUT and PATCH methods include postData (like POST)
+ String[] methods = {"PUT", "PATCH"};
+ String[] bodies = {"{\"name\":\"updated\"}", "{\"field\":\"new\"}"};
+
+ for (int i = 0; i < methods.length; i++) {
+ httpLog = new HttpLog(); // Reset for each iteration
+
+ ConnectionDescriptor conn = createConnection(23 + i, "10.0.0." + (23 + i), 80);
+ conn.info = "api.example.com";
+ conn.l7proto = "HTTP";
+
+ String body = bodies[i];
+ String httpRequest = methods[i] + " /api/resource/1 HTTP/1.1\r\n" +
+ "Host: api.example.com\r\n" +
+ "Content-Type: application/json\r\n" +
+ "Content-Length: " + body.length() + "\r\n" +
+ "\r\n" +
+ body;
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: application/json\r\n" +
+ "\r\n" +
+ "{}";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 100;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = methods[i];
+ httpReq.host = "api.example.com";
+ httpReq.path = "/api/resource/1";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = body.length();
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "application/json";
+ httpReply.bodyLength = 2;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ HarWriter writer = new HarWriter(context, httpLog);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ writer.write(out);
+ String harJson = out.toString(StandardCharsets.UTF_8.name());
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+
+ assertEquals(methods[i], request.get("method").getAsString());
+ assertTrue(methods[i] + " request should have postData", request.has("postData"));
+ JsonObject postData = request.getAsJsonObject("postData");
+ assertEquals("application/json", postData.get("mimeType").getAsString());
+ assertEquals(body, postData.get("text").getAsString());
+ }
+ }
+
+ @Test
+ public void testNullFieldsFallbacks() throws IOException {
+ // Test that null fields fall back to appropriate defaults
+ ConnectionDescriptor conn = createConnection(25, "10.0.0.25", 80);
+ conn.info = "null.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /path HTTP/1.1\r\n" +
+ "Host: null.example.com\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "\r\n" +
+ "OK";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = null; // Null method
+ httpReq.host = "null.example.com";
+ httpReq.path = "/path";
+ httpReq.query = null; // Null query
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = null; // Null response status
+ httpReply.contentType = null; // Null content type
+ httpReply.bodyLength = 2;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+ JsonObject response = entry.getAsJsonObject("response");
+ JsonObject content = response.getAsJsonObject("content");
+
+ // Verify null fallbacks
+ assertEquals("method should fallback to empty", "", request.get("method").getAsString());
+ assertTrue("null query should result in empty queryString", request.getAsJsonArray("queryString").isEmpty());
+ assertEquals("statusText should fallback to empty", "", response.get("statusText").getAsString());
+ assertEquals("null contentType should fallback to octet-stream", "application/octet-stream", content.get("mimeType").getAsString());
+ }
+
+ @Test
+ public void testMissingChunks() throws IOException {
+ // Test missing request chunk
+ ConnectionDescriptor conn1 = createConnection(26, "10.0.0.26", 80);
+ conn1.info = "nochunk.example.com";
+ conn1.l7proto = "HTTP";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/plain\r\n" +
+ "\r\n" +
+ "OK";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+ addChunkDirect(conn1, respChunk);
+
+ HttpLog.HttpRequest httpReq1 = new HttpLog.HttpRequest(conn1, 0);
+ httpReq1.method = "GET";
+ httpReq1.host = "nochunk.example.com";
+ httpReq1.path = "/path";
+ httpReq1.query = "";
+ httpReq1.timestamp = reqTimestamp;
+ httpReq1.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply1 = new HttpLog.HttpReply(httpReq1, 0);
+ httpReply1.responseCode = 200;
+ httpReply1.responseStatus = "OK";
+ httpReply1.contentType = "text/plain";
+ httpReply1.bodyLength = 2;
+ httpReq1.reply = httpReply1;
+
+ httpLog.addHttpRequest(httpReq1);
+ httpLog.addHttpReply(httpReply1);
+
+ // Test missing response chunk
+ ConnectionDescriptor conn2 = createConnection(29, "10.0.0.29", 80);
+ conn2.info = "missing.example.com";
+ conn2.l7proto = "HTTP";
+
+ String httpRequest = "GET /path HTTP/1.1\r\n" +
+ "Host: missing.example.com\r\n" +
+ "\r\n";
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ addChunkDirect(conn2, reqChunk);
+
+ HttpLog.HttpRequest httpReq2 = new HttpLog.HttpRequest(conn2, 0);
+ httpReq2.method = "GET";
+ httpReq2.host = "missing.example.com";
+ httpReq2.path = "/path";
+ httpReq2.query = "";
+ httpReq2.timestamp = reqTimestamp;
+ httpReq2.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply2 = new HttpLog.HttpReply(httpReq2, 5); // Invalid chunk position
+ httpReply2.responseCode = 200;
+ httpReply2.responseStatus = "OK";
+ httpReply2.contentType = "text/plain";
+ httpReply2.bodyLength = 10;
+ httpReq2.reply = httpReply2;
+
+ httpLog.addHttpRequest(httpReq2);
+ httpLog.addHttpReply(httpReply2);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonArray entries = har.getAsJsonObject("log").getAsJsonArray("entries");
+
+ // Verify missing request chunk defaults
+ JsonObject entry1 = entries.get(0).getAsJsonObject();
+ JsonObject request1 = entry1.getAsJsonObject("request");
+ assertEquals("HTTP/1.1", request1.get("httpVersion").getAsString());
+ assertEquals(-1, request1.get("headersSize").getAsInt());
+ assertTrue(request1.getAsJsonArray("headers").isEmpty());
+
+ // Verify missing response chunk defaults
+ JsonObject entry2 = entries.get(1).getAsJsonObject();
+ JsonObject response2 = entry2.getAsJsonObject("response");
+ assertEquals("HTTP/1.1", response2.get("httpVersion").getAsString());
+ assertEquals(-1, response2.get("headersSize").getAsInt());
+ assertTrue(response2.getAsJsonArray("headers").isEmpty());
+ assertFalse("content should NOT have text when chunk is missing",
+ entry2.getAsJsonObject("response").getAsJsonObject("content").has("text"));
+ }
+
+ @Test
+ public void testRedirectURLNotFound() throws IOException {
+ // Response without Location header should have empty redirectURL
+ ConnectionDescriptor conn = createConnection(30, "10.0.0.30", 80);
+ conn.info = "noredirect.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /path HTTP/1.1\r\n" +
+ "Host: noredirect.example.com\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/plain\r\n" +
+ "\r\n" +
+ "OK";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "noredirect.example.com";
+ httpReq.path = "/path";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "text/plain";
+ httpReply.bodyLength = 2;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject harEntry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = harEntry.getAsJsonObject("response");
+
+ assertEquals("", response.get("redirectURL").getAsString());
+ }
+
+ @Test
+ public void testEmptyResponseBody() throws IOException {
+ ConnectionDescriptor conn = createConnection(31, "10.0.0.31", 80);
+ conn.info = "empty.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /empty HTTP/1.1\r\n" +
+ "Host: empty.example.com\r\n" +
+ "\r\n";
+
+ // Response with no body (just headers)
+ String httpResponse = "HTTP/1.1 204 No Content\r\n" +
+ "Content-Length: 0\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "empty.example.com";
+ httpReq.path = "/empty";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 204;
+ httpReply.responseStatus = "No Content";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+ JsonObject content = response.getAsJsonObject("content");
+
+ // When body is empty, no text or encoding fields should be added
+ assertFalse("content should NOT have text when body is empty", content.has("text"));
+ assertFalse("content should NOT have encoding when body is empty", content.has("encoding"));
+ }
+
+ @Test
+ public void testNullContentTypeInPostData() throws IOException {
+ ConnectionDescriptor conn = createConnection(32, "10.0.0.32", 80);
+ conn.info = "post.example.com";
+ conn.l7proto = "HTTP";
+
+ // POST request without Content-Type header
+ String httpRequest = "POST /submit HTTP/1.1\r\n" +
+ "Host: post.example.com\r\n" +
+ "Content-Length: 11\r\n" +
+ "\r\n" +
+ "raw content";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "POST";
+ httpReq.host = "post.example.com";
+ httpReq.path = "/submit";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 11;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+ JsonObject postData = request.getAsJsonObject("postData");
+
+ assertEquals("", postData.get("mimeType").getAsString()); // Fallback to ""
+ }
+
+ @Test
+ public void testQueryParamWithoutValue() throws IOException {
+ ConnectionDescriptor conn = createConnection(34, "10.0.0.34", 80);
+ conn.info = "query.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /path?flag&key=value&another HTTP/1.1\r\n" +
+ "Host: query.example.com\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "query.example.com";
+ httpReq.path = "/path";
+ httpReq.query = "?flag&key=value&another"; // Params without '='
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+ JsonArray queryString = request.getAsJsonArray("queryString");
+
+ assertEquals(3, queryString.size());
+
+ // First param: flag (no value)
+ JsonObject param1 = queryString.get(0).getAsJsonObject();
+ assertEquals("flag", param1.get("name").getAsString());
+ assertEquals("", param1.get("value").getAsString());
+
+ // Second param: key=value
+ JsonObject param2 = queryString.get(1).getAsJsonObject();
+ assertEquals("key", param2.get("name").getAsString());
+ assertEquals("value", param2.get("value").getAsString());
+
+ // Third param: another (no value)
+ JsonObject param3 = queryString.get(2).getAsJsonObject();
+ assertEquals("another", param3.get("name").getAsString());
+ assertEquals("", param3.get("value").getAsString());
+ }
+
+ @Test
+ public void testMalformedUrlEncoding() throws IOException {
+ ConnectionDescriptor conn = createConnection(35, "10.0.0.35", 80);
+ conn.info = "query.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /path?valid=ok&bad=%ZZ&good=yes HTTP/1.1\r\n" +
+ "Host: query.example.com\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "query.example.com";
+ httpReq.path = "/path";
+ httpReq.query = "?valid=ok&bad=%ZZ&good=yes"; // Invalid %ZZ encoding
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+ JsonArray queryString = request.getAsJsonArray("queryString");
+
+ // Only valid params should be present (malformed one skipped)
+ assertEquals(2, queryString.size());
+ }
+
+ @Test
+ public void testInvalidRequestCookie() throws IOException {
+ ConnectionDescriptor conn = createConnection(36, "10.0.0.36", 80);
+ conn.info = "cookie.example.com";
+ conn.l7proto = "HTTP";
+
+ // Cookie with invalid format (no '=')
+ String httpRequest = "GET /path HTTP/1.1\r\n" +
+ "Host: cookie.example.com\r\n" +
+ "Cookie: valid=value; invalid; another=ok\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "cookie.example.com";
+ httpReq.path = "/path";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+ JsonArray cookies = request.getAsJsonArray("cookies");
+
+ // Only valid cookies should be present
+ assertEquals(2, cookies.size());
+ }
+
+ @Test
+ public void testInvalidSetCookie() throws IOException {
+ ConnectionDescriptor conn = createConnection(37, "10.0.0.37", 80);
+ conn.info = "cookie.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /path HTTP/1.1\r\n" +
+ "Host: cookie.example.com\r\n" +
+ "\r\n";
+
+ // Set-Cookie with invalid format (no '=' in name=value part)
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Set-Cookie: invalidcookie; Path=/\r\n" +
+ "Set-Cookie: valid=ok; Path=/\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "cookie.example.com";
+ httpReq.path = "/path";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+ JsonArray cookies = response.getAsJsonArray("cookies");
+
+ // Only valid cookie should be present
+ assertEquals(1, cookies.size());
+ assertEquals("valid", cookies.get(0).getAsJsonObject().get("name").getAsString());
+ }
+
+ @Test
+ public void testSetCookieAllAttributes() throws IOException {
+ ConnectionDescriptor conn = createConnection(38, "10.0.0.38", 80);
+ conn.info = "cookie.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /path HTTP/1.1\r\n" +
+ "Host: cookie.example.com\r\n" +
+ "\r\n";
+
+ // Set-Cookie with all attributes
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Set-Cookie: session=abc123; Path=/app; Domain=.example.com; HttpOnly; Secure; SameSite=Lax; Expires=Wed, 09 Jun 2027 10:18:14 GMT\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "cookie.example.com";
+ httpReq.path = "/path";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+ JsonArray cookies = response.getAsJsonArray("cookies");
+
+ assertEquals(1, cookies.size());
+ JsonObject cookie = cookies.get(0).getAsJsonObject();
+ assertEquals("session", cookie.get("name").getAsString());
+ assertEquals("abc123", cookie.get("value").getAsString());
+ assertEquals("/app", cookie.get("path").getAsString());
+ assertEquals(".example.com", cookie.get("domain").getAsString());
+ assertTrue(cookie.get("httpOnly").getAsBoolean());
+ assertTrue(cookie.get("secure").getAsBoolean());
+ assertEquals("Lax", cookie.get("sameSite").getAsString());
+ assertEquals("2027-06-09T10:18:14Z", cookie.get("expires").getAsString());
+ }
+
+ @Test
+ public void testEmptyHttpText() throws IOException {
+ ConnectionDescriptor conn = createConnection(39, "10.0.0.39", 80);
+ conn.info = "empty.example.com";
+ conn.l7proto = "HTTP";
+
+ // Empty request chunk payload
+ PayloadChunk reqChunk = new PayloadChunk(new byte[0], PayloadChunk.ChunkType.HTTP, true, System.currentTimeMillis(), 0);
+ PayloadChunk respChunk = new PayloadChunk(new byte[0], PayloadChunk.ChunkType.HTTP, false, System.currentTimeMillis() + 50, 0);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "empty.example.com";
+ httpReq.path = "/path";
+ httpReq.query = "";
+ httpReq.timestamp = System.currentTimeMillis();
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+ JsonObject response = entry.getAsJsonObject("response");
+
+ // With empty payload, defaults should be used
+ assertEquals("HTTP/1.1", request.get("httpVersion").getAsString());
+ assertEquals("HTTP/1.1", response.get("httpVersion").getAsString());
+ }
+
+ @Test
+ public void testMalformedHttpLines() throws IOException {
+ // Test malformed request line (no space/HTTP version)
+ ConnectionDescriptor conn1 = createConnection(40, "10.0.0.40", 80);
+ conn1.info = "malformed.example.com";
+ conn1.l7proto = "HTTP";
+
+ String httpRequest1 = "GET/path\r\n" +
+ "Host: malformed.example.com\r\n" +
+ "\r\n";
+ String httpResponse1 = "HTTP/1.1 200 OK\r\n\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ addChunkDirect(conn1, createHttpChunk(httpRequest1, true, reqTimestamp));
+ addChunkDirect(conn1, createHttpChunk(httpResponse1, false, respTimestamp));
+
+ HttpLog.HttpRequest httpReq1 = new HttpLog.HttpRequest(conn1, 0);
+ httpReq1.method = "GET";
+ httpReq1.host = "malformed.example.com";
+ httpReq1.path = "/path";
+ httpReq1.query = "";
+ httpReq1.timestamp = reqTimestamp;
+ httpReq1.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply1 = new HttpLog.HttpReply(httpReq1, 1);
+ httpReply1.responseCode = 200;
+ httpReply1.responseStatus = "OK";
+ httpReply1.contentType = "";
+ httpReply1.bodyLength = 0;
+ httpReq1.reply = httpReply1;
+
+ httpLog.addHttpRequest(httpReq1);
+ httpLog.addHttpReply(httpReply1);
+
+ // Test malformed response line (no space)
+ ConnectionDescriptor conn2 = createConnection(41, "10.0.0.41", 80);
+ conn2.info = "malformed.example.com";
+ conn2.l7proto = "HTTP";
+
+ String httpRequest2 = "GET /path HTTP/1.1\r\n" +
+ "Host: malformed.example.com\r\n" +
+ "\r\n";
+ String httpResponse2 = "200OK\r\n\r\n";
+
+ addChunkDirect(conn2, createHttpChunk(httpRequest2, true, reqTimestamp));
+ addChunkDirect(conn2, createHttpChunk(httpResponse2, false, respTimestamp));
+
+ HttpLog.HttpRequest httpReq2 = new HttpLog.HttpRequest(conn2, 0);
+ httpReq2.method = "GET";
+ httpReq2.host = "malformed.example.com";
+ httpReq2.path = "/path";
+ httpReq2.query = "";
+ httpReq2.timestamp = reqTimestamp;
+ httpReq2.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply2 = new HttpLog.HttpReply(httpReq2, 1);
+ httpReply2.responseCode = 200;
+ httpReply2.responseStatus = "OK";
+ httpReply2.contentType = "";
+ httpReply2.bodyLength = 0;
+ httpReq2.reply = httpReply2;
+
+ httpLog.addHttpRequest(httpReq2);
+ httpLog.addHttpReply(httpReply2);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonArray entries = har.getAsJsonObject("log").getAsJsonArray("entries");
+
+ // Both should default to HTTP/1.1 for malformed lines
+ JsonObject entry1 = entries.get(0).getAsJsonObject();
+ assertEquals("HTTP/1.1", entry1.getAsJsonObject("request").get("httpVersion").getAsString());
+
+ JsonObject entry2 = entries.get(1).getAsJsonObject();
+ assertEquals("HTTP/1.1", entry2.getAsJsonObject("response").get("httpVersion").getAsString());
+ }
+
+ @Test
+ public void testInvalidHeaders() throws IOException {
+ ConnectionDescriptor conn = createConnection(42, "10.0.0.42", 80);
+ conn.info = "header.example.com";
+ conn.l7proto = "HTTP";
+
+ // Request with invalid headers (missing colon, colon at position 0)
+ String httpRequest = "GET /path HTTP/1.1\r\n" +
+ "Valid-Header: value\r\n" +
+ "InvalidHeaderNoColon\r\n" +
+ ":InvalidColonAtStart\r\n" +
+ "Another-Valid: ok\r\n" +
+ "\r\n";
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "header.example.com";
+ httpReq.path = "/path";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+ JsonArray headers = request.getAsJsonArray("headers");
+
+ // Only valid headers should be present (invalid ones skipped)
+ assertEquals(2, headers.size());
+
+ boolean hasValid = false, hasAnother = false;
+ for (int i = 0; i < headers.size(); i++) {
+ String name = headers.get(i).getAsJsonObject().get("name").getAsString();
+ if (name.equals("Valid-Header")) hasValid = true;
+ if (name.equals("Another-Valid")) hasAnother = true;
+ }
+ assertTrue("Should have Valid-Header", hasValid);
+ assertTrue("Should have Another-Valid", hasAnother);
+ }
+
+ @Test
+ public void testXmlContentType() throws IOException {
+ ConnectionDescriptor conn = createConnection(43, "10.0.0.43", 80);
+ conn.info = "xml.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /data.xml HTTP/1.1\r\n" +
+ "Host: xml.example.com\r\n" +
+ "\r\n";
+
+ String xmlBody = "- value
";
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: application/xml\r\n" +
+ "\r\n" +
+ xmlBody;
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "xml.example.com";
+ httpReq.path = "/data.xml";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "application/xml";
+ httpReply.bodyLength = xmlBody.length();
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+ JsonObject content = response.getAsJsonObject("content");
+
+ // XML is text content, should not be base64 encoded
+ assertFalse("xml content should NOT have encoding", content.has("encoding"));
+ assertEquals(xmlBody, content.get("text").getAsString());
+ }
+
+ @Test
+ public void testBinaryContentTypesAreBase64Encoded() throws IOException {
+ // Test that audio, video, and octet-stream content types result in base64 encoding
+ String[] contentTypes = {"audio/mpeg", "video/mp4", "application/octet-stream"};
+ byte[] binaryData = new byte[] {0x01, 0x02, 0x03, 0x04};
+
+ for (int i = 0; i < contentTypes.length; i++) {
+ httpLog = new HttpLog(); // Reset for each iteration
+
+ ConnectionDescriptor conn = createConnection(44 + i, "10.0.0." + (44 + i), 80);
+ conn.info = "binary.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /file HTTP/1.1\r\n" +
+ "Host: binary.example.com\r\n" +
+ "\r\n";
+
+ String httpResponseHeaders = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: " + contentTypes[i] + "\r\n" +
+ "\r\n";
+ byte[] headerBytes = httpResponseHeaders.getBytes(StandardCharsets.UTF_8);
+ byte[] fullResponse = new byte[headerBytes.length + binaryData.length];
+ System.arraycopy(headerBytes, 0, fullResponse, 0, headerBytes.length);
+ System.arraycopy(binaryData, 0, fullResponse, headerBytes.length, binaryData.length);
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = new PayloadChunk(fullResponse, PayloadChunk.ChunkType.HTTP, false, respTimestamp, 0);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "binary.example.com";
+ httpReq.path = "/file";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = contentTypes[i];
+ httpReply.bodyLength = binaryData.length;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ HarWriter writer = new HarWriter(context, httpLog);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ writer.write(out);
+ String harJson = out.toString(StandardCharsets.UTF_8.name());
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+ JsonObject content = response.getAsJsonObject("content");
+
+ assertTrue(contentTypes[i] + " content should have encoding", content.has("encoding"));
+ assertEquals("base64", content.get("encoding").getAsString());
+ }
+ }
+
+ @Test
+ public void testByteBasedContentDetection() throws IOException {
+ // Test text detection: printable bytes with null content type
+ ConnectionDescriptor conn1 = createConnection(47, "10.0.0.47", 80);
+ conn1.info = "detect.example.com";
+ conn1.l7proto = "HTTP";
+
+ String textBody = "This is plain text content";
+ String httpResponse1 = "HTTP/1.1 200 OK\r\n\r\n" + textBody;
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ String httpRequest = "GET /data HTTP/1.1\r\n" +
+ "Host: detect.example.com\r\n" +
+ "\r\n";
+
+ addChunkDirect(conn1, createHttpChunk(httpRequest, true, reqTimestamp));
+ addChunkDirect(conn1, createHttpChunk(httpResponse1, false, respTimestamp));
+
+ HttpLog.HttpRequest httpReq1 = new HttpLog.HttpRequest(conn1, 0);
+ httpReq1.method = "GET";
+ httpReq1.host = "detect.example.com";
+ httpReq1.path = "/data";
+ httpReq1.query = "";
+ httpReq1.timestamp = reqTimestamp;
+ httpReq1.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply1 = new HttpLog.HttpReply(httpReq1, 1);
+ httpReply1.responseCode = 200;
+ httpReply1.responseStatus = "OK";
+ httpReply1.contentType = null; // Forces byte-based detection
+ httpReply1.bodyLength = textBody.length();
+ httpReq1.reply = httpReply1;
+
+ httpLog.addHttpRequest(httpReq1);
+ httpLog.addHttpReply(httpReply1);
+
+ // Test binary detection: non-printable bytes with null content type
+ ConnectionDescriptor conn2 = createConnection(48, "10.0.0.48", 80);
+ conn2.info = "detect.example.com";
+ conn2.l7proto = "HTTP";
+
+ byte[] binaryData = new byte[] {(byte)0x00, (byte)0x01, (byte)0xFF, (byte)0xFE};
+ String httpResponseHeaders = "HTTP/1.1 200 OK\r\n\r\n";
+ byte[] headerBytes = httpResponseHeaders.getBytes(StandardCharsets.UTF_8);
+ byte[] fullResponse = new byte[headerBytes.length + binaryData.length];
+ System.arraycopy(headerBytes, 0, fullResponse, 0, headerBytes.length);
+ System.arraycopy(binaryData, 0, fullResponse, headerBytes.length, binaryData.length);
+
+ addChunkDirect(conn2, createHttpChunk(httpRequest, true, reqTimestamp));
+ addChunkDirect(conn2, new PayloadChunk(fullResponse, PayloadChunk.ChunkType.HTTP, false, respTimestamp, 0));
+
+ HttpLog.HttpRequest httpReq2 = new HttpLog.HttpRequest(conn2, 0);
+ httpReq2.method = "GET";
+ httpReq2.host = "detect.example.com";
+ httpReq2.path = "/data";
+ httpReq2.query = "";
+ httpReq2.timestamp = reqTimestamp;
+ httpReq2.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply2 = new HttpLog.HttpReply(httpReq2, 1);
+ httpReply2.responseCode = 200;
+ httpReply2.responseStatus = "OK";
+ httpReply2.contentType = null; // Forces byte-based detection
+ httpReply2.bodyLength = binaryData.length;
+ httpReq2.reply = httpReply2;
+
+ httpLog.addHttpRequest(httpReq2);
+ httpLog.addHttpReply(httpReply2);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonArray entries = har.getAsJsonObject("log").getAsJsonArray("entries");
+
+ // Verify text detection (first entry)
+ JsonObject content1 = entries.get(0).getAsJsonObject().getAsJsonObject("response").getAsJsonObject("content");
+ assertFalse("text-detected content should NOT have encoding", content1.has("encoding"));
+ assertEquals(textBody, content1.get("text").getAsString());
+
+ // Verify binary detection (second entry)
+ JsonObject content2 = entries.get(1).getAsJsonObject().getAsJsonObject("response").getAsJsonObject("content");
+ assertTrue("binary-detected content should have encoding", content2.has("encoding"));
+ assertEquals("base64", content2.get("encoding").getAsString());
+ }
+
+ @Test
+ public void testEntryTimeWithMissingReplyChunk() throws IOException {
+ ConnectionDescriptor conn = createConnection(50, "10.0.0.50", 80);
+ conn.info = "time.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /path HTTP/1.1\r\n" +
+ "Host: time.example.com\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ addChunkDirect(conn, reqChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "time.example.com";
+ httpReq.path = "/path";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 10); // Invalid chunk position
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+
+ // With missing reply chunk, time should be -1
+ assertEquals(-1, entry.get("time").getAsInt());
+ }
+
+ @Test
+ public void testPostWithMissingChunk() throws IOException {
+ ConnectionDescriptor conn = createConnection(51, "10.0.0.51", 80);
+ conn.info = "post.example.com";
+ conn.l7proto = "HTTP";
+
+ // Don't add request chunk
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "\r\n";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 5); // Invalid chunk position
+ httpReq.method = "POST";
+ httpReq.host = "post.example.com";
+ httpReq.path = "/submit";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 100;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 0);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "";
+ httpReply.bodyLength = 0;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+
+ // POST should have postData even with missing chunk
+ assertTrue("POST should have postData", request.has("postData"));
+ JsonObject postData = request.getAsJsonObject("postData");
+ assertEquals("", postData.get("mimeType").getAsString());
+ // text field should not be present when chunk is missing
+ assertFalse("postData should NOT have text when chunk is missing", postData.has("text"));
+ }
+
+ @Test
+ public void testMimeTypeWithCharset() throws IOException {
+ ConnectionDescriptor conn = createConnection(53, "10.0.0.53", 80);
+ conn.info = "charset.example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /page HTTP/1.1\r\n" +
+ "Host: charset.example.com\r\n" +
+ "\r\n";
+
+ String htmlBody = "Hello";
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/html; charset=utf-8\r\n" +
+ "\r\n" +
+ htmlBody;
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "charset.example.com";
+ httpReq.path = "/page";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "text/html; charset=utf-8"; // With charset
+ httpReply.bodyLength = htmlBody.length();
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject response = entry.getAsJsonObject("response");
+ JsonObject content = response.getAsJsonObject("content");
+
+ // MimeType should include the full content-type with charset
+ assertEquals("text/html; charset=utf-8", content.get("mimeType").getAsString());
+
+ // Content should be detected as text (not base64)
+ assertFalse("text/html with charset should NOT have encoding", content.has("encoding"));
+ assertEquals(htmlBody, content.get("text").getAsString());
+ }
+
+ @Test
+ public void testPostDataMimeTypeWithCharset() throws IOException {
+ ConnectionDescriptor conn = createConnection(54, "10.0.0.54", 80);
+ conn.info = "form.example.com";
+ conn.l7proto = "HTTP";
+
+ String postBody = "name=test&value=123";
+ String httpRequest = "POST /submit HTTP/1.1\r\n" +
+ "Host: form.example.com\r\n" +
+ "Content-Type: application/x-www-form-urlencoded; charset=utf-8\r\n" +
+ "Content-Length: " + postBody.length() + "\r\n" +
+ "\r\n" +
+ postBody;
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: application/json\r\n" +
+ "\r\n" +
+ "{}";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "POST";
+ httpReq.host = "form.example.com";
+ httpReq.path = "/submit";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = postBody.length();
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "application/json";
+ httpReply.bodyLength = 2;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+ JsonObject postData = request.getAsJsonObject("postData");
+
+ // PostData mimeType should include the full content-type with charset
+ assertEquals("application/x-www-form-urlencoded; charset=utf-8", postData.get("mimeType").getAsString());
+ assertEquals(postBody, postData.get("text").getAsString());
+ }
+
+ @Test
+ public void testMimeTypeCaseInsensitive() throws IOException {
+ ConnectionDescriptor conn = createConnection(55, "10.0.0.55", 80);
+ conn.info = "case.example.com";
+ conn.l7proto = "HTTP";
+
+ String jsonBody = "{\"test\": true}";
+ String httpRequest = "POST /api HTTP/1.1\r\n" +
+ "Host: case.example.com\r\n" +
+ "CONTENT-TYPE: APPLICATION/JSON\r\n" + // Uppercase header
+ "Content-Length: " + jsonBody.length() + "\r\n" +
+ "\r\n" +
+ jsonBody;
+
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "content-type: TEXT/PLAIN\r\n" + // Lowercase header name, uppercase value
+ "\r\n" +
+ "OK";
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 50;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "POST";
+ httpReq.host = "case.example.com";
+ httpReq.path = "/api";
+ httpReq.query = "";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = jsonBody.length();
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "TEXT/PLAIN"; // Uppercase
+ httpReply.bodyLength = 2;
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+ JsonObject har = parseHar(harJson);
+ JsonObject entry = har.getAsJsonObject("log").getAsJsonArray("entries").get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+ JsonObject response = entry.getAsJsonObject("response");
+
+ // PostData mimeType should be found with case-insensitive header lookup
+ JsonObject postData = request.getAsJsonObject("postData");
+ assertEquals("APPLICATION/JSON", postData.get("mimeType").getAsString());
+
+ // Response mimeType
+ JsonObject content = response.getAsJsonObject("content");
+ assertEquals("TEXT/PLAIN", content.get("mimeType").getAsString());
+ }
+
+ @Test
+ public void testHtmlEntitiesNotEscaped() throws IOException {
+ // Create a connection with HTML content containing special characters
+ ConnectionDescriptor conn = createConnection(1, "93.184.216.34", 80);
+ conn.info = "example.com";
+ conn.l7proto = "HTTP";
+
+ String httpRequest = "GET /page?foo=1&bar=2 HTTP/1.1\r\n" +
+ "Host: example.com\r\n" +
+ "\r\n";
+
+ String htmlBody = "Hello & goodbye";
+ String httpResponse = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/html\r\n" +
+ "Content-Length: " + htmlBody.length() + "\r\n" +
+ "\r\n" +
+ htmlBody;
+
+ long reqTimestamp = System.currentTimeMillis();
+ long respTimestamp = reqTimestamp + 100;
+
+ PayloadChunk reqChunk = createHttpChunk(httpRequest, true, reqTimestamp);
+ PayloadChunk respChunk = createHttpChunk(httpResponse, false, respTimestamp);
+
+ addChunkDirect(conn, reqChunk);
+ addChunkDirect(conn, respChunk);
+
+ HttpLog.HttpRequest httpReq = new HttpLog.HttpRequest(conn, 0);
+ httpReq.method = "GET";
+ httpReq.host = "example.com";
+ httpReq.path = "/page";
+ httpReq.query = "foo=1&bar=2";
+ httpReq.timestamp = reqTimestamp;
+ httpReq.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply = new HttpLog.HttpReply(httpReq, 1);
+ httpReply.responseCode = 200;
+ httpReply.responseStatus = "OK";
+ httpReply.contentType = "text/html";
+ httpReply.bodyLength = htmlBody.length();
+ httpReq.reply = httpReply;
+
+ httpLog.addHttpRequest(httpReq);
+ httpLog.addHttpReply(httpReply);
+
+ String harJson = writeHarToString();
+
+ // Verify that HTML entities are NOT escaped (disableHtmlEscaping is working)
+ // Without disableHtmlEscaping, Gson would convert:
+ // < to \u003c
+ // > to \u003e
+ // & to \u0026
+ // = to \u003d
+ assertFalse("HTML entities should not be escaped: \\u003c found",
+ harJson.contains("\\u003c"));
+ assertFalse("HTML entities should not be escaped: \\u003e found",
+ harJson.contains("\\u003e"));
+ assertFalse("HTML entities should not be escaped: \\u0026 found",
+ harJson.contains("\\u0026"));
+ assertFalse("HTML entities should not be escaped: \\u003d found",
+ harJson.contains("\\u003d"));
+
+ // Verify the actual characters are present
+ assertTrue("Should contain literal < character", harJson.contains(""));
+ assertTrue("Should contain literal > character", harJson.contains(""));
+ assertTrue("Should contain literal & character", harJson.contains("&bar"));
+ assertTrue("Should contain literal = in query", harJson.contains("foo=1"));
+ }
+
+ @Test
+ public void testSingleRequestExport() throws IOException {
+ // Create two HTTP requests to verify that only the specified one is exported
+ ConnectionDescriptor conn1 = createConnection(1, "93.184.216.34", 80);
+ conn1.info = "example1.com";
+ conn1.l7proto = "HTTP";
+
+ ConnectionDescriptor conn2 = createConnection(2, "93.184.216.35", 80);
+ conn2.info = "example2.com";
+ conn2.l7proto = "HTTP";
+
+ String httpRequest1 = "GET /first HTTP/1.1\r\n" +
+ "Host: example1.com\r\n" +
+ "\r\n";
+
+ String httpResponse1 = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/plain\r\n" +
+ "\r\n" +
+ "First response";
+
+ String httpRequest2 = "GET /second HTTP/1.1\r\n" +
+ "Host: example2.com\r\n" +
+ "\r\n";
+
+ String httpResponse2 = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/plain\r\n" +
+ "\r\n" +
+ "Second response";
+
+ long reqTimestamp1 = System.currentTimeMillis();
+ long respTimestamp1 = reqTimestamp1 + 50;
+ long reqTimestamp2 = reqTimestamp1 + 100;
+ long respTimestamp2 = reqTimestamp2 + 50;
+
+ // Add chunks for first request
+ PayloadChunk reqChunk1 = createHttpChunk(httpRequest1, true, reqTimestamp1);
+ PayloadChunk respChunk1 = createHttpChunk(httpResponse1, false, respTimestamp1);
+ addChunkDirect(conn1, reqChunk1);
+ addChunkDirect(conn1, respChunk1);
+
+ // Add chunks for second request
+ PayloadChunk reqChunk2 = createHttpChunk(httpRequest2, true, reqTimestamp2);
+ PayloadChunk respChunk2 = createHttpChunk(httpResponse2, false, respTimestamp2);
+ addChunkDirect(conn2, reqChunk2);
+ addChunkDirect(conn2, respChunk2);
+
+ // Create first HTTP request
+ HttpLog.HttpRequest httpReq1 = new HttpLog.HttpRequest(conn1, 0);
+ httpReq1.method = "GET";
+ httpReq1.host = "example1.com";
+ httpReq1.path = "/first";
+ httpReq1.query = "";
+ httpReq1.timestamp = reqTimestamp1;
+ httpReq1.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply1 = new HttpLog.HttpReply(httpReq1, 1);
+ httpReply1.responseCode = 200;
+ httpReply1.responseStatus = "OK";
+ httpReply1.contentType = "text/plain";
+ httpReply1.bodyLength = 14;
+ httpReq1.reply = httpReply1;
+
+ // Create second HTTP request
+ HttpLog.HttpRequest httpReq2 = new HttpLog.HttpRequest(conn2, 0);
+ httpReq2.method = "GET";
+ httpReq2.host = "example2.com";
+ httpReq2.path = "/second";
+ httpReq2.query = "";
+ httpReq2.timestamp = reqTimestamp2;
+ httpReq2.bodyLength = 0;
+
+ HttpLog.HttpReply httpReply2 = new HttpLog.HttpReply(httpReq2, 1);
+ httpReply2.responseCode = 200;
+ httpReply2.responseStatus = "OK";
+ httpReply2.contentType = "text/plain";
+ httpReply2.bodyLength = 15;
+ httpReq2.reply = httpReply2;
+
+ // Add both requests to the HTTP log
+ httpLog.addHttpRequest(httpReq1);
+ httpLog.addHttpReply(httpReply1);
+ httpLog.addHttpRequest(httpReq2);
+ httpLog.addHttpReply(httpReply2);
+
+ // Export only the second request using single request constructor
+ HarWriter singleWriter = new HarWriter(context, httpReq2);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ singleWriter.write(out);
+ String harJson = out.toString(StandardCharsets.UTF_8.name());
+ JsonObject har = parseHar(harJson);
+
+ // Verify basic structure
+ assertTrue("HAR should have 'log' object", har.has("log"));
+ JsonObject log = har.getAsJsonObject("log");
+ assertEquals("1.2", log.get("version").getAsString());
+
+ // Verify only one entry is present
+ JsonArray entries = log.getAsJsonArray("entries");
+ assertEquals("Should have exactly one entry", 1, entries.size());
+
+ // Verify it's the second request
+ JsonObject entry = entries.get(0).getAsJsonObject();
+ JsonObject request = entry.getAsJsonObject("request");
+ String url = request.get("url").getAsString();
+ assertTrue("URL should contain example2.com", url.contains("example2.com"));
+ assertTrue("URL should contain /second", url.contains("/second"));
+ assertFalse("URL should NOT contain example1.com", url.contains("example1.com"));
+ assertFalse("URL should NOT contain /first", url.contains("/first"));
+
+ // Verify response content
+ JsonObject response = entry.getAsJsonObject("response");
+ assertEquals(200, response.get("status").getAsInt());
+ JsonObject content = response.getAsJsonObject("content");
+ assertEquals("text/plain", content.get("mimeType").getAsString());
+ assertEquals("Second response", content.get("text").getAsString());
+
+ // Verify the full log still has both requests
+ String fullHarJson = writeHarToString();
+ JsonObject fullHar = parseHar(fullHarJson);
+ JsonArray fullEntries = fullHar.getAsJsonObject("log").getAsJsonArray("entries");
+ assertEquals("Full log should have 2 entries", 2, fullEntries.size());
+ }
+}
diff --git a/app/src/test/java/com/emanuelef/remote_capture/UtilsTest.java b/app/src/test/java/com/emanuelef/remote_capture/UtilsTest.java
index b53d967f..9e571ea8 100644
--- a/app/src/test/java/com/emanuelef/remote_capture/UtilsTest.java
+++ b/app/src/test/java/com/emanuelef/remote_capture/UtilsTest.java
@@ -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;
+ }
}
\ No newline at end of file