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 62fd5c537eb..fa6d37e925c 100644 --- a/packages/devtools_app/lib/src/screens/network/network_controller.dart +++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart @@ -77,9 +77,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()), @@ -205,8 +204,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..781d2ffba3c 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,61 @@ 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' || + cleanedMime.endsWith('+json') || + cleanedMime.endsWith('+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 dynamicValue = header is List + ? (header.isNotEmpty ? header.first : null) + : header; + + if (dynamicValue == null) return null; + + final value = dynamicValue.toString().trim(); + if (value.isEmpty) return null; + + final mime = value.split(';').first.trim().toLowerCase(); + return mime.isEmpty ? null : mime; +} + +/// 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..c038c9d8f9e 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,15 @@ class DartIOHttpRequestData extends NetworkRequest { } } + 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(); diff --git a/packages/devtools_app/test/screens/network/utils/network_test_utils.dart b/packages/devtools_app/test/screens/network/utils/network_test_utils.dart index 0d9506c787e..19776488e2a 100644 --- a/packages/devtools_app/test/screens/network/utils/network_test_utils.dart +++ b/packages/devtools_app/test/screens/network/utils/network_test_utils.dart @@ -29,3 +29,5 @@ HttpProfile loadHttpProfile() { timestamp: DateTime.fromMicrosecondsSinceEpoch(0), ); } + + diff --git a/packages/devtools_app/test/screens/network/utils_test.dart b/packages/devtools_app/test/screens/network/utils_test.dart new file mode 100644 index 00000000000..7551037869c --- /dev/null +++ b/packages/devtools_app/test/screens/network/utils_test.dart @@ -0,0 +1,81 @@ +// Copyright 2025 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app/src/screens/network/utils/http_utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('isTextMimeType', () { + test('returns true for text/* types', () { + expect(isTextMimeType('text/plain'), isTrue); + expect(isTextMimeType('text/html'), isTrue); + expect(isTextMimeType('text/css'), isTrue); + }); + + test('returns true for common textual application/* types', () { + expect(isTextMimeType('application/json'), isTrue); + expect(isTextMimeType('application/javascript'), isTrue); + expect(isTextMimeType('application/xml'), isTrue); + }); + + test('returns true even if charset parameter is present', () { + expect(isTextMimeType('application/json; charset=utf-8'), isTrue); + expect(isTextMimeType('text/html; charset=UTF-8'), isTrue); + }); + + test('returns false for non-text types', () { + expect(isTextMimeType('image/png'), isFalse); + expect(isTextMimeType('application/octet-stream'), isFalse); + expect(isTextMimeType('video/mp4'), isFalse); + expect(isTextMimeType('audio/mpeg'), isFalse); + }); + + test('returns false for null or empty strings', () { + expect(isTextMimeType(null), isFalse); + expect(isTextMimeType(''), isFalse); + expect(isTextMimeType(' '), isFalse); + }); + + test('is case-insensitive', () { + expect(isTextMimeType('Application/Json'), isTrue); + expect(isTextMimeType('TEXT/HTML'), isTrue); + }); + }); + + group('getHeadersMimeType', () { + test('extracts MIME type from a plain string', () { + expect( + getHeadersMimeType('application/json; charset=utf-8'), + 'application/json', + ); + expect(getHeadersMimeType('text/html; charset=UTF-8'), 'text/html'); + }); + + test('extracts MIME type from a list of strings', () { + expect( + getHeadersMimeType(['application/json; charset=utf-8']), + 'application/json', + ); + expect(getHeadersMimeType(['text/css; something']), 'text/css'); + }); + + test('returns null for empty or null inputs', () { + expect(getHeadersMimeType(null), isNull); + expect(getHeadersMimeType(''), isNull); + expect(getHeadersMimeType(' '), isNull); + expect(getHeadersMimeType([]), isNull); + expect(getHeadersMimeType(['']), isNull); + }); + + test('normalizes to lowercase', () { + expect(getHeadersMimeType('Text/HTML; Charset=UTF-8'), 'text/html'); + expect(getHeadersMimeType(['APPLICATION/JSON']), 'application/json'); + }); + + test('handles unexpected header formats gracefully', () { + expect(getHeadersMimeType(['; charset=utf-8']), isNull); + expect(getHeadersMimeType([' ; ']), isNull); + }); + }); +}