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 be71e04e2d2..74272b80b78 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 @@ -4,6 +4,7 @@ import 'dart:convert'; +import '../../screens/network/utils/http_utils.dart'; import '../../shared/http/http_request_data.dart'; import '../../shared/primitives/utils.dart'; import 'constants.dart'; @@ -131,7 +132,7 @@ class HarDataEntry { NetworkEventKeys.text.name: e.requestBody, }, NetworkEventKeys.headersSize.name: - _calculateHeadersSize(e.requestHeaders), + calculateHeadersSize(e.requestHeaders), NetworkEventKeys.bodySize.name: _calculateBodySize(e.requestBody), }, // Response @@ -150,7 +151,7 @@ class HarDataEntry { }, NetworkEventKeys.redirectURL.name: '', NetworkEventKeys.headersSize.name: - _calculateHeadersSize(e.responseHeaders), + calculateHeadersSize(e.responseHeaders), NetworkEventKeys.bodySize.name: _calculateBodySize(e.responseBody), }, // Cache @@ -263,27 +264,6 @@ class HarDataEntry { } } -int _calculateHeadersSize(Map? headers) { - if (headers == null) return -1; - - // Combine headers into a single string with CRLF endings - String headersString = headers.entries.map((entry) { - final key = entry.key; - var value = entry.value; - // If the value is a List, join it with a comma - if (value is List) { - value = value.join(', '); - } - return '$key: $value\r\n'; - }).join(); - - // Add final CRLF to indicate end of headers - headersString += '\r\n'; - - // Calculate the byte length of the headers string - return utf8.encode(headersString).length; -} - int _calculateBodySize(String? requestBody) { if (requestBody.isNullOrEmpty) { return 0; 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 f6ea6c56822..2bc89797cd2 100644 --- a/packages/devtools_app/lib/src/screens/network/network_controller.dart +++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart @@ -14,6 +14,7 @@ import '../../shared/config_specific/logger/allowed_error.dart'; import '../../shared/globals.dart'; import '../../shared/http/http_request_data.dart'; import '../../shared/http/http_service.dart' as http_service; +import '../../shared/offline_data.dart'; import '../../shared/primitives/utils.dart'; import '../../shared/ui/filter.dart'; import '../../shared/ui/search.dart'; @@ -22,6 +23,7 @@ import 'har_network_data.dart'; import 'network_model.dart'; import 'network_screen.dart'; import 'network_service.dart'; +import 'offline_network_data.dart'; /// Different types of Network Response which can be used to visualise response /// on Response tab @@ -49,10 +51,12 @@ class NetworkController extends DisposableController with SearchControllerMixin, FilterControllerMixin, + OfflineScreenControllerMixin, AutoDisposeControllerMixin { NetworkController() { _networkService = NetworkService(this); _currentNetworkRequests = CurrentNetworkRequests(); + _initHelper(); addAutoDisposeListener( _currentNetworkRequests, _filterAndRefreshSearchMatches, @@ -168,6 +172,43 @@ class NetworkController extends DisposableController @visibleForTesting bool get isPolling => _pollingTimer != null; + void _initHelper() async { + if (offlineDataController.showingOfflineData.value) { + await maybeLoadOfflineData( + NetworkScreen.id, + createData: (json) => OfflineNetworkData.fromJson(json), + shouldLoad: (data) => !data.isEmpty, + loadData: (data) => loadOfflineData(data), + ); + } else { + await startRecording(); + } + } + + Future loadOfflineData(OfflineNetworkData offlineData) async { + final httpProfileData = offlineData.httpRequestData.mapToHttpProfileRequests; + final socketStatsData = offlineData.socketData.mapToSocketStatistics; + + _currentNetworkRequests + ..clear() + ..updateOrAddAll( + requests: httpProfileData, + sockets: socketStatsData, + timelineMicrosOffset: DateTime.now().microsecondsSinceEpoch, + ); + _filterAndRefreshSearchMatches(); + + // If a selectedRequestId is available, select it in offline mode. + if (offlineData.selectedRequestId != null) { + final selected = _currentNetworkRequests + .getRequest(offlineData.selectedRequestId ?? ''); + if (selected != null) { + selectedRequest.value = selected; + resetDropDown(); + } + } + } + @visibleForTesting void processNetworkTrafficHelper( List sockets, @@ -395,6 +436,30 @@ class NetworkController extends DisposableController } } + @override + OfflineScreenData prepareOfflineScreenData() { + final httpRequestData = []; + final socketData = []; + for (final request in _currentNetworkRequests.value) { + if (request is DartIOHttpRequestData) { + httpRequestData.add(request); + } else if (request is Socket) { + socketData.add(request); + } + } + + final offlineData = OfflineNetworkData( + httpRequestData: httpRequestData, + socketData: socketData, + selectedRequestId: selectedRequest.value?.id, + ); + + return OfflineScreenData( + screenId: NetworkScreen.id, + data: offlineData.toJson(), + ); + } + Future _fetchFullDataBeforeExport() => Future.wait( filteredData.value .whereType() diff --git a/packages/devtools_app/lib/src/screens/network/network_model.dart b/packages/devtools_app/lib/src/screens/network/network_model.dart index 64e7ba1f4a2..b2664baafe3 100644 --- a/packages/devtools_app/lib/src/screens/network/network_model.dart +++ b/packages/devtools_app/lib/src/screens/network/network_model.dart @@ -2,13 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:devtools_shared/devtools_shared.dart'; import 'package:flutter/material.dart'; import 'package:vm_service/vm_service.dart'; import '../../shared/primitives/utils.dart'; import '../../shared/ui/search.dart'; -abstract class NetworkRequest with ChangeNotifier, SearchableDataMixin { +abstract class NetworkRequest + with ChangeNotifier, SearchableDataMixin, Serializable { String get method; String get uri; @@ -85,6 +87,15 @@ abstract class NetworkRequest with ChangeNotifier, SearchableDataMixin { class Socket extends NetworkRequest { Socket(this._socket, this._timelineMicrosBase); + factory Socket.fromJson(Map json) { + return Socket( + SocketStatistic.parse( + json[SocketJsonKey.socket.name] as Map, + )!, + json[SocketJsonKey.timelineMicrosBase.name] as int, + ); + } + int _timelineMicrosBase; SocketStatistic _socket; @@ -180,4 +191,53 @@ class Socket extends NetworkRequest { @override int get hashCode => id.hashCode; + + SocketStatistic get socketData => _socket; + + @override + Map toJson() { + return { + SocketJsonKey.timelineMicrosBase.name: _timelineMicrosBase, + SocketJsonKey.socket.name: _socket.toJson(), + }; + } +} + +extension on SocketStatistic { + Map toJson() { + return { + SocketJsonKey.id.name: id, + SocketJsonKey.startTime.name: startTime, + SocketJsonKey.endTime.name: endTime, + //TODO verify if these timings are in correct format + SocketJsonKey.lastReadTime.name: lastReadTime, + SocketJsonKey.lastWriteTime.name: lastWriteTime, + SocketJsonKey.socketType.name: socketType, + SocketJsonKey.address.name: address, + SocketJsonKey.port.name: port, + SocketJsonKey.readBytes.name: readBytes, + SocketJsonKey.writeBytes.name: writeBytes, + }; + } +} + +enum SocketJsonKey { + id, + startTime, + endTime, + lastReadTime, + lastWriteTime, + socketType, + address, + port, + readBytes, + writeBytes, + timelineMicrosBase, + socket, +} + +extension SocketExtension on List { + List get mapToSocketStatistics { + return map((socket) => socket._socket).toList(); + } } diff --git a/packages/devtools_app/lib/src/screens/network/network_screen.dart b/packages/devtools_app/lib/src/screens/network/network_screen.dart index bb63e84e328..ce1db458887 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart @@ -14,7 +14,6 @@ import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; import '../../shared/common_widgets.dart'; import '../../shared/config_specific/copy_to_clipboard/copy_to_clipboard.dart'; -import '../../shared/globals.dart'; import '../../shared/http/curl_command.dart'; import '../../shared/http/http_request_data.dart'; import '../../shared/primitives/utils.dart'; @@ -106,19 +105,6 @@ class _NetworkScreenBodyState extends State void didChangeDependencies() { super.didChangeDependencies(); if (!initController()) return; - unawaited(controller.startRecording()); - - cancelListeners(); - - addAutoDisposeListener( - serviceConnection.serviceManager.isolateManager.mainIsolate, - () { - if (serviceConnection.serviceManager.isolateManager.mainIsolate.value != - null) { - unawaited(controller.startRecording()); - } - }, - ); } @override @@ -133,7 +119,13 @@ class _NetworkScreenBodyState extends State Widget build(BuildContext context) { return Column( children: [ - _NetworkProfilerControls(controller: controller), + OfflineAwareControls( + controlsBuilder: (offline) => _NetworkProfilerControls( + controller: controller, + offline: offline, + ), + gaScreen: gac.network, + ), const SizedBox(height: intermediateSpacing), Expanded( child: _NetworkProfilerBody(controller: controller), @@ -148,12 +140,15 @@ class _NetworkScreenBodyState extends State class _NetworkProfilerControls extends StatefulWidget { const _NetworkProfilerControls({ required this.controller, + required this.offline, }); static const _includeTextWidth = 810.0; final NetworkController controller; + final bool offline; + @override State<_NetworkProfilerControls> createState() => _NetworkProfilerControlsState(); @@ -166,7 +161,6 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> @override void initState() { super.initState(); - _recording = widget.controller.recordingNotifier.value; addAutoDisposeListener(widget.controller.recordingNotifier, () { setState(() { @@ -183,7 +177,8 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> final hasRequests = widget.controller.filteredData.value.isNotEmpty; return Row( children: [ - StartStopRecordingButton( + if (!widget.offline) ...[ + StartStopRecordingButton( recording: _recording, onPressed: () async => await widget.controller.togglePolling(!_recording), @@ -211,7 +206,7 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> gaScreen: gac.network, gaSelection: gac.NetworkEvent.downloadAsHar.name, ), - const SizedBox(width: defaultSpacing), + const Spacer(), // TODO(kenz): fix focus issue when state is refreshed Expanded( child: SearchField( diff --git a/packages/devtools_app/lib/src/screens/network/offline_network_data.dart b/packages/devtools_app/lib/src/screens/network/offline_network_data.dart new file mode 100644 index 00000000000..f43bee4db7f --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/offline_network_data.dart @@ -0,0 +1,93 @@ +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:devtools_shared/devtools_shared.dart'; + +import '../../shared/http/http_request_data.dart'; +import '../network/network_controller.dart'; +import 'network_model.dart'; + +/// Class to encapsulate offline data for the [NetworkController]. +/// +/// It is responsible for serializing and deserializing offline network data. +class OfflineNetworkData with Serializable { + OfflineNetworkData({ + required this.httpRequestData, + required this.socketData, + this.selectedRequestId, + }); + + /// Creates an instance of [OfflineNetworkData] from a JSON map. + factory OfflineNetworkData.fromJson(Map json) { + final httpRequestJsonList = + json[_OfflineDataKeys.httpRequestData.name] as List?; + + // Deserialize httpRequestData + final httpRequestData = httpRequestJsonList + ?.map((e) { + if (e is Map) { + final requestData = + e[_OfflineDataKeys.request.name] as Map?; + return requestData != null + ? DartIOHttpRequestData.fromJson(requestData, null, null) + : null; + } + return null; + }) + .whereType() + .toList() ?? + []; + + // Deserialize socketData + final socketJsonList = + json[_OfflineDataKeys.socketData.name] as List?; + final socketData = socketJsonList + ?.map((e) { + if (e is Map) { + return Socket.fromJson(e); + } + return null; + }) + .whereType() + .toList() ?? + []; + + return OfflineNetworkData( + httpRequestData: httpRequestData, + selectedRequestId: + json[_OfflineDataKeys.selectedRequestId.name] as String?, + socketData: socketData, + ); + } + + bool get isEmpty => httpRequestData.isEmpty && socketData.isEmpty; + + /// List of current [DartIOHttpRequestData] network requests. + final List httpRequestData; + + /// The ID of the currently selected request, if any. + final String? selectedRequestId; + + /// The list of socket statistics for the offline network data. + final List socketData; + + /// Converts the current offline data to a JSON format. + @override + Map toJson() { + return { + _OfflineDataKeys.httpRequestData.name: + httpRequestData.map((e) => e.toJson()).toList(), + _OfflineDataKeys.selectedRequestId.name: selectedRequestId, + _OfflineDataKeys.socketData.name: + socketData.map((e) => e.toJson()).toList(), + }; + } +} + +enum _OfflineDataKeys { + httpRequestData, + selectedRequestId, + socketData, + request, +} 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 new file mode 100644 index 00000000000..3550770df5d --- /dev/null +++ b/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart @@ -0,0 +1,31 @@ +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +/// Calculates the size of the headers in bytes. +/// +/// Takes a map of headers [headers], where keys are header names and values +/// can be strings or lists of strings. Returns the size of the headers +/// in bytes or -1 if [headers] is null. +int calculateHeadersSize(Map? headers) { + if (headers == null) return -1; + + // Combine headers into a single string with CRLF endings + String headersString = headers.entries.map((entry) { + final key = entry.key; + var value = entry.value; + // If the value is a List, join it with a comma + if (value is List) { + value = value.join(', '); + } + return '$key: $value\r\n'; + }).join(); + + // Add final CRLF to indicate end of headers + headersString += '\r\n'; + + // Calculate the byte length of the headers string + return utf8.encode(headersString).length; +} diff --git a/packages/devtools_app/lib/src/shared/feature_flags.dart b/packages/devtools_app/lib/src/shared/feature_flags.dart index 8901a3b4195..29a4051638c 100644 --- a/packages/devtools_app/lib/src/shared/feature_flags.dart +++ b/packages/devtools_app/lib/src/shared/feature_flags.dart @@ -38,6 +38,9 @@ bool get enableBeta => enableExperiments || !isExternalBuild; const _kMemoryDisconnectExperience = bool.fromEnvironment('memory_disconnect_experience', defaultValue: true); +const _kNetworkOfflineExperiment = + bool.fromEnvironment('network_disconnect_experience', defaultValue: true); + // It is ok to have enum-like static only classes. // ignore: avoid_classes_with_only_static_members /// Flags to hide features under construction. @@ -62,6 +65,11 @@ abstract class FeatureFlags { /// https://github.com/flutter/devtools/issues/5606 static const memoryDisconnectExperience = _kMemoryDisconnectExperience; + /// Flag to enable offline data on network screen. + /// + /// https://github.com/flutter/devtools/issues/3806 + static const networkOffline = _kNetworkOfflineExperiment; + /// Flag to enable save/load for the Memory screen. /// /// https://github.com/flutter/devtools/issues/8019 diff --git a/packages/devtools_app/lib/src/shared/http/constants.dart b/packages/devtools_app/lib/src/shared/http/constants.dart new file mode 100644 index 00000000000..3ed1190b561 --- /dev/null +++ b/packages/devtools_app/lib/src/shared/http/constants.dart @@ -0,0 +1,81 @@ +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +enum HttpRequestDataKeys { + connectionInfo, + remoteAddress, + localPort, + contentLength, + startedDateTime, + time, + request, + method, + url, + httpVersion, + cookies, + headers, + queryString, + postData, + mimeType, + text, + headersSize, + bodySize, + followRedirects, + maxRedirects, + persistentConnection, + proxyDetails, + proxy, + type, + error, + response, + status, + statusCode, + statusText, + redirects, + redirectURL, + cache, + timings, + blocked, + dns, + connect, + send, + wait, + receive, + ssl, + connection, + comment, + isolateId, + uri, + id, + startTime, + events, + timestamp, + event, + compressionState, + isRedirect, + reasonPhrase, + queryParameters, + content, + size, + connectionId, + requestBody, + responseBody, + endTime, + arguments, + host, + username, + isDirect +} + +enum HttpRequestDataValues { + json, +} + +class HttpRequestDataDefaults { + static const none = 'None'; + static const error = 'Error'; + static const httpVersion = 'HTTP/2.0'; + static const json = 'json'; + static const httpProfileRequest = '@HttpProfileRequest'; +} 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 67bcc5b006f..e49b33e1e53 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 @@ -13,6 +13,7 @@ import 'package:vm_service/vm_service.dart'; import '../../screens/network/network_model.dart'; import '../globals.dart'; import '../primitives/utils.dart'; +import 'constants.dart'; import 'http.dart'; final _log = Logger('http_request_data'); @@ -51,12 +52,33 @@ class DartIOHttpRequestData extends NetworkRequest { Map? requestPostData, Map? responseContent, ) { + final isFullRequest = modifiedRequestData + .containsKey(HttpRequestDataKeys.requestBody.name) && + modifiedRequestData.containsKey(HttpRequestDataKeys.responseBody.name); + + final parsedRequest = isFullRequest + ? HttpProfileRequest.parse(modifiedRequestData) + : HttpProfileRequestRef.parse(modifiedRequestData); + + final responseBody = + responseContent?[HttpRequestDataKeys.text.name]?.toString(); + final requestBody = + requestPostData?[HttpRequestDataKeys.text.name]?.toString(); + return DartIOHttpRequestData( - HttpProfileRequestRef.parse(modifiedRequestData)!, - requestFullDataFromVmService: false, + parsedRequest!, + requestFullDataFromVmService: parsedRequest is! HttpProfileRequest, ) - .._responseBody = responseContent?['text'].toString() - .._requestBody = requestPostData?['text'].toString(); + .._responseBody = responseBody + .._requestBody = requestBody; + } + + @override + Map toJson() { + return { + HttpRequestDataKeys.request.name: + (_request as HttpProfileRequest).toJson(), + }; } static const _connectionInfoKey = 'connectionInfo'; @@ -336,3 +358,85 @@ class DartIOHttpRequestData extends NetworkRequest { startTimestamp, ); } + +extension HttpRequestExtension on List { + List get mapToHttpProfileRequests { + return map( + (httpRequestData) => httpRequestData._request as HttpProfileRequest, + ).toList(); + } +} + +extension HttpProfileRequestExtension on HttpProfileRequest { + Map toJson() { + return { + HttpRequestDataKeys.id.name: id, + HttpRequestDataKeys.method.name: method, + HttpRequestDataKeys.uri.name: uri.toString(), + HttpRequestDataKeys.startTime.name: startTime.microsecondsSinceEpoch, + HttpRequestDataKeys.endTime.name: endTime?.microsecondsSinceEpoch, + HttpRequestDataKeys.response.name: response?.toJson(), + HttpRequestDataKeys.request.name: request?.toJson(), + HttpRequestDataKeys.isolateId.name: isolateId, + HttpRequestDataKeys.events.name: events.map((e) => e.toJson()).toList(), + HttpRequestDataKeys.requestBody.name: requestBody?.toList(), + HttpRequestDataKeys.responseBody.name: responseBody?.toList(), + }; + } +} + +extension HttpProfileRequestDataExtension on HttpProfileRequestData { + Map toJson() { + return { + HttpRequestDataKeys.headers.name: headers, + HttpRequestDataKeys.followRedirects.name: followRedirects, + HttpRequestDataKeys.maxRedirects.name: maxRedirects, + HttpRequestDataKeys.connectionInfo.name: connectionInfo, + HttpRequestDataKeys.contentLength.name: contentLength, + HttpRequestDataKeys.cookies.name: cookies, + HttpRequestDataKeys.persistentConnection.name: persistentConnection, + HttpRequestDataKeys.proxyDetails.name: proxyDetails, + }; + } +} + +extension HttpProfileResponseDataExtension on HttpProfileResponseData { + Map toJson() { + return { + HttpRequestDataKeys.startTime.name: startTime?.microsecondsSinceEpoch, + HttpRequestDataKeys.endTime.name: endTime?.microsecondsSinceEpoch, + HttpRequestDataKeys.headers.name: headers, + HttpRequestDataKeys.compressionState.name: compressionState, + HttpRequestDataKeys.connectionInfo.name: connectionInfo, + HttpRequestDataKeys.contentLength.name: contentLength, + HttpRequestDataKeys.cookies.name: cookies, + HttpRequestDataKeys.isRedirect.name: isRedirect, + HttpRequestDataKeys.persistentConnection.name: persistentConnection, + HttpRequestDataKeys.reasonPhrase.name: reasonPhrase, + HttpRequestDataKeys.redirects.name: redirects, + HttpRequestDataKeys.statusCode.name: statusCode, + HttpRequestDataKeys.error.name: error, + }; + } +} + +extension HttpProfileRequestEventExtension on HttpProfileRequestEvent { + Map toJson() { + return { + HttpRequestDataKeys.event.name: event, + HttpRequestDataKeys.timestamp.name: timestamp.microsecondsSinceEpoch, + HttpRequestDataKeys.arguments.name: arguments, + }; + } +} + +extension HttpProfileProxyDataExtension on HttpProfileProxyData { + Map toJson() { + return { + HttpRequestDataKeys.host.name: host, + HttpRequestDataKeys.username.name: username, + HttpRequestDataKeys.isDirect.name: isDirect, + HttpRequestDataKeys.host.name: port, + }; + } +} diff --git a/packages/devtools_app/lib/src/shared/screen.dart b/packages/devtools_app/lib/src/shared/screen.dart index 74c89719f98..c0dbf4c13eb 100644 --- a/packages/devtools_app/lib/src/shared/screen.dart +++ b/packages/devtools_app/lib/src/shared/screen.dart @@ -76,6 +76,10 @@ enum ScreenMetaData { iconAsset: 'icons/app_bar/network.png', requiresDartVm: true, tutorialVideoTimestamp: '?t=547', + // ignore: avoid_redundant_argument_values, false positive + requiresConnection: FeatureFlags.networkOffline, + // ignore: avoid_redundant_argument_values, false positive + worksWithOfflineData: FeatureFlags.networkOffline, ), logging( 'logging',