implemented har feature (#7970)

diff --git a/packages/devtools_app/lib/src/screens/network/constants.dart b/packages/devtools_app/lib/src/screens/network/constants.dart
new file mode 100644
index 0000000..b6e016e
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/network/constants.dart
@@ -0,0 +1,88 @@
+// 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 NetworkEventKeys {
+  log,
+  version,
+  creator,
+  name,
+  pages,
+  startedDateTime,
+  id,
+  title,
+  pageTimings,
+  onContentLoad,
+  onLoad,
+  entries,
+  pageref,
+  time,
+  request,
+  method,
+  url,
+  httpVersion,
+  cookies,
+  headers,
+  queryString,
+  postData,
+  mimeType,
+  text,
+  headersSize,
+  bodySize,
+  response,
+  status,
+  statusText,
+  content,
+  size,
+  redirectURL,
+  cache,
+  timings,
+  blocked,
+  dns,
+  connect,
+  send,
+  wait,
+  receive,
+  ssl,
+  serverIPAddress,
+  connection,
+  comment,
+  value,
+  uri,
+  redirects,
+  path,
+  domain,
+  expires,
+  httpOnly,
+  secure,
+  reasonPhrase,
+}
+
+class NetworkEventDefaults {
+  static const logVersion = '1.2';
+  static const creatorName = 'devtools';
+  static const onContentLoad = -1;
+  static const onLoad = -1;
+  static const httpVersion = 'HTTP/1.1';
+  static const responseHttpVersion = 'http/2.0';
+  static const blocked = -1;
+  static const dns = -1;
+  static const connect = -1;
+  static const send = 1;
+  static const receive = 1;
+  static const ssl = -1;
+}
+
+class NetworkEventCustomFieldKeys {
+  static const isolateId = '_isolateId';
+  static const id = '_id';
+  static const startTime = '_startTime';
+  static const events = '_events';
+}
+
+enum NetworkEventCustomFieldRemappedKeys {
+  isolateId,
+  id,
+  startTime,
+  events,
+}
diff --git a/packages/devtools_app/lib/src/screens/network/har_builder.dart b/packages/devtools_app/lib/src/screens/network/har_builder.dart
new file mode 100644
index 0000000..6e37ca9
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/network/har_builder.dart
@@ -0,0 +1,42 @@
+// 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 '../../shared/http/http_request_data.dart';
+import '../../shared/utils.dart';
+import 'constants.dart';
+import 'har_data_entry.dart';
+
+/// Builds a HAR (HTTP Archive) object from a list of HTTP requests.
+///
+/// The HAR format is a JSON-based format used for logging a web browser's
+/// interaction with a site. It is useful for performance analysis and
+/// debugging. This function constructs the HAR object based on the 1.2
+/// specification.
+///
+/// For more details on the HAR format, see the [HAR 1.2 Specification](https://github.com/ahmadnassri/har-spec/blob/master/versions/1.2.md).
+///
+/// Parameters:
+/// - [httpRequests]: A list of [DartIOHttpRequestData] data.
+///
+/// Returns:
+/// - A Map representing the HAR object.
+Map<String, Object?> buildHar(List<DartIOHttpRequestData> httpRequests) {
+  // Build the creator
+  final creator = <String, Object?>{
+    NetworkEventKeys.name.name: NetworkEventDefaults.creatorName,
+    NetworkEventKeys.version.name: devToolsVersion,
+  };
+
+  // Build the entries
+  final entries = httpRequests.map((e) => HarDataEntry.toJson(e)).toList();
+
+  // Assemble the final HAR object
+  return <String, Object?>{
+    NetworkEventKeys.log.name: <String, Object?>{
+      NetworkEventKeys.version.name: NetworkEventDefaults.logVersion,
+      NetworkEventKeys.creator.name: creator,
+      NetworkEventKeys.entries.name: entries,
+    },
+  };
+}
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
new file mode 100644
index 0000000..be71e04
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/network/har_data_entry.dart
@@ -0,0 +1,292 @@
+// 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';
+
+import '../../shared/http/http_request_data.dart';
+import '../../shared/primitives/utils.dart';
+import 'constants.dart';
+
+/// A class representing a single entry in the HTTP Archive (HAR) format.
+///
+/// This class is used to parse and handle data from HAR entries, converting
+/// them into [DartIOHttpRequestData] objects which represent HTTP request data.
+///
+/// This class also provides functionality to convert its instances back to
+/// JSON format, maintaining the HAR entry structure.
+class HarDataEntry {
+  HarDataEntry(this.request);
+
+  /// Creates an instance of [HarDataEntry] from a JSON object.
+  ///
+  /// This factory constructor expects the [json] parameter to be a Map
+  /// representing a single HAR entry.
+  factory HarDataEntry.fromJson(Map<String, Object?> json) {
+    _convertHeaders(json);
+
+    final modifiedRequestData = _remapCustomFieldKeys(json);
+
+    // Retrieving url, method from requestData
+    final requestData = modifiedRequestData[NetworkEventKeys.request.name]
+        as Map<String, Object?>;
+    modifiedRequestData[NetworkEventKeys.uri.name] =
+        requestData[NetworkEventKeys.url.name];
+    modifiedRequestData[NetworkEventKeys.method.name] =
+        requestData[NetworkEventKeys.method.name];
+
+    // Adding missing keys which are mandatory for parsing
+    final responseData = modifiedRequestData[NetworkEventKeys.response.name]
+        as Map<String, Object?>;
+    responseData[NetworkEventKeys.redirects.name] = <Map<String, Object?>>[];
+    final requestPostData = responseData[NetworkEventKeys.postData.name];
+    final responseContent = responseData[NetworkEventKeys.content.name];
+
+    return HarDataEntry(
+      DartIOHttpRequestData.fromJson(
+        modifiedRequestData,
+        requestPostData as Map<String, Object?>?,
+        responseContent as Map<String, Object?>?,
+      ),
+    );
+  }
+
+  final DartIOHttpRequestData request;
+
+  /// Converts the instance to a JSON object.
+  ///
+  /// This method returns a Map representing a single HAR entry, suitable for
+  /// serialization.
+  static Map<String, Object?> toJson(DartIOHttpRequestData e) {
+    // Implement the logic to convert DartIOHttpRequestData to HAR entry format
+    final requestCookies = e.requestCookies.map((cookie) {
+      return <String, Object?>{
+        NetworkEventKeys.name.name: cookie.name,
+        NetworkEventKeys.value.name: cookie.value,
+        NetworkEventKeys.path.name: cookie.path,
+        NetworkEventKeys.domain.name: cookie.domain,
+        NetworkEventKeys.expires.name:
+            cookie.expires?.toUtc().toIso8601String(),
+        NetworkEventKeys.httpOnly.name: cookie.httpOnly,
+        NetworkEventKeys.secure.name: cookie.secure,
+      };
+    }).toList();
+
+    final requestHeaders = e.requestHeaders?.entries.map((header) {
+      var value = header.value;
+      if (value is List) {
+        value = value.first;
+      }
+      return <String, Object?>{
+        NetworkEventKeys.name.name: header.key,
+        NetworkEventKeys.value.name: value,
+      };
+    }).toList();
+
+    final queryString = Uri.parse(e.uri).queryParameters.entries.map((param) {
+      return <String, Object?>{
+        NetworkEventKeys.name.name: param.key,
+        NetworkEventKeys.value.name: param.value,
+      };
+    }).toList();
+
+    final responseCookies = e.responseCookies.map((cookie) {
+      return <String, Object?>{
+        NetworkEventKeys.name.name: cookie.name,
+        NetworkEventKeys.value.name: cookie.value,
+        NetworkEventKeys.path.name: cookie.path,
+        NetworkEventKeys.domain.name: cookie.domain,
+        NetworkEventKeys.expires.name:
+            cookie.expires?.toUtc().toIso8601String(),
+        NetworkEventKeys.httpOnly.name: cookie.httpOnly,
+        NetworkEventKeys.secure.name: cookie.secure,
+      };
+    }).toList();
+
+    final responseHeaders = e.responseHeaders?.entries.map((header) {
+      var value = header.value;
+      if (value is List) {
+        value = value.first;
+      }
+      return <String, Object?>{
+        NetworkEventKeys.name.name: header.key,
+        NetworkEventKeys.value.name: value,
+      };
+    }).toList();
+
+    return <String, Object?>{
+      NetworkEventKeys.startedDateTime.name:
+          e.startTimestamp.toUtc().toIso8601String(),
+      NetworkEventKeys.time.name: e.duration?.inMilliseconds,
+      // Request
+      NetworkEventKeys.request.name: <String, Object?>{
+        NetworkEventKeys.method.name: e.method.toUpperCase(),
+        NetworkEventKeys.url.name: e.uri.toString(),
+        NetworkEventKeys.httpVersion.name: NetworkEventDefaults.httpVersion,
+        NetworkEventKeys.cookies.name: requestCookies,
+        NetworkEventKeys.headers.name: requestHeaders,
+        NetworkEventKeys.queryString.name: queryString,
+        NetworkEventKeys.postData.name: <String, Object?>{
+          NetworkEventKeys.mimeType.name: e.contentType,
+          NetworkEventKeys.text.name: e.requestBody,
+        },
+        NetworkEventKeys.headersSize.name:
+            _calculateHeadersSize(e.requestHeaders),
+        NetworkEventKeys.bodySize.name: _calculateBodySize(e.requestBody),
+      },
+      // Response
+      NetworkEventKeys.response.name: <String, Object?>{
+        NetworkEventKeys.status.name: e.status,
+        NetworkEventKeys.statusText.name:
+            e.general[NetworkEventKeys.reasonPhrase.name] ?? '',
+        NetworkEventKeys.httpVersion.name:
+            NetworkEventDefaults.responseHttpVersion,
+        NetworkEventKeys.cookies.name: responseCookies,
+        NetworkEventKeys.headers.name: responseHeaders,
+        NetworkEventKeys.content.name: <String, Object?>{
+          NetworkEventKeys.size.name: e.responseBody?.length,
+          NetworkEventKeys.mimeType.name: e.type,
+          NetworkEventKeys.text.name: e.responseBody,
+        },
+        NetworkEventKeys.redirectURL.name: '',
+        NetworkEventKeys.headersSize.name:
+            _calculateHeadersSize(e.responseHeaders),
+        NetworkEventKeys.bodySize.name: _calculateBodySize(e.responseBody),
+      },
+      // Cache
+      NetworkEventKeys.cache.name: <String, Object?>{},
+      NetworkEventKeys.timings.name: <String, Object?>{
+        NetworkEventKeys.blocked.name: NetworkEventDefaults.blocked,
+        NetworkEventKeys.dns.name: NetworkEventDefaults.dns,
+        NetworkEventKeys.connect.name: NetworkEventDefaults.connect,
+        NetworkEventKeys.send.name: NetworkEventDefaults.send,
+        NetworkEventKeys.wait.name: e.duration?.inMilliseconds ?? 0,
+        NetworkEventKeys.receive.name: NetworkEventDefaults.receive,
+        NetworkEventKeys.ssl.name: NetworkEventDefaults.ssl,
+      },
+      NetworkEventKeys.connection.name: e.hashCode.toString(),
+      NetworkEventKeys.comment.name: '',
+
+      // Custom fields
+      // har spec requires underscore to be added for custom fields, hence removing them
+      // Note: the 'isolateId' field is kept empty because DartIOHttpRequestData does not expose it
+      // (but it is required by HttpProfileRequestRef.parse)
+      NetworkEventCustomFieldKeys.isolateId: '',
+      NetworkEventCustomFieldKeys.id: e.id,
+      NetworkEventCustomFieldKeys.startTime:
+          e.startTimestamp.microsecondsSinceEpoch,
+      // Note: The 'events' field is kept empty because DartIOHttpRequestData does not expose it
+      // (but it is required by HttpProfileRequestRef.parse)
+      NetworkEventCustomFieldKeys.events: [],
+    };
+  }
+
+  /// Returns the original [DartIOHttpRequestData] that this HAR entry was created from or parsed into.
+  DartIOHttpRequestData toDartIOHttpRequest() {
+    return request;
+  }
+
+  static Map<String, Object?> _convertHeadersListToMap(
+    List<Object?> serializedHeaders,
+  ) {
+    final transformedHeaders = <String, Object?>{};
+
+    for (final header in serializedHeaders) {
+      if (header is Map<String, Object?>) {
+        final key = header[NetworkEventKeys.name.name] as String?;
+        final value = header[NetworkEventKeys.value.name];
+
+        if (key != null) {
+          if (transformedHeaders.containsKey(key)) {
+            if (transformedHeaders[key] is List) {
+              (transformedHeaders[key] as List).add(value);
+            } else {
+              transformedHeaders[key] = [transformedHeaders[key], value];
+            }
+          } else {
+            transformedHeaders[key] = value;
+          }
+        }
+      }
+    }
+
+    return transformedHeaders;
+  }
+
+  /// Convert list of headers to map
+  static void _convertHeaders(Map<String, Object?> requestData) {
+    final reqData =
+        requestData[NetworkEventKeys.request.name] as Map<String, Object?>;
+    // Request Headers
+    if (reqData[NetworkEventKeys.headers.name] is List) {
+      reqData[NetworkEventKeys.headers.name] = _convertHeadersListToMap(
+        (reqData[NetworkEventKeys.headers.name]) as List<Object?>,
+      );
+    }
+
+    // Response Headers
+    final resData =
+        requestData[NetworkEventKeys.response.name] as Map<String, Object?>;
+    if (resData[NetworkEventKeys.headers.name] is List) {
+      resData[NetworkEventKeys.headers.name] = _convertHeadersListToMap(
+        (resData[NetworkEventKeys.headers.name]) as List<Object?>,
+      );
+    }
+  }
+
+  /// Removing underscores from custom fields
+  static Map<String, Object?> _remapCustomFieldKeys(
+    Map<String, Object?> originalMap,
+  ) {
+    final replacementMap = {
+      NetworkEventCustomFieldKeys.isolateId:
+          NetworkEventCustomFieldRemappedKeys.isolateId.name,
+      NetworkEventCustomFieldKeys.id:
+          NetworkEventCustomFieldRemappedKeys.id.name,
+      NetworkEventCustomFieldKeys.startTime:
+          NetworkEventCustomFieldRemappedKeys.startTime.name,
+      NetworkEventCustomFieldKeys.events:
+          NetworkEventCustomFieldRemappedKeys.events.name,
+    };
+
+    final convertedMap = <String, Object?>{};
+
+    originalMap.forEach((key, value) {
+      if (replacementMap.containsKey(key)) {
+        convertedMap[replacementMap[key]!] = value;
+      } else {
+        convertedMap[key] = value;
+      }
+    });
+
+    return convertedMap;
+  }
+}
+
+int _calculateHeadersSize(Map<String, Object?>? 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<String>) {
+      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;
+  }
+  return utf8.encode(requestBody!).length;
+}
diff --git a/packages/devtools_app/lib/src/screens/network/har_network_data.dart b/packages/devtools_app/lib/src/screens/network/har_network_data.dart
new file mode 100644
index 0000000..60b02ac
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/network/har_network_data.dart
@@ -0,0 +1,60 @@
+// 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 'constants.dart';
+import 'har_builder.dart';
+import 'har_data_entry.dart';
+
+/// A class that represents network data in the HTTP Archive (HAR) format.
+///
+/// This class implements the [Serializable] interface, allowing instances to
+/// be serialized to and from JSON.
+class HarNetworkData with Serializable {
+  /// Creates an instance of [HarNetworkData] with a list of [DartIOHttpRequestData] requests.
+  ///
+  /// The [requests] parameter should contain the list of [DartIOHttpRequestData] request data.
+  HarNetworkData(this.requests);
+
+  /// Creates an instance of [HarNetworkData] from a JSON object.
+  ///
+  /// This factory constructor expects the [json] parameter to be a Map
+  /// representing the HAR data, with a structure containing a 'log' key,
+  /// which in turn contains an 'entries' key. Each entry in the 'entries'
+  /// list should be a Map representing an HTTP request.
+  ///
+  /// ```dart
+  /// final harData = HarNetworkData.fromJson(json);
+  /// ```
+  factory HarNetworkData.fromJson(Map<String, Object?> json) {
+    final entries = ((json[NetworkEventKeys.log.name]
+                as Map<String, Object?>)[NetworkEventKeys.entries.name]
+            as List<Object?>)
+        .map(
+          (entryJson) =>
+              HarDataEntry.fromJson(entryJson as Map<String, Object?>)
+                  .toDartIOHttpRequest(),
+        )
+        .toList();
+
+    return HarNetworkData(entries);
+  }
+
+  /// The list of [DartIOHttpRequestData] request data.
+  final List<DartIOHttpRequestData> requests;
+
+  /// Converts the instance to a JSON object.
+  ///
+  /// This method returns a Map representing the HAR data, suitable for
+  /// serialization.
+  ///
+  /// ```dart
+  /// final json = harData.toJson();
+  /// ```
+  @override
+  Map<String, Object?> toJson() {
+    return buildHar(requests);
+  }
+}
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 cf3c1dd..dc76ad1 100644
--- a/packages/devtools_app/lib/src/screens/network/network_controller.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart
@@ -3,11 +3,13 @@
 // found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:convert';
 
 import 'package:devtools_app_shared/utils.dart';
 import 'package:flutter/foundation.dart';
 import 'package:vm_service/vm_service.dart';
 
+import '../../shared/config_specific/import_export/import_export.dart';
 import '../../shared/config_specific/logger/allowed_error.dart';
 import '../../shared/globals.dart';
 import '../../shared/http/http_request_data.dart';
@@ -16,6 +18,7 @@
 import '../../shared/ui/filter.dart';
 import '../../shared/ui/search.dart';
 import '../../shared/utils.dart';
+import 'har_network_data.dart';
 import 'network_model.dart';
 import 'network_screen.dart';
 import 'network_service.dart';
@@ -56,6 +59,29 @@
     );
     subscribeToFilterChanges();
   }
+  List<DartIOHttpRequestData>? _httpRequests;
+
+  String? exportAsHarFile() {
+    _httpRequests =
+        filteredData.value.whereType<DartIOHttpRequestData>().toList();
+
+    if (_httpRequests.isNullOrEmpty) {
+      debugPrint('No valid request data to export');
+      return '';
+    }
+
+    try {
+      // Build the HAR object
+      final har = HarNetworkData(_httpRequests!);
+      return ExportController().downloadFile(
+        json.encode(har.toJson()),
+        type: ExportFileType.har,
+      );
+    } catch (e) {
+      debugPrint('Exception in export $e');
+    }
+    return null;
+  }
 
   static const methodFilterId = 'network-method-filter';
 
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 7464e7a..12e0d3a 100644
--- a/packages/devtools_app/lib/src/screens/network/network_screen.dart
+++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart
@@ -234,6 +234,14 @@
           onPressed: widget.controller.clear,
         ),
         const SizedBox(width: defaultSpacing),
+        DownloadButton(
+          minScreenWidthForTextBeforeScaling:
+              _NetworkProfilerControls._includeTextWidth,
+          onPressed: widget.controller.exportAsHarFile,
+          gaScreen: gac.network,
+          gaSelection: gac.NetworkEvent.downloadAsHar.name,
+        ),
+        const SizedBox(width: defaultSpacing),
         const Expanded(child: SizedBox()),
         // TODO(kenz): fix focus issue when state is refreshed
         SearchField<NetworkController>(
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants.dart
index 28dbc03..420f31f 100644
--- a/packages/devtools_app/lib/src/shared/analytics/constants.dart
+++ b/packages/devtools_app/lib/src/shared/analytics/constants.dart
@@ -12,6 +12,7 @@
 part 'constants/_deep_links_constants.dart';
 part 'constants/_extension_constants.dart';
 part 'constants/_memory_constants.dart';
+part 'constants/_network_constants.dart';
 part 'constants/_logging_constants.dart';
 part 'constants/_performance_constants.dart';
 part 'constants/_vs_code_sidebar_constants.dart';
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_network_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_network_constants.dart
new file mode 100644
index 0000000..5ac4bbf
--- /dev/null
+++ b/packages/devtools_app/lib/src/shared/analytics/constants/_network_constants.dart
@@ -0,0 +1,9 @@
+// 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.
+
+part of '../constants.dart';
+
+enum NetworkEvent {
+  downloadAsHar,
+}
diff --git a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart
index d90d0bf..52ec275 100644
--- a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart
+++ b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart
@@ -113,7 +113,8 @@
   json,
   csv,
   yaml,
-  data;
+  data,
+  har;
 
   @override
   String toString() => name;
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 1196793..bef81dc 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
@@ -46,6 +46,19 @@
     }
   }
 
+  factory DartIOHttpRequestData.fromJson(
+    Map<String, Object?> modifiedRequestData,
+    Map<String, Object?>? requestPostData,
+    Map<String, Object?>? responseContent,
+  ) {
+    return DartIOHttpRequestData(
+      HttpProfileRequestRef.parse(modifiedRequestData)!,
+      requestFullDataFromVmService: false,
+    )
+      .._responseBody = responseContent?['text'].toString()
+      .._requestBody = requestPostData?['text'].toString();
+  }
+
   static const _connectionInfoKey = 'connectionInfo';
   static const _contentTypeKey = 'content-type';
   static const _localPortKey = 'localPort';
@@ -147,7 +160,7 @@
   }
 
   /// Extracts the extension from [mime], with overrides for shortened
-  /// extenstions of common types (e.g., jpe -> jpeg).
+  /// extensions of common types (e.g., jpe -> jpeg).
   String _extensionFromMime(String mime) {
     final extension = extensionFromMime(mime);
     if (extension == 'jpe') {
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index c848254..90c581f 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -38,6 +38,8 @@
 
 ## Network profiler updates
 
+* Added support to export network requests to HAR file.
+   - [#7970](https://github.com/flutter/devtools/pull/7970)
 * Fixed issue where socket statistics were being reported as web sockets. - [#8061](https://github.com/flutter/devtools/pull/8061)
 
     ![Network profiler correctly displaying socket statistics](images/socket-profiling.png "Network profiler correctly displaying socket statistics")
diff --git a/packages/devtools_app/test/network/har_network_test.dart b/packages/devtools_app/test/network/har_network_test.dart
new file mode 100644
index 0000000..0520c93
--- /dev/null
+++ b/packages/devtools_app/test/network/har_network_test.dart
@@ -0,0 +1,102 @@
+// 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';
+import 'dart:io';
+
+import 'package:devtools_app/src/screens/network/constants.dart';
+import 'package:devtools_app/src/screens/network/har_data_entry.dart';
+import 'package:devtools_app/src/screens/network/har_network_data.dart';
+
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  final file = File('test/network/sample_requests.json');
+  final fileContent = file.readAsStringSync();
+  final jsonData = jsonDecode(fileContent) as Map<String, Object?>;
+
+  group('HarNetworkData', () {
+    test('toJson serializes correctly', () {
+      // Parse the HAR data
+      final harData = HarNetworkData.fromJson(jsonData);
+
+      // Serialize the HAR data back to JSON
+      final json = harData.toJson();
+
+      // Verify the serialization
+      expect(json['log'], isNotNull);
+      final log = json['log'] as Map<String, Object?>?;
+      expect(log?['version'], '1.2');
+      expect((log?['creator'] as Map<String, Object?>)['name'], 'devtools');
+
+      final entries = log?['entries'] as List<Object?>?;
+      expect(entries?.length, 2);
+
+      final entry = entries?[0] as Map<String, Object?>?;
+      expect(entry?['startedDateTime'], '2024-07-11T13:19:35.156Z');
+      expect((entry?['request'] as Map<String, Object?>)['method'], 'GET');
+      expect(
+        (entry?['request'] as Map<String, Object?>)['url'],
+        'https://jsonplaceholder.typicode.com/albums/1',
+      );
+      expect(
+        (entry?['request'] as Map<String, Object?>)['httpVersion'],
+        'HTTP/1.1',
+      );
+      expect((entry?['request'] as Map<String, Object?>)['cookies'], isEmpty);
+
+      expect(entry?['cache'], isEmpty);
+      expect((entry?['timings'] as Map<String, Object?>)['blocked'], -1);
+      expect((entry?['timings'] as Map<String, Object?>)['dns'], -1);
+      expect((entry?['timings'] as Map<String, Object?>)['connect'], -1);
+      expect((entry?['timings'] as Map<String, Object?>)['send'], 1);
+      expect((entry?['timings'] as Map<String, Object?>)['receive'], 1);
+      expect((entry?['timings'] as Map<String, Object?>)['ssl'], -1);
+      expect(entry?['comment'], '');
+    });
+  });
+
+  group('HarDataEntry', () {
+    test('fromJson parses correctly', () {
+      final entryJson =
+          ((jsonData['log'] as Map<String, Object?>)['entries'] as List).first
+              as Map<String, Object?>;
+      final harDataEntry = HarDataEntry.fromJson(entryJson);
+
+      expect(
+        harDataEntry.request.uri.toString(),
+        'https://jsonplaceholder.typicode.com/albums/1',
+      );
+      expect(harDataEntry.request.method, 'GET');
+      expect(harDataEntry.request.requestHeaders, isNotEmpty);
+      expect(harDataEntry.request.requestCookies, isEmpty);
+    });
+
+    test('toJson serializes correctly', () {
+      final entryJson =
+          ((jsonData['log'] as Map<String, Object?>)['entries'] as List).first
+              as Map<String, Object?>;
+      final harDataEntry = HarDataEntry.fromJson(entryJson);
+      final json = HarDataEntry.toJson(harDataEntry.request);
+
+      expect(json['startedDateTime'], '2024-07-11T13:19:35.156Z');
+      expect(json['request'], isNotNull);
+      final request = json['request'] as Map<String, Object?>?;
+      expect(request?['method'], 'GET');
+      expect(request?['url'], 'https://jsonplaceholder.typicode.com/albums/1');
+      expect(request?['httpVersion'], 'HTTP/1.1');
+      expect(request?['cookies'], isEmpty);
+
+      expect(json['cache'], isEmpty);
+      final timings = json['timings'] as Map<String, Object?>?;
+      expect(timings?['blocked'], NetworkEventDefaults.blocked);
+      expect(timings?['dns'], NetworkEventDefaults.dns);
+      expect(timings?['connect'], NetworkEventDefaults.connect);
+      expect(timings?['send'], 1);
+      expect(timings?['receive'], 1);
+      expect(timings?['ssl'], NetworkEventDefaults.ssl);
+      expect(json['comment'], '');
+    });
+  });
+}
diff --git a/packages/devtools_app/test/network/sample_requests.json b/packages/devtools_app/test/network/sample_requests.json
new file mode 100644
index 0000000..9d54338
--- /dev/null
+++ b/packages/devtools_app/test/network/sample_requests.json
@@ -0,0 +1,228 @@
+{
+  "log": {
+    "version": "1.2",
+    "creator": {
+      "name": "devtools",
+      "version": "2.38.0-dev.0"
+    },
+    "entries": [
+      {
+        "startedDateTime": "2024-07-11T13:19:35.156Z",
+        "time": 313,
+        "request": {
+          "method": "GET",
+          "url": "https://jsonplaceholder.typicode.com/albums/1",
+          "httpVersion": "HTTP/1.1",
+          "cookies": [],
+          "headers": { "name": "x-ratelimit-reset",
+            "value": "1709091028"},
+          "queryString": [],
+          "postData": {
+            "mimeType": "[application/json; charset=utf-8]",
+            "text": null
+          },
+          "headersSize": 120,
+          "bodySize": 0
+        },
+        "response": {
+          "status": "200",
+          "statusText": "",
+          "httpVersion": "http/2.0",
+          "cookies": [],
+          "headers": [{"": ""}],
+          "content": {
+            "size": 0,
+            "mimeType": "json",
+            "text": ""
+          },
+          "redirectURL": "",
+          "headersSize": 1164,
+          "bodySize": 0
+        },
+        "cache": {},
+        "timings": {
+          "blocked": -1,
+          "dns": -1,
+          "connect": -1,
+          "send": 1,
+          "wait": 313,
+          "receive": 1,
+          "ssl": -1
+        },
+        "connection": "525659655",
+        "comment": "",
+        "_isolateId": "",
+        "_id": "-2285651906968511108",
+        "_startTime": 1720703975156000,
+        "_events": []
+      },
+      {
+        "startedDateTime": "2024-07-11T13:19:37.155Z",
+        "time": 182,
+        "request": {
+          "method": "GET",
+          "url": "https://jsonplaceholder.typicode.com/albums/1",
+          "httpVersion": "HTTP/1.1",
+          "cookies": [],
+          "headers": [
+            {
+              "name": "user-agent",
+              "value": "Dart/3.4 (dart:io)"
+            },
+            {
+              "name": "accept-encoding",
+              "value": "gzip"
+            },
+            {
+              "name": "content-length",
+              "value": "0"
+            },
+            {
+              "name": "host",
+              "value": "jsonplaceholder.typicode.com"
+            }
+          ],
+          "queryString": [],
+          "postData": {
+            "mimeType": "[application/json; charset=utf-8]",
+            "text": null
+          },
+          "headersSize": 120,
+          "bodySize": 0
+        },
+        "response": {
+          "status": "200",
+          "statusText": "",
+          "httpVersion": "http/2.0",
+          "cookies": [],
+          "headers": [
+            {
+              "name": "x-ratelimit-reset",
+              "value": "1709091028"
+            },
+            {
+              "name": "x-ratelimit-limit",
+              "value": "1000"
+            },
+            {
+              "name": "date",
+              "value": "Thu, 11 Jul 2024 13:19:37 GMT"
+            },
+            {
+              "name": "transfer-encoding",
+              "value": "chunked"
+            },
+            {
+              "name": "vary",
+              "value": "Origin, Accept-Encoding"
+            },
+            {
+              "name": "content-encoding",
+              "value": "gzip"
+            },
+            {
+              "name": "x-ratelimit-remaining",
+              "value": "999"
+            },
+            {
+              "name": "pragma",
+              "value": "no-cache"
+            },
+            {
+              "name": "server",
+              "value": "cloudflare"
+            },
+            {
+              "name": "reporting-endpoints",
+              "value": "heroku-nel=https://nel.heroku.com/reports?ts=1709090976&sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d&s=YpsV0iwpAFY4lRLb4CpTkx%2B929Gjbe5%2BZ7X1RzUnIzs%3D"
+            },
+            {
+              "name": "cf-ray",
+              "value": "8a1916135be93c5e-BOM"
+            },
+            {
+              "name": "etag",
+              "value": "W/\"40-74G1+b66MteeTYAz6G+NybtDGFA\""
+            },
+            {
+              "name": "connection",
+              "value": "keep-alive"
+            },
+            {
+              "name": "cache-control",
+              "value": "max-age=43200"
+            },
+            {
+              "name": "age",
+              "value": "3993"
+            },
+            {
+              "name": "report-to",
+              "value": "{\"group\":\"heroku-nel\",\"max_age\":3600,\"endpoints\":[{\"url\":\"https://nel.heroku.com/reports?ts=1709090976&sid=e11707d5-02a7-43ef-b45e-2cf4d2036f7d&s=YpsV0iwpAFY4lRLb4CpTkx%2B929Gjbe5%2BZ7X1RzUnIzs%3D\"}]}"
+            },
+            {
+              "name": "cf-cache-status",
+              "value": "HIT"
+            },
+            {
+              "name": "content-type",
+              "value": "application/json; charset=utf-8"
+            },
+            {
+              "name": "access-control-allow-credentials",
+              "value": "true"
+            },
+            {
+              "name": "x-powered-by",
+              "value": "Express"
+            },
+            {
+              "name": "alt-svc",
+              "value": "h3=\":443\"; ma=86400"
+            },
+            {
+              "name": "nel",
+              "value": "{\"report_to\":\"heroku-nel\",\"max_age\":3600,\"success_fraction\":0.005,\"failure_fraction\":0.05,\"response_headers\":[\"Via\"]}"
+            },
+            {
+              "name": "via",
+              "value": "1.1 vegur"
+            },
+            {
+              "name": "x-content-type-options",
+              "value": "nosniff"
+            },
+            {
+              "name": "expires",
+              "value": "-1"
+            }
+          ],
+          "content": {
+            "size": 0,
+            "mimeType": "json",
+            "text": ""
+          },
+          "redirectURL": "",
+          "headersSize": 1164,
+          "bodySize": 0
+        },
+        "cache": {},
+        "timings": {
+          "blocked": -1,
+          "dns": -1,
+          "connect": -1,
+          "send": 1,
+          "wait": 182,
+          "receive": 1,
+          "ssl": -1
+        },
+        "connection": "249624739",
+        "comment": "",
+        "_isolateId": "",
+        "_id": "-2285651906968511106",
+        "_startTime": 1720703977155000,
+        "_events": []
+      }
+    ]
+  }
+}
\ No newline at end of file