Integrate cronet_http with `package:http_profile` (#1167)

diff --git a/pkgs/cronet_http/CHANGELOG.md b/pkgs/cronet_http/CHANGELOG.md
index 188b3cd..fd2acca 100644
--- a/pkgs/cronet_http/CHANGELOG.md
+++ b/pkgs/cronet_http/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 1.3.0
+
+* Add integration to the
+  [DevTools Network View](https://docs.flutter.dev/tools/devtools/network).
+
 ## 1.2.1
 
 * Upgrade `package:jni` to 0.9.2 to fix the build error in the latest versions
diff --git a/pkgs/cronet_http/example/integration_test/client_profile_test.dart b/pkgs/cronet_http/example/integration_test/client_profile_test.dart
new file mode 100644
index 0000000..3e17327
--- /dev/null
+++ b/pkgs/cronet_http/example/integration_test/client_profile_test.dart
@@ -0,0 +1,288 @@
+// Copyright (c) 2024, the Dart project authors.  Please see the AUTHORS file
+// for details. 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:async';
+import 'dart:io';
+
+import 'package:cronet_http/src/cronet_client.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart';
+import 'package:http_profile/http_profile.dart';
+import 'package:integration_test/integration_test.dart';
+
+void main() {
+  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+  group('profile', () {
+    final profilingEnabled = HttpClientRequestProfile.profilingEnabled;
+
+    setUpAll(() {
+      HttpClientRequestProfile.profilingEnabled = true;
+    });
+
+    tearDownAll(() {
+      HttpClientRequestProfile.profilingEnabled = profilingEnabled;
+    });
+
+    group('POST', () {
+      late HttpServer successServer;
+      late Uri successServerUri;
+      late HttpClientRequestProfile profile;
+
+      setUpAll(() async {
+        successServer = (await HttpServer.bind('localhost', 0))
+          ..listen((request) async {
+            await request.drain<void>();
+            request.response.headers.set('Content-Type', 'text/plain');
+            request.response.headers.set('Content-Length', '11');
+            request.response.write('Hello World');
+            await request.response.close();
+          });
+        successServerUri = Uri.http('localhost:${successServer.port}');
+        final client = CronetClientWithProfile.defaultCronetEngine();
+        await client.post(successServerUri,
+            headers: {'Content-Type': 'text/plain'}, body: 'Hi');
+        profile = client.profile!;
+      });
+      tearDownAll(() {
+        successServer.close();
+      });
+
+      test('profile attributes', () {
+        expect(profile.events, isEmpty);
+        expect(profile.requestMethod, 'POST');
+        expect(profile.requestUri, successServerUri.toString());
+        expect(profile.connectionInfo,
+            containsPair('package', 'package:cronet_http'));
+      });
+
+      test('request attributes', () {
+        expect(profile.requestData.bodyBytes, 'Hi'.codeUnits);
+        expect(profile.requestData.contentLength, 2);
+        expect(profile.requestData.endTime, isNotNull);
+        expect(profile.requestData.error, isNull);
+        expect(
+            profile.requestData.headers, containsPair('Content-Length', ['2']));
+        expect(profile.requestData.headers,
+            containsPair('Content-Type', ['text/plain; charset=utf-8']));
+        expect(profile.requestData.persistentConnection, isNull);
+        expect(profile.requestData.proxyDetails, isNull);
+        expect(profile.requestData.startTime, isNotNull);
+      });
+
+      test('response attributes', () {
+        expect(profile.responseData.bodyBytes, 'Hello World'.codeUnits);
+        expect(profile.responseData.compressionState, isNull);
+        expect(profile.responseData.contentLength, 11);
+        expect(profile.responseData.endTime, isNotNull);
+        expect(profile.responseData.error, isNull);
+        expect(profile.responseData.headers,
+            containsPair('content-type', ['text/plain']));
+        expect(profile.responseData.headers,
+            containsPair('content-length', ['11']));
+        expect(profile.responseData.isRedirect, false);
+        expect(profile.responseData.persistentConnection, isNull);
+        expect(profile.responseData.reasonPhrase, 'OK');
+        expect(profile.responseData.redirects, isEmpty);
+        expect(profile.responseData.startTime, isNotNull);
+        expect(profile.responseData.statusCode, 200);
+      });
+    });
+
+    group('failed POST request', () {
+      late HttpClientRequestProfile profile;
+
+      setUpAll(() async {
+        final client = CronetClientWithProfile.defaultCronetEngine();
+        try {
+          await client.post(Uri.http('thisisnotahost'),
+              headers: {'Content-Type': 'text/plain'}, body: 'Hi');
+          fail('expected exception');
+        } on ClientException {
+          // Expected exception.
+        }
+        profile = client.profile!;
+      });
+
+      test('profile attributes', () {
+        expect(profile.events, isEmpty);
+        expect(profile.requestMethod, 'POST');
+        expect(profile.requestUri, 'http://thisisnotahost');
+        expect(profile.connectionInfo,
+            containsPair('package', 'package:cronet_http'));
+      });
+
+      test('request attributes', () {
+        expect(profile.requestData.bodyBytes, 'Hi'.codeUnits);
+        expect(profile.requestData.contentLength, 2);
+        expect(profile.requestData.endTime, isNotNull);
+        expect(profile.requestData.error, startsWith('ClientException:'));
+        expect(
+            profile.requestData.headers, containsPair('Content-Length', ['2']));
+        expect(profile.requestData.headers,
+            containsPair('Content-Type', ['text/plain; charset=utf-8']));
+        expect(profile.requestData.persistentConnection, isNull);
+        expect(profile.requestData.proxyDetails, isNull);
+        expect(profile.requestData.startTime, isNotNull);
+      });
+
+      test('response attributes', () {
+        expect(profile.responseData.bodyBytes, isEmpty);
+        expect(profile.responseData.compressionState, isNull);
+        expect(profile.responseData.contentLength, isNull);
+        expect(profile.responseData.endTime, isNull);
+        expect(profile.responseData.error, isNull);
+        expect(profile.responseData.headers, isNull);
+        expect(profile.responseData.isRedirect, isNull);
+        expect(profile.responseData.persistentConnection, isNull);
+        expect(profile.responseData.reasonPhrase, isNull);
+        expect(profile.responseData.redirects, isEmpty);
+        expect(profile.responseData.startTime, isNull);
+        expect(profile.responseData.statusCode, isNull);
+      });
+    });
+
+    group('failed POST response', () {
+      late HttpServer successServer;
+      late Uri successServerUri;
+      late HttpClientRequestProfile profile;
+
+      setUpAll(() async {
+        successServer = (await HttpServer.bind('localhost', 0))
+          ..listen((request) async {
+            await request.drain<void>();
+            request.response.headers.set('Content-Type', 'text/plain');
+            request.response.headers.set('Content-Length', '11');
+            final socket = await request.response.detachSocket();
+            await socket.close();
+          });
+        successServerUri = Uri.http('localhost:${successServer.port}');
+        final client = CronetClientWithProfile.defaultCronetEngine();
+
+        try {
+          await client.post(successServerUri,
+              headers: {'Content-Type': 'text/plain'}, body: 'Hi');
+          fail('expected exception');
+        } on ClientException {
+          // Expected exception.
+        }
+        profile = client.profile!;
+      });
+      tearDownAll(() {
+        successServer.close();
+      });
+
+      test('profile attributes', () {
+        expect(profile.events, isEmpty);
+        expect(profile.requestMethod, 'POST');
+        expect(profile.requestUri, successServerUri.toString());
+        expect(profile.connectionInfo,
+            containsPair('package', 'package:cronet_http'));
+      });
+
+      test('request attributes', () {
+        expect(profile.requestData.bodyBytes, 'Hi'.codeUnits);
+        expect(profile.requestData.contentLength, 2);
+        expect(profile.requestData.endTime, isNotNull);
+        expect(profile.requestData.error, isNull);
+        expect(
+            profile.requestData.headers, containsPair('Content-Length', ['2']));
+        expect(profile.requestData.headers,
+            containsPair('Content-Type', ['text/plain; charset=utf-8']));
+        expect(profile.requestData.persistentConnection, isNull);
+        expect(profile.requestData.proxyDetails, isNull);
+        expect(profile.requestData.startTime, isNotNull);
+      });
+
+      test('response attributes', () {
+        expect(profile.responseData.bodyBytes, isEmpty);
+        expect(profile.responseData.compressionState, isNull);
+        expect(profile.responseData.contentLength, 11);
+        expect(profile.responseData.endTime, isNotNull);
+        expect(profile.responseData.error, startsWith('ClientException:'));
+        expect(profile.responseData.headers,
+            containsPair('content-type', ['text/plain']));
+        expect(profile.responseData.headers,
+            containsPair('content-length', ['11']));
+        expect(profile.responseData.isRedirect, false);
+        expect(profile.responseData.persistentConnection, isNull);
+        expect(profile.responseData.reasonPhrase, 'OK');
+        expect(profile.responseData.redirects, isEmpty);
+        expect(profile.responseData.startTime, isNotNull);
+        expect(profile.responseData.statusCode, 200);
+      });
+    });
+
+    group('redirects', () {
+      late HttpServer successServer;
+      late Uri successServerUri;
+      late HttpClientRequestProfile profile;
+
+      setUpAll(() async {
+        successServer = (await HttpServer.bind('localhost', 0))
+          ..listen((request) async {
+            if (request.requestedUri.pathSegments.isEmpty) {
+              unawaited(request.response.close());
+            } else {
+              final n = int.parse(request.requestedUri.pathSegments.last);
+              final nextPath = n - 1 == 0 ? '' : '${n - 1}';
+              unawaited(request.response
+                  .redirect(successServerUri.replace(path: '/$nextPath')));
+            }
+          });
+        successServerUri = Uri.http('localhost:${successServer.port}');
+      });
+      tearDownAll(() {
+        successServer.close();
+      });
+
+      test('no redirects', () async {
+        final client = CronetClientWithProfile.defaultCronetEngine();
+        await client.get(successServerUri);
+        profile = client.profile!;
+
+        expect(profile.responseData.redirects, isEmpty);
+      });
+
+      test('follow redirects', () async {
+        final client = CronetClientWithProfile.defaultCronetEngine();
+        await client.send(Request('GET', successServerUri.replace(path: '/3'))
+          ..followRedirects = true
+          ..maxRedirects = 4);
+        profile = client.profile!;
+
+        expect(profile.requestData.followRedirects, true);
+        expect(profile.requestData.maxRedirects, 4);
+        expect(profile.responseData.isRedirect, false);
+
+        expect(profile.responseData.redirects, [
+          HttpProfileRedirectData(
+              statusCode: 302,
+              method: 'GET',
+              location: successServerUri.replace(path: '/2').toString()),
+          HttpProfileRedirectData(
+              statusCode: 302,
+              method: 'GET',
+              location: successServerUri.replace(path: '/1').toString()),
+          HttpProfileRedirectData(
+            statusCode: 302,
+            method: 'GET',
+            location: successServerUri.replace(path: '/').toString(),
+          )
+        ]);
+      });
+
+      test('no follow redirects', () async {
+        final client = CronetClientWithProfile.defaultCronetEngine();
+        await client.send(Request('GET', successServerUri.replace(path: '/3'))
+          ..followRedirects = false);
+        profile = client.profile!;
+
+        expect(profile.requestData.followRedirects, false);
+        expect(profile.responseData.isRedirect, true);
+        expect(profile.responseData.redirects, isEmpty);
+      });
+    });
+  });
+}
diff --git a/pkgs/cronet_http/example/integration_test/client_test.dart b/pkgs/cronet_http/example/integration_test/client_test.dart
index f126f84..e2ea749 100644
--- a/pkgs/cronet_http/example/integration_test/client_test.dart
+++ b/pkgs/cronet_http/example/integration_test/client_test.dart
@@ -4,18 +4,41 @@
 
 import 'package:cronet_http/cronet_http.dart';
 import 'package:http_client_conformance_tests/http_client_conformance_tests.dart';
+import 'package:http_profile/http_profile.dart';
 import 'package:integration_test/integration_test.dart';
 import 'package:test/test.dart';
 
 Future<void> testConformance() async {
-  group(
-      'default cronet engine',
-      () => testAll(
-            CronetClient.defaultCronetEngine,
-            canStreamRequestBody: false,
-            canReceiveSetCookieHeaders: true,
-            canSendCookieHeaders: true,
-          ));
+  group('default cronet engine', () {
+    group('profile enabled', () {
+      final profile = HttpClientRequestProfile.profilingEnabled;
+      HttpClientRequestProfile.profilingEnabled = true;
+      try {
+        testAll(
+          CronetClient.defaultCronetEngine,
+          canStreamRequestBody: false,
+          canReceiveSetCookieHeaders: true,
+          canSendCookieHeaders: true,
+        );
+      } finally {
+        HttpClientRequestProfile.profilingEnabled = profile;
+      }
+    });
+    group('profile disabled', () {
+      final profile = HttpClientRequestProfile.profilingEnabled;
+      HttpClientRequestProfile.profilingEnabled = false;
+      try {
+        testAll(
+          CronetClient.defaultCronetEngine,
+          canStreamRequestBody: false,
+          canReceiveSetCookieHeaders: true,
+          canSendCookieHeaders: true,
+        );
+      } finally {
+        HttpClientRequestProfile.profilingEnabled = profile;
+      }
+    });
+  });
 
   group('from cronet engine', () {
     testAll(
diff --git a/pkgs/cronet_http/example/pubspec.yaml b/pkgs/cronet_http/example/pubspec.yaml
index 41c6611..122e283 100644
--- a/pkgs/cronet_http/example/pubspec.yaml
+++ b/pkgs/cronet_http/example/pubspec.yaml
@@ -4,7 +4,7 @@
 publish_to: 'none'
 
 environment:
-  sdk: ^3.2.0
+  sdk: ^3.4.0
 
 dependencies:
   cronet_http:
@@ -22,6 +22,7 @@
     sdk: flutter
   http_client_conformance_tests:
     path: ../../http_client_conformance_tests/
+  http_profile: ^0.1.0
   integration_test:
     sdk: flutter
   test: ^1.23.1
diff --git a/pkgs/cronet_http/lib/src/cronet_client.dart b/pkgs/cronet_http/lib/src/cronet_client.dart
index 634a823..772f03b 100644
--- a/pkgs/cronet_http/lib/src/cronet_client.dart
+++ b/pkgs/cronet_http/lib/src/cronet_client.dart
@@ -16,6 +16,7 @@
 import 'dart:async';
 
 import 'package:http/http.dart';
+import 'package:http_profile/http_profile.dart';
 import 'package:jni/jni.dart';
 
 import 'jni/jni_bindings.dart' as jb;
@@ -157,7 +158,9 @@
         value.join(',')));
 
 jb.UrlRequestCallbackProxy_UrlRequestCallbackInterface _urlRequestCallbacks(
-    BaseRequest request, Completer<StreamedResponse> responseCompleter) {
+    BaseRequest request,
+    Completer<StreamedResponse> responseCompleter,
+    HttpClientRequestProfile? profile) {
   StreamController<List<int>>? responseStream;
   JByteBuffer? jByteBuffer;
   var numRedirects = 0;
@@ -198,10 +201,22 @@
         headers: responseHeaders,
       ));
 
+      profile?.requestData.close();
+      profile?.responseData
+        ?..contentLength = contentLength
+        ..headersCommaValues = responseHeaders
+        ..isRedirect = false
+        ..reasonPhrase =
+            responseInfo.getHttpStatusText().toDartString(releaseOriginal: true)
+        ..startTime = DateTime.now()
+        ..statusCode = responseInfo.getHttpStatusCode();
       jByteBuffer = JByteBuffer.allocateDirect(_bufferSize);
       urlRequest.read(jByteBuffer!);
     },
     onRedirectReceived: (urlRequest, responseInfo, newLocationUrl) {
+      final responseHeaders =
+          _cronetToClientHeaders(responseInfo.getAllHeaders());
+
       if (!request.followRedirects) {
         urlRequest.cancel();
         responseCompleter.complete(StreamedResponse(
@@ -214,10 +229,27 @@
             request: request,
             isRedirect: true,
             headers: _cronetToClientHeaders(responseInfo.getAllHeaders())));
+
+        profile?.responseData
+          ?..headersCommaValues = responseHeaders
+          ..isRedirect = true
+          ..reasonPhrase = responseInfo
+              .getHttpStatusText()
+              .toDartString(releaseOriginal: true)
+          ..startTime = DateTime.now()
+          ..statusCode = responseInfo.getHttpStatusCode();
+
         return;
       }
       ++numRedirects;
       if (numRedirects <= request.maxRedirects) {
+        profile?.responseData.addRedirect(HttpProfileRedirectData(
+            statusCode: responseInfo.getHttpStatusCode(),
+            // This method is not correct for status codes 303 to 307. Cronet
+            // does not seem to have a way to get the method so we'd have to
+            // calculate it according to the rules in RFC-7231.
+            method: 'GET',
+            location: newLocationUrl.toDartString(releaseOriginal: true)));
         urlRequest.followRedirect();
       } else {
         urlRequest.cancel();
@@ -227,8 +259,9 @@
     },
     onReadCompleted: (urlRequest, responseInfo, byteBuffer) {
       byteBuffer.flip();
-      responseStream!
-          .add(jByteBuffer!.asUint8List().sublist(0, byteBuffer.remaining));
+      final data = jByteBuffer!.asUint8List().sublist(0, byteBuffer.remaining);
+      responseStream!.add(data);
+      profile?.responseData.bodySink.add(data);
 
       byteBuffer.clear();
       urlRequest.read(byteBuffer);
@@ -236,6 +269,7 @@
     onSucceeded: (urlRequest, responseInfo) {
       responseStream!.sink.close();
       jByteBuffer?.release();
+      profile?.responseData.close();
     },
     onFailed: (urlRequest, responseInfo, cronetException) {
       final error = ClientException(
@@ -246,6 +280,14 @@
         responseStream!.addError(error);
         responseStream!.close();
       }
+
+      if (profile != null) {
+        if (profile.requestData.endTime == null) {
+          profile.requestData.closeWithError(error.toString());
+        } else {
+          profile.responseData.closeWithError(error.toString());
+        }
+      }
       jByteBuffer?.release();
     },
   ));
@@ -322,6 +364,12 @@
     _isClosed = true;
   }
 
+  HttpClientRequestProfile? _createProfile(BaseRequest request) =>
+      HttpClientRequestProfile.profile(
+          requestStartTime: DateTime.now(),
+          requestMethod: request.method,
+          requestUri: request.url.toString());
+
   @override
   Future<StreamedResponse> send(BaseRequest request) async {
     if (_isClosed) {
@@ -337,14 +385,33 @@
           'HTTP request failed. CronetEngine is already closed.', request.url);
     }
 
+    final profile = _createProfile(request);
+    profile?.connectionInfo = {
+      'package': 'package:cronet_http',
+      'client': 'CronetHttp',
+    };
+    profile?.requestData
+      ?..contentLength = request.contentLength
+      ..followRedirects = request.followRedirects
+      ..headersCommaValues = request.headers
+      ..maxRedirects = request.maxRedirects;
+    if (profile != null && request.contentLength != null) {
+      profile.requestData.headersListValues = {
+        'Content-Length': ['${request.contentLength}'],
+        ...profile.requestData.headers!
+      };
+    }
+
     final stream = request.finalize();
     final body = await stream.toBytes();
+    profile?.requestData.bodySink.add(body);
+
     final responseCompleter = Completer<StreamedResponse>();
 
     final builder = engine._engine.newUrlRequestBuilder(
       request.url.toString().toJString(),
       jb.UrlRequestCallbackProxy.new1(
-          _urlRequestCallbacks(request, responseCompleter)),
+          _urlRequestCallbacks(request, responseCompleter, profile)),
       _executor,
     )..setHttpMethod(request.method.toJString());
 
@@ -365,3 +432,17 @@
     return responseCompleter.future;
   }
 }
+
+/// A test-only class that makes the [HttpClientRequestProfile] data available.
+class CronetClientWithProfile extends CronetClient {
+  HttpClientRequestProfile? profile;
+
+  @override
+  HttpClientRequestProfile? _createProfile(BaseRequest request) =>
+      profile = super._createProfile(request);
+
+  CronetClientWithProfile._(super._engine, super._closeEngine) : super._();
+
+  factory CronetClientWithProfile.defaultCronetEngine() =>
+      CronetClientWithProfile._(null, true);
+}
diff --git a/pkgs/cronet_http/pubspec.yaml b/pkgs/cronet_http/pubspec.yaml
index 2e86938..4a3da88 100644
--- a/pkgs/cronet_http/pubspec.yaml
+++ b/pkgs/cronet_http/pubspec.yaml
@@ -1,17 +1,18 @@
 name: cronet_http
-version: 1.2.1
+version: 1.3.0
 description: >-
   An Android Flutter plugin that provides access to the Cronet HTTP client.
 repository: https://github.com/dart-lang/http/tree/master/pkgs/cronet_http
 
 environment:
-  sdk: ^3.0.0
-  flutter: ">=3.0.0"
+  sdk: ^3.4.0
+  flutter: '>=3.22.0'
 
 dependencies:
   flutter:
     sdk: flutter
   http: ^1.2.0
+  http_profile: ^0.1.0
   jni: ^0.9.2
 
 dev_dependencies: