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: