diff --git a/packages/devtools_app/lib/src/screens/network/har_data_entry.dart b/packages/devtools_app/lib/src/screens/network/har_data_entry.dart index fe50b818403..8e5560e544c 100644 --- a/packages/devtools_app/lib/src/screens/network/har_data_entry.dart +++ b/packages/devtools_app/lib/src/screens/network/har_data_entry.dart @@ -119,6 +119,9 @@ class HarDataEntry { }; }).toList(); + final isBinary = !isTextMimeType(e.type); + final responseBodyBytes = e.encodedResponse; + return { NetworkEventKeys.startedDateTime.name: e.startTimestamp .toUtc() @@ -153,8 +156,14 @@ class HarDataEntry { NetworkEventKeys.content.name: { NetworkEventKeys.size.name: e.responseBody?.length, NetworkEventKeys.mimeType.name: e.type, - NetworkEventKeys.text.name: e.responseBody, + if (responseBodyBytes != null && isBinary) ...{ + NetworkEventKeys.text.name: base64.encode(responseBodyBytes), + 'encoding': 'base64', + } else if (e.responseBody != null) ...{ + NetworkEventKeys.text.name: e.responseBody, + }, }, + NetworkEventKeys.redirectURL.name: '', NetworkEventKeys.headersSize.name: calculateHeadersSize( e.responseHeaders, diff --git a/packages/devtools_app/lib/src/screens/network/network_controller.dart b/packages/devtools_app/lib/src/screens/network/network_controller.dart index 309b62e8503..cd922a01ed9 100644 --- a/packages/devtools_app/lib/src/screens/network/network_controller.dart +++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart @@ -78,9 +78,8 @@ class NetworkController extends DevToolsScreenController debugPrint('No valid request data to export'); return ''; } - + // Build the HAR object try { - // Build the HAR object final har = HarNetworkData(_httpRequests!); return ExportController().downloadFile( json.encode(har.toJson()), @@ -206,8 +205,11 @@ class NetworkController extends DevToolsScreenController shouldLoad: (data) => !data.isEmpty, loadData: (data) => loadOfflineData(data), ); - } - if (serviceConnection.serviceManager.connectedState.value.connected) { + } else if (serviceConnection + .serviceManager + .connectedState + .value + .connected) { await startRecording(); } } diff --git a/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart b/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart index 9efd5482a73..7ef81239cba 100644 --- a/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart +++ b/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart @@ -29,3 +29,55 @@ int calculateHeadersSize(Map? headers) { // Calculate the byte length of the headers string return utf8.encode(headersString).length; } + +/// Returns `true` if the given [mimeType] is considered textual and can be +/// safely decoded as UTF-8 without base64 encoding. +/// +/// This function is useful for determining whether the content of an HTTP +/// request or response can be directly included in a HAR or JSON file as +/// human-readable text. +bool isTextMimeType(String? mimeType) { + if (mimeType == null) return false; + + // Strip charset if present + final cleanedMime = mimeType.split(';').first.trim().toLowerCase(); + + return cleanedMime.startsWith('text/') || + cleanedMime == 'application/json' || + cleanedMime == 'application/javascript' || + cleanedMime == 'application/xml'; +} + +/// Extracts and normalizes the `content-type` MIME type from the headers. +/// +/// - Supports headers as either a `List` or a single `String`. +/// - Strips any parameters (e.g., `charset=utf-8`) and converts to lowercase. +/// - Returns `null` if no valid MIME type is found. +/// +/// Example: +/// - "application/json; charset=utf-8" → "application/json" +/// - ["text/html; charset=UTF-8"] → "text/html" +String? getHeadersMimeType(dynamic header) { + if (header == null) return null; + + final value = header is List + ? (header.isNotEmpty ? header.first : null) + : header; + + if (value == null || value.trim().isEmpty) return null; + + return value.split(';').first.trim().toLowerCase(); +} + +/// Converts the given [bodyBytes] to a String based on its [mimeType]. +/// +/// - If the MIME type is text-based (e.g., `application/json`, `text/html`), +/// it decodes the raw bytes as UTF-8 for readability. +/// - Otherwise, it Base64 encodes the bytes so they can be safely stored +/// in JSON-based exports such as HAR files. +String convertBodyBytesToString(List bodyBytes, String? mimeType) { + if (isTextMimeType(mimeType)) { + return utf8.decode(bodyBytes); + } + return base64.encode(bodyBytes); +} diff --git a/packages/devtools_app/lib/src/shared/http/http_request_data.dart b/packages/devtools_app/lib/src/shared/http/http_request_data.dart index 8be9458d23f..c3d66087d84 100644 --- a/packages/devtools_app/lib/src/shared/http/http_request_data.dart +++ b/packages/devtools_app/lib/src/shared/http/http_request_data.dart @@ -11,6 +11,7 @@ import 'package:mime/mime.dart'; import 'package:vm_service/vm_service.dart'; import '../../screens/network/network_model.dart'; +import '../../screens/network/utils/http_utils.dart'; import '../globals.dart'; import '../primitives/utils.dart'; import 'constants.dart'; @@ -101,8 +102,24 @@ class DartIOHttpRequestData extends NetworkRequest { ); _request = updated; final fullRequest = _request as HttpProfileRequest; - _responseBody = utf8.decode(fullRequest.responseBody!); - _requestBody = utf8.decode(fullRequest.requestBody!); + var responseMime = getHeadersMimeType(responseHeaders?['content-type']); + final requestMime = getHeadersMimeType(requestHeaders?['content-type']); + + if (fullRequest.responseBody != null) { + responseMime = normalizeContentType(responseHeaders?['content-type']); + _responseBody = convertBodyBytesToString( + fullRequest.responseBody!, + responseMime, + ); + } + + if (fullRequest.requestBody != null) { + _requestBody = convertBodyBytesToString( + fullRequest.requestBody!, + requestMime, + ); + } + notifyListeners(); } } finally { @@ -110,6 +127,16 @@ class DartIOHttpRequestData extends NetworkRequest { } } + //TODO check if all cases are handled correctly + String? normalizeContentType(dynamic header) { + if (header is List && header.isNotEmpty) { + return header.first.toString().split(';').first.trim().toLowerCase(); + } else if (header is String) { + return header.split(';').first.trim().toLowerCase(); + } + return null; + } + static List _parseCookies(List? cookies) { if (cookies == null) return []; return cookies.map((cookie) => Cookie.fromSetCookieValue(cookie)).toList();