mirror of
https://github.com/emanuele-f/PCAPdroid.git
synced 2026-06-05 21:04:28 +08:00
Implement HAR export of HTTP and WebSocket data
Implement the ability to export the HTTP log/a single HTTP request to the HAR 1.2 format. The export happens in a separate thread to avoid blocking the UI, with the ability to cancel. WebSocket data is exported in the Chrome DevTools extension format. See #184
This commit is contained in:
parent
de3f604422
commit
9d821b7931
@ -14,7 +14,7 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Copyright 2022 - Emanuele Faranda
|
||||
* Copyright 2022-26 - Emanuele Faranda
|
||||
*/
|
||||
|
||||
package com.emanuelef.remote_capture;
|
||||
@ -163,6 +163,7 @@ public class HTTPReassembly {
|
||||
|
||||
if ((first_space > 0) && (second_space > 0)) {
|
||||
mFirstChunk.httpMethod = line.substring(0, first_space).toUpperCase();
|
||||
mFirstChunk.httpVersion = line.substring(second_space + 1);
|
||||
String path = line.substring(first_space + 1, second_space);
|
||||
|
||||
if (!path.startsWith("/")) {
|
||||
@ -192,6 +193,8 @@ public class HTTPReassembly {
|
||||
} else if (line.startsWith("HTTP/")) {
|
||||
int first_space = line.indexOf(' ');
|
||||
if (first_space > 0) {
|
||||
mFirstChunk.httpVersion = line.substring(0, first_space);
|
||||
|
||||
try {
|
||||
// NOTE: the response status may be missing when the response is reconstructed by the ushark HTTP2 reassembly
|
||||
int second_space = line.indexOf(' ', first_space + 1);
|
||||
@ -212,6 +215,7 @@ public class HTTPReassembly {
|
||||
|
||||
if(line.startsWith("content-encoding: ")) {
|
||||
String contentEncoding = line.substring(18);
|
||||
|
||||
log_d("Content-Encoding: " + contentEncoding);
|
||||
|
||||
switch (contentEncoding) {
|
||||
@ -356,7 +360,6 @@ public class HTTPReassembly {
|
||||
|
||||
//log_d("mContentLength=" + mContentLength + ", mReassembleChunks=" + mReassembleChunks + ", mChunkedEncoding=" + mChunkedEncoding);
|
||||
|
||||
// Decode body
|
||||
if ((body != null) && (mContentEncoding != ContentEncoding.UNKNOWN))
|
||||
decodeBody(body);
|
||||
|
||||
@ -382,6 +385,7 @@ public class HTTPReassembly {
|
||||
to_add.httpHost = mFirstChunk.httpHost;
|
||||
to_add.httpPath = mFirstChunk.httpPath;
|
||||
to_add.httpQuery = mFirstChunk.httpQuery;
|
||||
to_add.httpVersion = mFirstChunk.httpVersion;
|
||||
to_add.httpBodyLength = mBodySize;
|
||||
|
||||
if (httpRst)
|
||||
|
||||
620
app/src/main/java/com/emanuelef/remote_capture/HarWriter.java
Normal file
620
app/src/main/java/com/emanuelef/remote_capture/HarWriter.java
Normal file
@ -0,0 +1,620 @@
|
||||
/*
|
||||
* This file is part of PCAPdroid.
|
||||
*
|
||||
* PCAPdroid is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* PCAPdroid is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Copyright 2026 - Emanuele Faranda
|
||||
*/
|
||||
|
||||
package com.emanuelef.remote_capture;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Base64;
|
||||
|
||||
import com.emanuelef.remote_capture.model.ConnectionDescriptor;
|
||||
import com.emanuelef.remote_capture.model.PayloadChunk;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Writes HTTP log data to HAR 1.2 format using streaming output.
|
||||
* See: http://www.softwareishard.com/blog/har-12-spec/
|
||||
*/
|
||||
public class HarWriter {
|
||||
private static final String TAG = "HarWriter";
|
||||
private final Context mContext;
|
||||
private final HttpLog mHttpLog;
|
||||
private final HttpLog.HttpRequest mSingleRequest;
|
||||
|
||||
public HarWriter(Context context, HttpLog httpLog) {
|
||||
mContext = context;
|
||||
mHttpLog = httpLog;
|
||||
mSingleRequest = null;
|
||||
}
|
||||
|
||||
public HarWriter(Context context, HttpLog.HttpRequest request) {
|
||||
mContext = context;
|
||||
mHttpLog = null;
|
||||
mSingleRequest = request;
|
||||
}
|
||||
|
||||
public void write(OutputStream out) throws IOException {
|
||||
JsonWriter writer = new JsonWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
|
||||
writer.setIndent(" ");
|
||||
|
||||
writer.beginObject();
|
||||
writer.name("log");
|
||||
writeLog(writer);
|
||||
writer.endObject();
|
||||
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
private void writeLog(JsonWriter writer) throws IOException {
|
||||
writer.beginObject();
|
||||
|
||||
writer.name("version").value("1.2");
|
||||
|
||||
writer.name("creator");
|
||||
writeCreator(writer);
|
||||
|
||||
writer.name("entries");
|
||||
writeEntries(writer);
|
||||
|
||||
writer.endObject();
|
||||
}
|
||||
|
||||
private void writeCreator(JsonWriter writer) throws IOException {
|
||||
writer.beginObject();
|
||||
writer.name("name").value("PCAPdroid");
|
||||
writer.name("version").value(Utils.getAppVersion(mContext));
|
||||
writer.endObject();
|
||||
}
|
||||
|
||||
private void writeEntries(JsonWriter writer) throws IOException {
|
||||
writer.beginArray();
|
||||
|
||||
if (mSingleRequest != null) {
|
||||
try {
|
||||
writeEntry(writer, mSingleRequest);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to serialize single entry: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
} else if (mHttpLog != null) {
|
||||
int size = mHttpLog.getSize();
|
||||
|
||||
// NOTE: don't synchronize on the HTTP log, to avoid blocking the UI
|
||||
for (int i = 0; i < size; i++) {
|
||||
if (Thread.interrupted())
|
||||
throw new InterruptedIOException("Export cancelled");
|
||||
|
||||
HttpLog.HttpRequest req = mHttpLog.getRequest(i);
|
||||
if (req == null)
|
||||
continue;
|
||||
|
||||
try {
|
||||
writeEntry(writer, req);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to serialize entry " + i + ": " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writer.endArray();
|
||||
}
|
||||
|
||||
private void writeEntry(JsonWriter writer, HttpLog.HttpRequest req) throws IOException {
|
||||
ConnectionDescriptor conn = req.conn;
|
||||
|
||||
writer.beginObject();
|
||||
writer.name("startedDateTime").value(Utils.formatMillisIso8601(mContext, req.timestamp));
|
||||
|
||||
// time (total elapsed time in ms)
|
||||
long time = -1;
|
||||
if (req.reply != null) {
|
||||
PayloadChunk replyChunk = conn.getHttpResponseChunk(req.reply.firstChunkPos);
|
||||
if (replyChunk != null)
|
||||
time = replyChunk.timestamp - req.timestamp;
|
||||
}
|
||||
writer.name("time").value(time);
|
||||
|
||||
writer.name("serverIPAddress").value(conn.dst_ip);
|
||||
writer.name("connection").value(String.valueOf(conn.incr_id));
|
||||
|
||||
writer.name("request");
|
||||
writeRequest(writer, req);
|
||||
|
||||
writer.name("response");
|
||||
writeResponse(writer, req);
|
||||
|
||||
// cache (required, but empty as we don't track caching)
|
||||
writer.name("cache");
|
||||
writer.beginObject();
|
||||
writer.endObject();
|
||||
|
||||
writer.name("timings");
|
||||
writeTimings(writer);
|
||||
|
||||
writeWebSocketMessages(writer, conn, req);
|
||||
|
||||
writer.endObject();
|
||||
}
|
||||
|
||||
private void writeRequest(JsonWriter writer, HttpLog.HttpRequest req) throws IOException {
|
||||
ConnectionDescriptor conn = req.conn;
|
||||
|
||||
writer.beginObject();
|
||||
writer.name("method").value(req.method != null ? req.method : "");
|
||||
writer.name("url").value(req.getUrl());
|
||||
|
||||
PayloadChunk reqChunk = conn.getHttpRequestChunk(req.firstChunkPos);
|
||||
String httpVersion = "HTTP/1.1";
|
||||
List<String[]> requestHeaders = new ArrayList<>();
|
||||
int headersSize = -1;
|
||||
|
||||
if (reqChunk != null) {
|
||||
if (!reqChunk.httpVersion.isEmpty())
|
||||
httpVersion = reqChunk.httpVersion;
|
||||
|
||||
if (reqChunk.payload != null) {
|
||||
String httpText = new String(reqChunk.payload, StandardCharsets.UTF_8);
|
||||
requestHeaders = parseHeaders(httpText);
|
||||
headersSize = getHeadersSize(reqChunk.payload);
|
||||
}
|
||||
}
|
||||
|
||||
writer.name("httpVersion").value(httpVersion);
|
||||
|
||||
writer.name("cookies");
|
||||
writeRequestCookies(writer, requestHeaders);
|
||||
|
||||
writer.name("headers");
|
||||
writeHeaders(writer, requestHeaders);
|
||||
|
||||
writer.name("queryString");
|
||||
writeQueryString(writer, req.query);
|
||||
|
||||
if ((req.method != null) && (req.method.equals("POST") || req.method.equals("PUT") || req.method.equals("PATCH"))) {
|
||||
writer.name("postData");
|
||||
writePostData(writer, reqChunk, requestHeaders);
|
||||
}
|
||||
|
||||
writer.name("headersSize").value(headersSize);
|
||||
writer.name("bodySize").value(req.bodyLength);
|
||||
writer.endObject();
|
||||
}
|
||||
|
||||
private void writeResponse(JsonWriter writer, HttpLog.HttpRequest req) throws IOException {
|
||||
HttpLog.HttpReply reply = req.reply;
|
||||
ConnectionDescriptor conn = req.conn;
|
||||
|
||||
writer.beginObject();
|
||||
|
||||
if ((reply == null) || req.httpRst) {
|
||||
// No response available - return minimal response object
|
||||
writer.name("status").value(0);
|
||||
writer.name("statusText").value("");
|
||||
writer.name("httpVersion").value("");
|
||||
writer.name("cookies");
|
||||
writer.beginArray();
|
||||
writer.endArray();
|
||||
writer.name("headers");
|
||||
writer.beginArray();
|
||||
writer.endArray();
|
||||
writer.name("content");
|
||||
writer.beginObject();
|
||||
writer.endObject();
|
||||
writer.name("redirectURL").value("");
|
||||
writer.name("headersSize").value(-1);
|
||||
writer.name("bodySize").value(-1);
|
||||
writer.endObject();
|
||||
return;
|
||||
}
|
||||
|
||||
writer.name("status").value(reply.responseCode);
|
||||
writer.name("statusText").value(reply.responseStatus != null ? reply.responseStatus : "");
|
||||
|
||||
PayloadChunk respChunk = conn.getHttpResponseChunk(reply.firstChunkPos);
|
||||
String httpVersion = "HTTP/1.1";
|
||||
List<String[]> responseHeaders = new ArrayList<>();
|
||||
int headersSize = -1;
|
||||
|
||||
if (respChunk != null) {
|
||||
if (!respChunk.httpVersion.isEmpty())
|
||||
httpVersion = respChunk.httpVersion;
|
||||
|
||||
if (respChunk.payload != null) {
|
||||
String httpText = new String(respChunk.payload, StandardCharsets.UTF_8);
|
||||
responseHeaders = parseHeaders(httpText);
|
||||
headersSize = getHeadersSize(respChunk.payload);
|
||||
}
|
||||
}
|
||||
|
||||
writer.name("httpVersion").value(httpVersion);
|
||||
|
||||
writer.name("cookies");
|
||||
writeResponseCookies(writer, responseHeaders);
|
||||
|
||||
writer.name("headers");
|
||||
writeHeaders(writer, responseHeaders);
|
||||
|
||||
writer.name("content");
|
||||
writeContent(writer, reply, respChunk);
|
||||
|
||||
String redirectURL = getHeaderValue(responseHeaders, "location");
|
||||
writer.name("redirectURL").value(redirectURL != null ? redirectURL : "");
|
||||
writer.name("headersSize").value(headersSize);
|
||||
writer.name("bodySize").value(reply.bodyLength);
|
||||
writer.endObject();
|
||||
}
|
||||
|
||||
private void writeContent(JsonWriter writer, HttpLog.HttpReply reply, PayloadChunk respChunk) throws IOException {
|
||||
writer.beginObject();
|
||||
writer.name("size").value(reply.bodyLength);
|
||||
|
||||
String mimeType = reply.contentType != null ? reply.contentType : "application/octet-stream";
|
||||
writer.name("mimeType").value(mimeType);
|
||||
|
||||
if ((respChunk != null) && (respChunk.payload != null)) {
|
||||
byte[] body = extractBody(respChunk.payload);
|
||||
if ((body != null) && (body.length > 0)) {
|
||||
if (isTextContent(body, reply.contentType))
|
||||
writer.name("text").value(new String(body, StandardCharsets.UTF_8));
|
||||
else {
|
||||
writer.name("text").value(Base64.encodeToString(body, Base64.NO_WRAP));
|
||||
writer.name("encoding").value("base64");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writer.endObject();
|
||||
}
|
||||
|
||||
private void writePostData(JsonWriter writer, PayloadChunk reqChunk, List<String[]> requestHeaders) throws IOException {
|
||||
writer.beginObject();
|
||||
|
||||
String contentType = getHeaderValue(requestHeaders, "content-type");
|
||||
writer.name("mimeType").value(contentType != null ? contentType : "");
|
||||
|
||||
if ((reqChunk != null) && (reqChunk.payload != null)) {
|
||||
byte[] body = extractBody(reqChunk.payload);
|
||||
if ((body != null) && (body.length > 0))
|
||||
writer.name("text").value(new String(body, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// params empty - we don't parse form data
|
||||
writer.name("params");
|
||||
writer.beginArray();
|
||||
writer.endArray();
|
||||
|
||||
writer.endObject();
|
||||
}
|
||||
|
||||
private void writeTimings(JsonWriter writer) throws IOException {
|
||||
writer.beginObject();
|
||||
|
||||
writer.name("send").value(-1);
|
||||
writer.name("wait").value(-1);
|
||||
writer.name("receive").value(-1);
|
||||
|
||||
// Optional fields
|
||||
writer.name("blocked").value(-1);
|
||||
writer.name("dns").value(-1);
|
||||
writer.name("connect").value(-1);
|
||||
writer.name("ssl").value(-1);
|
||||
|
||||
writer.endObject();
|
||||
}
|
||||
|
||||
private void writeHeaders(JsonWriter writer, List<String[]> headers) throws IOException {
|
||||
writer.beginArray();
|
||||
for (String[] header : headers) {
|
||||
writer.beginObject();
|
||||
writer.name("name").value(header[0]);
|
||||
writer.name("value").value(header[1]);
|
||||
writer.endObject();
|
||||
}
|
||||
writer.endArray();
|
||||
}
|
||||
|
||||
private void writeQueryString(JsonWriter writer, String query) throws IOException {
|
||||
writer.beginArray();
|
||||
|
||||
if ((query != null) && !query.isEmpty()) {
|
||||
// Remove leading '?'
|
||||
if (query.startsWith("?"))
|
||||
query = query.substring(1);
|
||||
|
||||
String[] pairs = query.split("&");
|
||||
for (String pair : pairs) {
|
||||
int eqPos = pair.indexOf('=');
|
||||
|
||||
// Decode first, then write - to avoid leaving JSON in invalid state on exception
|
||||
String name, value;
|
||||
try {
|
||||
if (eqPos > 0) {
|
||||
name = URLDecoder.decode(pair.substring(0, eqPos), "UTF-8");
|
||||
value = URLDecoder.decode(pair.substring(eqPos + 1), "UTF-8");
|
||||
} else {
|
||||
name = URLDecoder.decode(pair, "UTF-8");
|
||||
value = "";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Skip malformed parameter
|
||||
continue;
|
||||
}
|
||||
|
||||
writer.beginObject();
|
||||
writer.name("name").value(name);
|
||||
writer.name("value").value(value);
|
||||
writer.endObject();
|
||||
}
|
||||
}
|
||||
|
||||
writer.endArray();
|
||||
}
|
||||
|
||||
private void writeRequestCookies(JsonWriter writer, List<String[]> headers) throws IOException {
|
||||
writer.beginArray();
|
||||
|
||||
for (String[] header : headers) {
|
||||
if (header[0].equalsIgnoreCase("Cookie")) {
|
||||
String value = header[1];
|
||||
|
||||
// Parse: name1=value1; name2=value2
|
||||
String[] pairs = value.split(";");
|
||||
for (String pair : pairs) {
|
||||
pair = pair.trim();
|
||||
|
||||
int eqPos = pair.indexOf('=');
|
||||
if (eqPos > 0) {
|
||||
writer.beginObject();
|
||||
writer.name("name").value(pair.substring(0, eqPos));
|
||||
writer.name("value").value(pair.substring(eqPos + 1));
|
||||
writer.endObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writer.endArray();
|
||||
}
|
||||
|
||||
private void writeResponseCookies(JsonWriter writer, List<String[]> headers) throws IOException {
|
||||
writer.beginArray();
|
||||
|
||||
for (String[] header : headers) {
|
||||
if (header[0].equalsIgnoreCase("Set-Cookie")) {
|
||||
String value = header[1];
|
||||
|
||||
// Parse: name=value; Path=/; Domain=.example.com; HttpOnly; Secure; SameSite=Lax
|
||||
String[] parts = value.split(";");
|
||||
|
||||
// First part is name=value
|
||||
if (parts.length > 0) {
|
||||
int eqPos = parts[0].indexOf('=');
|
||||
if (eqPos > 0) {
|
||||
writer.beginObject();
|
||||
writer.name("name").value(parts[0].substring(0, eqPos).trim());
|
||||
writer.name("value").value(parts[0].substring(eqPos + 1).trim());
|
||||
|
||||
// Parse attributes with defaults
|
||||
String path = "/";
|
||||
String domain = "";
|
||||
boolean httpOnly = false;
|
||||
boolean secure = false;
|
||||
String sameSite = null;
|
||||
String expires = null;
|
||||
|
||||
for (int i = 1; i < parts.length; i++) {
|
||||
String attr = parts[i].trim();
|
||||
String attrLower = attr.toLowerCase();
|
||||
|
||||
if (attrLower.startsWith("path="))
|
||||
path = attr.substring(5);
|
||||
else if (attrLower.startsWith("domain="))
|
||||
domain = attr.substring(7);
|
||||
else if (attrLower.equals("httponly"))
|
||||
httpOnly = true;
|
||||
else if (attrLower.equals("secure"))
|
||||
secure = true;
|
||||
else if (attrLower.startsWith("samesite="))
|
||||
sameSite = attr.substring(9);
|
||||
else if (attrLower.startsWith("expires="))
|
||||
expires = attr.substring(8);
|
||||
}
|
||||
|
||||
writer.name("path").value(path);
|
||||
writer.name("domain").value(domain);
|
||||
writer.name("httpOnly").value(httpOnly);
|
||||
writer.name("secure").value(secure);
|
||||
if (sameSite != null)
|
||||
writer.name("sameSite").value(sameSite);
|
||||
if (expires != null) {
|
||||
String isoExpires = Utils.httpDateToIso8601(expires);
|
||||
if (isoExpires != null)
|
||||
writer.name("expires").value(isoExpires);
|
||||
}
|
||||
|
||||
writer.endObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writer.endArray();
|
||||
}
|
||||
|
||||
private List<String[]> parseHeaders(String httpText) {
|
||||
List<String[]> headers = new ArrayList<>();
|
||||
int headerEnd = Utils.getEndOfHTTPHeaders(httpText.getBytes(StandardCharsets.UTF_8));
|
||||
if (headerEnd == 0) headerEnd = httpText.length();
|
||||
|
||||
String headerSection = httpText.substring(0, Math.min(headerEnd, httpText.length()));
|
||||
String[] lines = headerSection.split("\r\n");
|
||||
|
||||
// Skip first line (request line or status line)
|
||||
for (int i = 1; i < lines.length; i++) {
|
||||
int colonPos = lines[i].indexOf(':');
|
||||
if (colonPos > 0) {
|
||||
headers.add(new String[]{
|
||||
lines[i].substring(0, colonPos),
|
||||
lines[i].substring(colonPos + 1).trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private byte[] extractBody(byte[] payload) {
|
||||
int headerEnd = Utils.getEndOfHTTPHeaders(payload);
|
||||
if ((headerEnd <= 0) || (headerEnd >= payload.length))
|
||||
return null;
|
||||
|
||||
byte[] body = new byte[payload.length - headerEnd];
|
||||
System.arraycopy(payload, headerEnd, body, 0, body.length);
|
||||
return body;
|
||||
}
|
||||
|
||||
private int getHeadersSize(byte[] payload) {
|
||||
int headerEnd = Utils.getEndOfHTTPHeaders(payload);
|
||||
return headerEnd > 0 ? headerEnd : -1;
|
||||
}
|
||||
|
||||
private boolean isTextContent(byte[] body, String contentType) {
|
||||
if (contentType != null) {
|
||||
String ct = contentType.toLowerCase();
|
||||
if (ct.startsWith("text/") || ct.contains("json") ||
|
||||
ct.contains("xml") || ct.contains("javascript") || ct.contains("html"))
|
||||
return true;
|
||||
if (ct.startsWith("image/") || ct.startsWith("audio/") ||
|
||||
ct.startsWith("video/") || ct.equals("application/octet-stream"))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check first bytes using Utils.isPrintable()
|
||||
int checkLen = Math.min(body.length, 16);
|
||||
for (int i = 0; i < checkLen; i++) {
|
||||
if (!Utils.isPrintable(body[i]))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String getHeaderValue(List<String[]> headers, String name) {
|
||||
for (String[] header : headers) {
|
||||
if (header[0].equalsIgnoreCase(name))
|
||||
return header[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add WebSocket messages to entry if this is a WebSocket connection.
|
||||
* Uses Chrome DevTools extension format.
|
||||
*/
|
||||
private void writeWebSocketMessages(JsonWriter writer, ConnectionDescriptor conn, HttpLog.HttpRequest req) throws IOException {
|
||||
if ((req.reply == null) || !req.hasWebsocketData())
|
||||
return;
|
||||
|
||||
ArrayList<PayloadChunk> wsChunks;
|
||||
|
||||
if (CaptureService.isReadingFromPcapFile()) {
|
||||
// When reading from PCAP, chunks contain raw data that must be processed
|
||||
// through HTTPReassembly to decode WebSocket frames
|
||||
wsChunks = new ArrayList<>();
|
||||
HTTPReassembly.ReassemblyListener listener = chunk -> {
|
||||
if ((chunk.type == PayloadChunk.ChunkType.WEBSOCKET) &&
|
||||
!WebSocketDecoder.isControlOpcode(chunk.wsOpcode))
|
||||
wsChunks.add(chunk);
|
||||
};
|
||||
HTTPReassembly httpReq = new HTTPReassembly(true, listener);
|
||||
HTTPReassembly httpRes = new HTTPReassembly(true, listener);
|
||||
|
||||
int startPos = req.firstChunkPos;
|
||||
synchronized (conn) {
|
||||
for (int i = startPos; i < conn.getNumPayloadChunks(); i++) {
|
||||
PayloadChunk chunk = conn.getPayloadChunk(i);
|
||||
if ((chunk == null) || (chunk.type == PayloadChunk.ChunkType.RAW))
|
||||
continue;
|
||||
|
||||
if (chunk.is_sent)
|
||||
httpReq.handleChunk(chunk);
|
||||
else
|
||||
httpRes.handleChunk(chunk);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Live capture: chunks are already decoded by mitmproxy
|
||||
wsChunks = new ArrayList<>();
|
||||
int startPos = req.reply.firstChunkPos + 1;
|
||||
|
||||
synchronized (conn) {
|
||||
for (int i = startPos; i < conn.getNumPayloadChunks(); i++) {
|
||||
PayloadChunk chunk = conn.getPayloadChunk(i);
|
||||
if ((chunk != null) && (chunk.type == PayloadChunk.ChunkType.WEBSOCKET))
|
||||
wsChunks.add(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wsChunks.isEmpty())
|
||||
return;
|
||||
|
||||
// Chrome DevTools extension
|
||||
writer.name("_resourceType").value("websocket");
|
||||
writer.name("_webSocketMessages");
|
||||
writer.beginArray();
|
||||
|
||||
for (PayloadChunk chunk : wsChunks) {
|
||||
writer.beginObject();
|
||||
writer.name("type").value(chunk.is_sent ? "send" : "receive");
|
||||
writer.name("time").value(chunk.timestamp / 1000.0);
|
||||
|
||||
// Use wsOpcode if available, otherwise guess from content
|
||||
int opcode;
|
||||
if (chunk.wsOpcode > 0)
|
||||
opcode = chunk.wsOpcode;
|
||||
else {
|
||||
boolean isText = (chunk.payload == null) || (chunk.payload.length == 0) ||
|
||||
isTextContent(chunk.payload, null);
|
||||
opcode = isText ? WebSocketDecoder.OPCODE_TEXT : WebSocketDecoder.OPCODE_BINARY;
|
||||
}
|
||||
writer.name("opcode").value(opcode);
|
||||
|
||||
if ((chunk.payload != null) && (chunk.payload.length > 0)) {
|
||||
if (opcode == WebSocketDecoder.OPCODE_TEXT)
|
||||
writer.name("data").value(new String(chunk.payload, StandardCharsets.UTF_8));
|
||||
else
|
||||
writer.name("data").value(Base64.encodeToString(chunk.payload, Base64.NO_WRAP));
|
||||
} else
|
||||
writer.name("data").value("");
|
||||
|
||||
writer.endObject();
|
||||
}
|
||||
|
||||
writer.endArray();
|
||||
}
|
||||
}
|
||||
@ -62,6 +62,7 @@ public class PCAPdroid extends Application {
|
||||
private Context mLocalizedContext;
|
||||
private boolean mIsDecryptingPcap = false;
|
||||
private boolean mIsUsharkAvailable = false;
|
||||
private String mLoadedPcapBasename = null;
|
||||
private static WeakReference<PCAPdroid> mInstance;
|
||||
protected static boolean isUnderTest = false;
|
||||
|
||||
@ -252,4 +253,12 @@ public class PCAPdroid extends Application {
|
||||
public boolean isUsharkAvailable() {
|
||||
return mIsUsharkAvailable;
|
||||
}
|
||||
|
||||
public void setLoadedPcapBasename(String basename) {
|
||||
mLoadedPcapBasename = basename;
|
||||
}
|
||||
|
||||
public String getLoadedPcapBasename() {
|
||||
return mLoadedPcapBasename;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -19,6 +19,9 @@
|
||||
|
||||
package com.emanuelef.remote_capture.activities;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
@ -26,6 +29,10 @@ import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
|
||||
import androidx.activity.result.ActivityResult;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
@ -34,6 +41,7 @@ import androidx.viewpager2.widget.ViewPager2;
|
||||
|
||||
import com.emanuelef.remote_capture.CaptureService;
|
||||
import com.emanuelef.remote_capture.ConnectionsRegister;
|
||||
import com.emanuelef.remote_capture.HarWriter;
|
||||
import com.emanuelef.remote_capture.HttpLog;
|
||||
import com.emanuelef.remote_capture.Log;
|
||||
import com.emanuelef.remote_capture.R;
|
||||
@ -47,7 +55,11 @@ import com.emanuelef.remote_capture.model.PayloadChunk;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import com.google.android.material.tabs.TabLayoutMediator;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class HttpDetailsActivity extends PayloadExportActivity implements ConnectionsListener, PayloadHostActivity {
|
||||
private static final String TAG = "HttpRequestDetailsActivity";
|
||||
@ -63,6 +75,11 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec
|
||||
private MenuItem mMenuNext;
|
||||
private MenuItem mMenuDisplayAs;
|
||||
private Boolean mDisplayMode;
|
||||
private Uri mHarFname;
|
||||
private AlertDialog mAlertDialog;
|
||||
|
||||
private final ActivityResultLauncher<Intent> harFileLauncher =
|
||||
registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), this::harFileResult);
|
||||
|
||||
private static final int POS_REQUEST = 0;
|
||||
private static final int POS_REPLY = 1;
|
||||
@ -132,6 +149,14 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec
|
||||
unregisterConnsListener();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if(mAlertDialog != null)
|
||||
mAlertDialog.dismiss();
|
||||
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void setupTabs() {
|
||||
mPagerAdapter = new StateAdapter(this);
|
||||
mPager.setAdapter(mPagerAdapter);
|
||||
@ -205,11 +230,10 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec
|
||||
|
||||
payloadFragment.setDisplayMode(mDisplayMode);
|
||||
|
||||
if(mDisplayMode) {
|
||||
if(mDisplayMode)
|
||||
mMenuDisplayAs.setTitle(R.string.display_as_hexdump);
|
||||
} else {
|
||||
else
|
||||
mMenuDisplayAs.setTitle(R.string.display_as_text);
|
||||
}
|
||||
} else if(currentFragment instanceof ConnectionPayload wsFragment) {
|
||||
if(mDisplayMode == null)
|
||||
mDisplayMode = true;
|
||||
@ -245,6 +269,9 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec
|
||||
updateMenuVisibility();
|
||||
}
|
||||
return true;
|
||||
} else if(itemId == R.id.save_as_har) {
|
||||
openHarFileSelector();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
@ -393,6 +420,120 @@ public class HttpDetailsActivity extends PayloadExportActivity implements Connec
|
||||
unregisterConnsListener();
|
||||
}
|
||||
|
||||
private void openHarFileSelector() {
|
||||
if (mHttpReq == null)
|
||||
return;
|
||||
|
||||
boolean noFileDialog = false;
|
||||
String fname = Utils.getExportFileName(this, "har");
|
||||
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
intent.putExtra(Intent.EXTRA_TITLE, fname);
|
||||
|
||||
if(Utils.supportsFileDialog(this, intent)) {
|
||||
try {
|
||||
harFileLauncher.launch(intent);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
noFileDialog = true;
|
||||
}
|
||||
} else
|
||||
noFileDialog = true;
|
||||
|
||||
if(noFileDialog) {
|
||||
Log.d(TAG, "No app found to handle file selection");
|
||||
|
||||
Uri uri = Utils.getDownloadsUri(this, fname);
|
||||
|
||||
if(uri != null) {
|
||||
mHarFname = uri;
|
||||
exportHar();
|
||||
} else
|
||||
Utils.showToastLong(this, R.string.no_activity_file_selection);
|
||||
}
|
||||
}
|
||||
|
||||
private void exportHar() {
|
||||
if(mHarFname == null || mHttpReq == null)
|
||||
return;
|
||||
|
||||
Log.d(TAG, "Writing HAR file: " + mHarFname);
|
||||
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
Handler handler = new Handler(Looper.getMainLooper());
|
||||
final boolean[] cancelled = {false};
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(this);
|
||||
builder.setTitle(R.string.exporting);
|
||||
builder.setMessage(R.string.export_in_progress);
|
||||
builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
|
||||
Log.i(TAG, "Abort HAR export");
|
||||
cancelled[0] = true;
|
||||
executor.shutdownNow();
|
||||
});
|
||||
|
||||
mAlertDialog = builder.create();
|
||||
mAlertDialog.setCanceledOnTouchOutside(false);
|
||||
mAlertDialog.show();
|
||||
|
||||
mAlertDialog.setOnCancelListener(dialog -> {
|
||||
Log.i(TAG, "Abort HAR export (back button)");
|
||||
cancelled[0] = true;
|
||||
executor.shutdownNow();
|
||||
});
|
||||
mAlertDialog.setOnDismissListener(dialog -> mAlertDialog = null);
|
||||
|
||||
final Uri harFname = mHarFname;
|
||||
final HttpLog.HttpRequest httpReq = mHttpReq;
|
||||
mHarFname = null;
|
||||
|
||||
executor.execute(() -> {
|
||||
boolean success = false;
|
||||
|
||||
try {
|
||||
OutputStream stream = getContentResolver().openOutputStream(harFname, "rwt");
|
||||
|
||||
if(stream != null) {
|
||||
HarWriter writer = new HarWriter(HttpDetailsActivity.this, httpReq);
|
||||
writer.write(stream);
|
||||
stream.close();
|
||||
success = true;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if(!cancelled[0])
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
if(cancelled[0])
|
||||
return;
|
||||
|
||||
final boolean result = success;
|
||||
final Utils.UriStat stat = result ? Utils.getUriStat(HttpDetailsActivity.this, harFname) : null;
|
||||
|
||||
handler.post(() -> {
|
||||
if(mAlertDialog != null)
|
||||
mAlertDialog.dismiss();
|
||||
|
||||
if(result) {
|
||||
if(stat != null)
|
||||
Utils.showToast(HttpDetailsActivity.this, R.string.file_saved_with_name, stat.name);
|
||||
else
|
||||
Utils.showToast(HttpDetailsActivity.this, R.string.save_ok);
|
||||
} else
|
||||
Utils.showToast(HttpDetailsActivity.this, R.string.cannot_write_file);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void harFileResult(final ActivityResult result) {
|
||||
if((result.getResultCode() == RESULT_OK) && (result.getData() != null)) {
|
||||
mHarFname = result.getData().getData();
|
||||
exportHar();
|
||||
} else {
|
||||
mHarFname = null;
|
||||
}
|
||||
}
|
||||
|
||||
private class StateAdapter extends FragmentStateAdapter {
|
||||
StateAdapter(final FragmentActivity fa) { super(fa); }
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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("*/*");
|
||||
|
||||
@ -40,6 +40,7 @@ import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.SearchView;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.MenuProvider;
|
||||
@ -52,6 +53,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.emanuelef.remote_capture.AppsResolver;
|
||||
import com.emanuelef.remote_capture.CaptureService;
|
||||
import com.emanuelef.remote_capture.HarWriter;
|
||||
import com.emanuelef.remote_capture.HttpLog;
|
||||
import com.emanuelef.remote_capture.Log;
|
||||
import com.emanuelef.remote_capture.R;
|
||||
@ -69,6 +71,8 @@ import com.google.android.material.slider.Slider;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuProvider, SearchView.OnQueryTextListener {
|
||||
private static final String TAG = "HttpLogFragment";
|
||||
@ -79,12 +83,15 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
|
||||
private int mFabDownMargin = 0;
|
||||
private MenuItem mMenuItemSearch;
|
||||
private MenuItem mSave;
|
||||
private MenuItem mSaveAsHar;
|
||||
private SearchView mSearchView;
|
||||
private Handler mHandler;
|
||||
private ChipGroup mActiveFilter;
|
||||
private Slider mSizeSlider;
|
||||
private boolean mSizeSliderActive = false;
|
||||
private Uri mTxtFname;
|
||||
private Uri mHarFname;
|
||||
private AlertDialog mAlertDialog;
|
||||
|
||||
private String mQueryToApply;
|
||||
private AppsResolver mApps;
|
||||
@ -95,6 +102,8 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
|
||||
registerForActivityResult(new StartActivityForResult(), this::filterResult);
|
||||
private final ActivityResultLauncher<Intent> txtFileLauncher =
|
||||
registerForActivityResult(new StartActivityForResult(), this::txtFileResult);
|
||||
private final ActivityResultLauncher<Intent> harFileLauncher =
|
||||
registerForActivityResult(new StartActivityForResult(), this::harFileResult);
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
@ -129,6 +138,14 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
|
||||
mQueryToApply = mSearchView.getQuery().toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
if(mAlertDialog != null)
|
||||
mAlertDialog.dismiss();
|
||||
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHiddenChanged(boolean hidden) {
|
||||
super.onHiddenChanged(hidden);
|
||||
@ -336,6 +353,7 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
|
||||
menuInflater.inflate(R.menu.http_log_menu, menu);
|
||||
|
||||
mSave = menu.findItem(R.id.save);
|
||||
mSaveAsHar = menu.findItem(R.id.save_as_har);
|
||||
mMenuItemSearch = menu.findItem(R.id.search);
|
||||
|
||||
mSearchView = (SearchView) mMenuItemSearch.getActionView();
|
||||
@ -357,6 +375,9 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
|
||||
if(id == R.id.save) {
|
||||
openFileSelector();
|
||||
return true;
|
||||
} else if(id == R.id.save_as_har) {
|
||||
openHarFileSelector();
|
||||
return true;
|
||||
} else if(id == R.id.edit_filter) {
|
||||
Intent intent = new Intent(requireContext(), HttpLogFilterActivity.class);
|
||||
intent.putExtra(HttpLogFilterActivity.FILTER_DESCRIPTOR, mAdapter.mFilter);
|
||||
@ -542,11 +563,13 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
|
||||
|
||||
mMenuItemSearch.setVisible(is_enabled);
|
||||
mSave.setEnabled(is_enabled);
|
||||
if(mSaveAsHar != null)
|
||||
mSaveAsHar.setEnabled(is_enabled);
|
||||
}
|
||||
|
||||
public void openFileSelector() {
|
||||
boolean noFileDialog = false;
|
||||
String fname = Utils.getUniqueFileName(requireContext(), "txt");
|
||||
String fname = Utils.getExportFileName(requireContext(), "txt");
|
||||
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
@ -654,6 +677,121 @@ public class HttpLogFragment extends Fragment implements HttpLog.Listener, MenuP
|
||||
}
|
||||
}
|
||||
|
||||
public void openHarFileSelector() {
|
||||
boolean noFileDialog = false;
|
||||
String fname = Utils.getExportFileName(requireContext(), "har");
|
||||
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
intent.putExtra(Intent.EXTRA_TITLE, fname);
|
||||
|
||||
if(Utils.supportsFileDialog(requireContext(), intent)) {
|
||||
try {
|
||||
harFileLauncher.launch(intent);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
noFileDialog = true;
|
||||
}
|
||||
} else
|
||||
noFileDialog = true;
|
||||
|
||||
if(noFileDialog) {
|
||||
Log.d(TAG, "No app found to handle file selection");
|
||||
|
||||
Uri uri = Utils.getDownloadsUri(requireContext(), fname);
|
||||
|
||||
if(uri != null) {
|
||||
mHarFname = uri;
|
||||
exportHttpLogHar();
|
||||
} else
|
||||
Utils.showToastLong(requireContext(), R.string.no_activity_file_selection);
|
||||
}
|
||||
}
|
||||
|
||||
private void exportHttpLogHar() {
|
||||
if(mHarFname == null)
|
||||
return;
|
||||
|
||||
HttpLog httpLog = CaptureService.getHttpLog();
|
||||
if(httpLog == null)
|
||||
return;
|
||||
|
||||
Log.d(TAG, "Writing HAR file: " + mHarFname);
|
||||
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
Handler handler = new Handler(Looper.getMainLooper());
|
||||
final boolean[] cancelled = {false};
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());
|
||||
builder.setTitle(R.string.exporting);
|
||||
builder.setMessage(R.string.export_in_progress);
|
||||
builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
|
||||
Log.i(TAG, "Abort HAR export");
|
||||
cancelled[0] = true;
|
||||
executor.shutdownNow();
|
||||
});
|
||||
|
||||
mAlertDialog = builder.create();
|
||||
mAlertDialog.setCanceledOnTouchOutside(false);
|
||||
mAlertDialog.show();
|
||||
|
||||
mAlertDialog.setOnCancelListener(dialog -> {
|
||||
Log.i(TAG, "Abort HAR export (back button)");
|
||||
cancelled[0] = true;
|
||||
executor.shutdownNow();
|
||||
});
|
||||
mAlertDialog.setOnDismissListener(dialog -> mAlertDialog = null);
|
||||
|
||||
final Uri harFname = mHarFname;
|
||||
mHarFname = null;
|
||||
|
||||
executor.execute(() -> {
|
||||
boolean success = false;
|
||||
|
||||
try {
|
||||
OutputStream stream = requireActivity().getContentResolver().openOutputStream(harFname, "rwt");
|
||||
|
||||
if(stream != null) {
|
||||
HarWriter writer = new HarWriter(requireContext(), httpLog);
|
||||
writer.write(stream);
|
||||
stream.close();
|
||||
success = true;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if(!cancelled[0])
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
if(cancelled[0])
|
||||
return;
|
||||
|
||||
final boolean result = success;
|
||||
final Utils.UriStat stat = result ? Utils.getUriStat(requireContext(), harFname) : null;
|
||||
|
||||
handler.post(() -> {
|
||||
if(mAlertDialog != null)
|
||||
mAlertDialog.dismiss();
|
||||
|
||||
if(result) {
|
||||
if(stat != null) {
|
||||
String msg = String.format(getString(R.string.file_saved_with_name), stat.name);
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show();
|
||||
} else
|
||||
Utils.showToast(requireContext(), R.string.save_ok);
|
||||
} else
|
||||
Utils.showToast(requireContext(), R.string.cannot_write_file);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void harFileResult(final ActivityResult result) {
|
||||
if((result.getResultCode() == Activity.RESULT_OK) && (result.getData() != null)) {
|
||||
mHarFname = result.getData().getData();
|
||||
exportHttpLogHar();
|
||||
} else {
|
||||
mHarFname = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void clearFilters() {
|
||||
if(mAdapter != null) {
|
||||
mAdapter.mFilter = new HttpLogFilterDescriptor();
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* Copyright 2020-22 - Emanuele Faranda
|
||||
* Copyright 2020-26 - Emanuele Faranda
|
||||
*/
|
||||
|
||||
package com.emanuelef.remote_capture.model;
|
||||
@ -37,6 +37,7 @@ public class PayloadChunk implements Serializable {
|
||||
public String httpPath = "";
|
||||
public String httpQuery = "";
|
||||
public String httpContentType = "";
|
||||
public String httpVersion = "";
|
||||
public int httpBodyLength = 0;
|
||||
private boolean mHttpRst = false;
|
||||
|
||||
|
||||
@ -24,4 +24,10 @@
|
||||
android:icon="@drawable/ic_short_text"
|
||||
app:showAsAction="never"
|
||||
android:visible="false" />
|
||||
|
||||
<item
|
||||
android:id="@+id/save_as_har"
|
||||
android:title="@string/save_as_har"
|
||||
android:orderInCategory="40"
|
||||
app:showAsAction="never" />
|
||||
</menu>
|
||||
|
||||
@ -24,11 +24,17 @@
|
||||
|
||||
<item
|
||||
android:id="@+id/save"
|
||||
android:title="@string/save_to_file"
|
||||
android:title="@string/save_as_text"
|
||||
android:orderInCategory="30"
|
||||
android:icon="@drawable/ic_save"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/save_as_har"
|
||||
android:title="@string/save_as_har"
|
||||
android:orderInCategory="31"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/switch_to_connections"
|
||||
android:visible="false" />
|
||||
|
||||
@ -350,6 +350,8 @@
|
||||
<string name="download_in_progress">Download in progress, please wait</string>
|
||||
<string name="pcap_load_in_progress">Capture file loading in progress, please wait</string>
|
||||
<string name="download_failed">Download failed</string>
|
||||
<string name="exporting">Exporting…</string>
|
||||
<string name="export_in_progress">Export in progress, please wait</string>
|
||||
<string name="geo_db_not_found">Database not found. Geolocation is disabled</string>
|
||||
<string name="geo_db_missing">Geolocation database missing</string>
|
||||
<string name="country_rules_warning">To use country-based firewall rules, download the geolocation database from the PCAPdroid settings, otherwise country-based rules will be ineffective</string>
|
||||
@ -587,4 +589,6 @@
|
||||
<string name="decryption_error">Decryption error</string>
|
||||
<string name="previous">Previous</string>
|
||||
<string name="next">Next</string>
|
||||
<string name="save_as_text">Save as text</string>
|
||||
<string name="save_as_har">Save as HAR</string>
|
||||
</resources>
|
||||
|
||||
2507
app/src/test/java/com/emanuelef/remote_capture/HarWriterTest.java
Normal file
2507
app/src/test/java/com/emanuelef/remote_capture/HarWriterTest.java
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user