blob: 26cc019cce21d70aa1d1f0715537674399168c0e [file]
// Copyright (c) 2022, 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 'package:http/http.dart';
import 'package:http_profile/http_profile.dart';
import 'package:jni/jni.dart';
import 'jni/jni_bindings.dart' as jb;
final _digitRegex = RegExp(r'^\d+$');
const _bufferSize = 10 * 1024; // The size of the Cronet read buffer.
/// A [ClientException] generated from a Java [`CronetException`][1].
///
/// [1]: https://developer.android.com/develop/connectivity/cronet/reference/org/chromium/net/CronetException.html
class CronetException extends ClientException {
CronetException(super.message, Uri super.uri);
@override
String toString() => 'CronetClientException: $message, uri=$uri';
}
/// A [ClientException] generated from a Java [`CallbackException`][1].
///
/// [1]: https://developer.android.com/develop/connectivity/cronet/reference/org/chromium/net/CallbackException.html
class CallbackException extends CronetException {
CallbackException._(super.message, super.uri);
@override
String toString() => 'CallbackException: $message, uri=$uri';
}
/// A [ClientException] generated from a Java [`NetworkException`][1].
///
/// [1]: https://developer.android.com/develop/connectivity/cronet/reference/org/chromium/net/NetworkException.html
class NetworkException extends CronetException {
/// The Cronet internal error code.
///
/// This may provide more specific error diagnosis than [errorCode].
///
/// The list of possible value is contained in [net_error_list.h][1].
///
/// [1]: https://chromium.googlesource.com/chromium/src/+/main/net/base/net_error_list.h
final int cronetInternalErrorCode;
/// The error code associated with the failure, which is one of the `ERROR_*`
/// constants defined in [`NetworkException`][1].
///
/// For example, a value of `6` corresponds to `ERROR_CONNECTION_TIMED_OUT`.
///
/// [1]: https://developer.android.com/develop/connectivity/cronet/reference/org/chromium/net/NetworkException.html#constants
final int errorCode;
/// Whether retrying this request right away might succeed.
///
/// For example, this is `true` when [errorCode] is `ERROR_NETWORK_CHANGED`
/// because trying the request might succeed using the new network
/// configuration.
final bool immediatelyRetryable;
NetworkException._(super.message, super.uri,
{required this.cronetInternalErrorCode,
required this.errorCode,
required this.immediatelyRetryable});
@override
String toString() => 'NetworkClientException: $message, uri=$uri, '
'errorCode=$errorCode, cronetInternalErrorCode=$cronetInternalErrorCode, '
'immediatelyRetryable=$immediatelyRetryable';
}
/// A [ClientException] generated from a Java [`QuicException`][1].
///
/// [1]: https://developer.android.com/develop/connectivity/cronet/reference/org/chromium/net/QuicException.html
class QuicException extends NetworkException {
/// The QUIC error code, which is a value from [`QuicErrorCode`][1].
///
/// [1]: https://source.chromium.org/chromium/chromium/src/+/main:net/third_party/quiche/src/quiche/quic/core/quic_error_codes.h
final int quicDetailedErrorCode;
QuicException._(
super.message,
super.uri, {
required this.quicDetailedErrorCode,
required super.errorCode,
required super.cronetInternalErrorCode,
required super.immediatelyRetryable,
}) : super._();
@override
String toString() => 'QuicException: $message, uri=$uri, '
'errorCode=$errorCode, cronetInternalErrorCode=$cronetInternalErrorCode, '
'immediatelyRetryable=$immediatelyRetryable, '
'quicDetailedErrorCode=$quicDetailedErrorCode';
}
ClientException _convertCronetException(jb.CronetException? e, Uri uri) {
if (e == null) {
return CronetException('unknown exception', uri);
}
final message = e.getMessage()?.toDartString(releaseOriginal: true) ??
'unknown exception';
if (e.isA(jb.QuicException.type)) {
final quicException = e.as(jb.QuicException.type, releaseOriginal: true);
return QuicException._(
message,
uri,
quicDetailedErrorCode: quicException.getQuicDetailedErrorCode(),
errorCode: quicException.getErrorCode(),
cronetInternalErrorCode: quicException.getCronetInternalErrorCode(),
immediatelyRetryable: quicException.immediatelyRetryable(),
);
}
if (e.isA(jb.NetworkException.type)) {
final networkException =
e.as(jb.NetworkException.type, releaseOriginal: true);
return NetworkException._(
message,
uri,
cronetInternalErrorCode: networkException.getCronetInternalErrorCode(),
errorCode: networkException.getErrorCode(),
immediatelyRetryable: networkException.immediatelyRetryable(),
);
}
if (e.isA(jb.CallbackException.type)) {
return CallbackException._(message, uri);
}
return CronetException(message, uri);
}
/// This class can be removed when `package:http` v2 is released.
class _StreamedResponseWithUrl extends StreamedResponse
implements BaseResponseWithUrl {
@override
final Uri url;
_StreamedResponseWithUrl(
super.stream,
super.statusCode, {
required this.url,
super.contentLength,
super.request,
super.headers,
super.isRedirect,
super.reasonPhrase,
});
}
/// An HTTP response from the Cronet network stack.
///
/// The response body is received asynchronously after the headers have been
/// received.
class CronetStreamedResponse extends _StreamedResponseWithUrl {
/// The protocol (for example `'quic/1+spdy/3'`) negotiated with the server.
///
/// It will be the empty string or `'unknown'` if no protocol was negotiated,
/// the protocol is not known, or when using plain HTTP or HTTPS.
final String negotiatedProtocol;
/// The minimum count of bytes received from the network to process this
/// request.
///
/// This count may ignore certain overheads (for example IP and TCP/UDP
/// framing, SSL handshake and framing, proxy handling). This count is taken
/// prior to decompression (for example GZIP) and includes headers and data
/// from all redirects. This value may change as more response data is
/// received from the network.
final int receivedByteCount;
/// Whether the response came from the cache.
///
/// Is `true` for requests that were revalidated over the network before being
/// retrieved from the cache
final bool wasCached;
CronetStreamedResponse._(
super.stream,
super.statusCode, {
required this.negotiatedProtocol,
required this.receivedByteCount,
required this.wasCached,
required super.url,
super.contentLength,
super.request,
super.headers,
super.isRedirect,
super.reasonPhrase,
});
}
/// The type of caching to use when making HTTP requests.
enum CacheMode {
disabled,
memory,
diskNoHttp,
disk,
}
/// An environment that can be used to make HTTP requests.
class CronetEngine {
late final jb.CronetEngine _engine;
bool _isClosed = false;
CronetEngine._(this._engine);
/// Construct a new [CronetEngine] with the given configuration.
///
/// [cacheMode] controls the type of caching that should be used by the
/// engine. If [cacheMode] is not [CacheMode.disabled] then [cacheMaxSize]
/// must be set. If [cacheMode] is [CacheMode.disk] or [CacheMode.diskNoHttp]
/// then [storagePath] must be set.
///
/// [cacheMaxSize] is the maximum amount of data that should be cached, in
/// bytes.
///
/// [enableBrotli] controls whether
/// [Brotli compression](https://www.rfc-editor.org/rfc/rfc7932) can be used.
///
/// [enableHttp2] controls whether the HTTP/2 protocol can be used.
///
/// [enablePublicKeyPinningBypassForLocalTrustAnchors] enables or disables
/// public key pinning bypass for local trust anchors. Disabling the bypass
/// for local trust anchors is highly discouraged since it may prohibit the
/// app from communicating with the pinned hosts. E.g., a user may want to
/// send all traffic through an SSL enabled proxy by changing the device
/// proxy settings and adding the proxy certificate to the list of local
/// trust anchor.
///
/// [enableQuic] controls whether the [QUIC](https://www.chromium.org/quic/)
/// protocol can be used.
///
/// [storagePath] sets the path of an existing directory where HTTP data can
/// be cached and where cookies can be stored. NOTE: a unique [storagePath]
/// should be used per [CronetEngine].
///
/// [userAgent] controls the `User-Agent` header.
///
/// [quicHints] adds a list of hosts that support QUIC. Each hint is a tuple
/// of (host, port, alternativePort) that indicates that the host supports
/// QUIC. Note that [CacheMode.disk] or [CacheMode.diskNoHttp] is needed to
/// take advantage of 0-RTT connection establishment between sessions.
static CronetEngine build(
{CacheMode? cacheMode,
int? cacheMaxSize,
bool? enableBrotli,
bool? enableHttp2,
bool? enablePublicKeyPinningBypassForLocalTrustAnchors,
bool? enableQuic,
String? storagePath,
String? userAgent,
List<(String, int, int)>? quicHints}) {
try {
return using((arena) {
final builder = jb.CronetEngine$Builder(
Jni.androidApplicationContext..releasedBy(arena))
..releasedBy(arena);
if (storagePath != null) {
builder
.setStoragePath(storagePath.toJString()..releasedBy(arena))
?.release();
}
if (cacheMode == CacheMode.disabled) {
builder
.enableHttpCache(0, 0)
?.release(); // HTTP_CACHE_DISABLED, 0 bytes
} else if (cacheMode != null && cacheMaxSize != null) {
builder.enableHttpCache(cacheMode.index, cacheMaxSize)?.release();
}
if (enableBrotli != null) {
builder.enableBrotli(enableBrotli)?.release();
}
if (enableHttp2 != null) {
builder.enableHttp2(enableHttp2)?.release();
}
if (enablePublicKeyPinningBypassForLocalTrustAnchors != null) {
builder
.enablePublicKeyPinningBypassForLocalTrustAnchors(
enablePublicKeyPinningBypassForLocalTrustAnchors)
?.release();
}
if (enableQuic != null) {
builder.enableQuic(enableQuic)?.release();
}
if (userAgent != null) {
builder
.setUserAgent(userAgent.toJString()..releasedBy(arena))
?.release();
}
if (quicHints != null) {
for (final (host, port, alternativePort) in quicHints) {
builder.addQuicHint(
host.toJString()..releasedBy(arena), port, alternativePort);
}
}
return CronetEngine._(builder.build()!);
});
} on JniException catch (e) {
// TODO: Decode this exception in a better way when
// https://github.com/dart-lang/jnigen/issues/239 is fixed.
if (e.message.contains('java.lang.IllegalArgumentException:')) {
throw ArgumentError(
e.message.split('java.lang.IllegalArgumentException:').last);
}
rethrow;
}
}
void close() {
if (!_isClosed) {
_engine
..shutdown()
..release();
}
_isClosed = true;
}
/// Starts NetLog logging to a file.
///
/// The NetLog is meant for debugging and will contain events emitted by
/// all live CronetEngines.
///
/// NetLog can files can be viewed at https://netlog-viewer.appspot.com/
///
/// If [logAll] is `false` then only basic event information will be logged.
/// If it is `true`, then user cookies, credentials and all transferred bytes
/// will appear in the log.
///
/// > [!CAUTION]
/// > Setting [logAll] to `true` presents a privacy risk, since it exposes the
/// > user's credentials, and should only be used with the user's consent and
/// > in situations where the log won't be public.
void startNetLogToFile(String fileName, bool logAll) {
using((arena) {
_engine.startNetLogToFile(
fileName.toJString()..releasedBy(arena), logAll);
});
}
/// Stops NetLog logging and flushes the data to disk.
///
/// This method does nothing if NetLog was not started.
void stopNetLog() {
_engine.stopNetLog();
}
}
Map<String, String> _cronetToClientHeaders(
JMap<JString?, JList<JString?>?> cronetHeaders) =>
cronetHeaders.map((key, value) {
final entry = MapEntry(
key!.toDartString(releaseOriginal: true).toLowerCase(),
value!.join(','));
value.release();
return entry;
});
jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface _urlRequestCallbacks(
BaseRequest request,
Completer<CronetStreamedResponse> responseCompleter,
HttpClientRequestProfile? profile) {
StreamController<List<int>>? responseStream;
JByteBuffer? jByteBuffer;
var numRedirects = 0;
var responseStreamCancelled = false;
// The order of callbacks generated by Cronet is documented here:
// https://developer.android.com/guide/topics/connectivity/cronet/lifecycle
return jb.UrlRequestCallbackProxy$UrlRequestCallbackInterface.implement(
// All of the variables in the interface are non-nullable with the
// exception of onFailed's UrlResponseInfo as specified in:
// https://source.chromium.org/chromium/chromium/src/+/main:components/cronet/android/api/src/org/chromium/net/UrlRequest.java;l=232
jb.$UrlRequestCallbackProxy$UrlRequestCallbackInterface(
onResponseStarted$async: true,
onResponseStarted: (urlRequest, responseInfo) {
using((arena) {
// `urlRequest` is captured by the `onCancel` callback of the
// `StreamController` below. So it must not be registered to be later
// released here.
responseInfo?.releasedBy(arena);
responseStream = StreamController(onCancel: () {
// The user did `response.stream.cancel()`. We can just pretend that
// the response completed normally.
if (responseStreamCancelled) return;
responseStreamCancelled = true;
urlRequest!
..cancel()
..release();
responseStream!.sink.close();
jByteBuffer?.release();
profile?.responseData.close();
});
final responseHeaders = _cronetToClientHeaders(
responseInfo!.getAllHeaders()!..releasedBy(arena));
int? contentLength;
switch (responseHeaders['content-length']) {
case final contentLengthHeader?
when !_digitRegex.hasMatch(contentLengthHeader):
responseCompleter.completeError(ClientException(
'Invalid content-length header [$contentLengthHeader].',
request.url,
));
urlRequest
?..cancel()
..release();
return;
case final contentLengthHeader?:
contentLength = int.parse(contentLengthHeader);
}
responseCompleter.complete(CronetStreamedResponse._(
responseStream!.stream,
responseInfo.getHttpStatusCode(),
negotiatedProtocol: responseInfo
.getNegotiatedProtocol()!
.toDartString(releaseOriginal: true),
receivedByteCount: responseInfo.getReceivedByteCount(),
wasCached: responseInfo.wasCached(),
url: Uri.parse(
responseInfo.getUrl()!.toDartString(releaseOriginal: true)),
contentLength: contentLength,
reasonPhrase: responseInfo
.getHttpStatusText()!
.toDartString(releaseOriginal: true),
request: request,
isRedirect: false,
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$async: true,
onRedirectReceived: (urlRequest, responseInfo, newLocationUrl) {
using((arena) {
urlRequest?.releasedBy(arena);
responseInfo?.releasedBy(arena);
newLocationUrl?.releasedBy(arena);
if (responseStreamCancelled) return;
final responseHeaders =
_cronetToClientHeaders(responseInfo!.getAllHeaders()!);
if (!request.followRedirects) {
urlRequest!.cancel();
responseCompleter.complete(CronetStreamedResponse._(
const Stream.empty(), // Cronet provides no body for redirects.
responseInfo.getHttpStatusCode(),
negotiatedProtocol: responseInfo
.getNegotiatedProtocol()!
.toDartString(releaseOriginal: true),
receivedByteCount: responseInfo.getReceivedByteCount(),
wasCached: responseInfo.wasCached(),
url: Uri.parse(
responseInfo.getUrl()!.toDartString(releaseOriginal: true)),
contentLength: 0,
reasonPhrase: responseInfo
.getHttpStatusText()!
.toDartString(releaseOriginal: true),
request: request,
isRedirect: true,
headers: responseHeaders,
));
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();
responseCompleter.completeError(
ClientException('Redirect limit exceeded', request.url));
}
});
},
onReadCompleted$async: true,
onReadCompleted: (urlRequest, responseInfo, byteBuffer) {
using((arena) {
urlRequest?.releasedBy(arena);
responseInfo?.releasedBy(arena);
byteBuffer?.releasedBy(arena);
if (responseStreamCancelled) return;
byteBuffer!.flip();
final data =
jByteBuffer!.asUint8List().sublist(0, byteBuffer.remaining);
responseStream!.add(data);
profile?.responseData.bodySink.add(data);
byteBuffer.clear();
urlRequest!.read(byteBuffer);
});
},
onSucceeded$async: true,
onSucceeded: (urlRequest, responseInfo) {
using((arena) {
urlRequest?.releasedBy(arena);
responseInfo?.releasedBy(arena);
if (responseStreamCancelled) return;
responseStreamCancelled = true;
responseStream!.sink.close();
jByteBuffer?.release();
profile?.responseData.close();
});
},
onFailed$async: true,
onFailed: (urlRequest, responseInfo /* can be null */, cronetException) {
using((arena) {
urlRequest?.releasedBy(arena);
responseInfo?.releasedBy(arena);
cronetException?.releasedBy(arena);
if (responseStreamCancelled) return;
responseStreamCancelled = true;
final error = _convertCronetException(cronetException, request.url);
if (responseStream == null) {
responseCompleter.completeError(error);
} else {
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();
});
},
onCanceled$async: true,
// Will always be the last callback invoked.
// See https://developer.android.com/develop/connectivity/cronet/reference/org/chromium/net/UrlRequest#cancel()
onCanceled: (urlRequest, urlResponseInfo /* can be null */) {
using((arena) {
urlRequest?.releasedBy(arena);
urlResponseInfo?.releasedBy(arena);
if (responseStreamCancelled) return;
responseStreamCancelled = true;
final error = RequestAbortedException(request.url);
if (responseStream == null) {
responseCompleter.completeError(error);
} else {
if (!responseStream!.isClosed) {
responseStream!.sink.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();
});
},
));
}
/// A HTTP [Client] based on the
/// [Cronet](https://developer.android.com/guide/topics/connectivity/cronet)
/// network stack.
class CronetClient extends BaseClient {
static final _executor = jb.Executors.newCachedThreadPool();
CronetEngine? _engine;
bool _isClosed = false;
/// Indicates that [CronetClient] is responsible for closing [_engine].
final bool _closeEngine;
CronetClient._(this._engine, this._closeEngine);
/// A [CronetClient] that will be initialized with a new [CronetEngine].
factory CronetClient.defaultCronetEngine() => CronetClient._(null, true);
/// A [CronetClient] configured with a [CronetEngine].
///
/// If [closeEngine] is `true`, then [engine] will be closed when [close] is
/// called on this [CronetClient]. This can simplify lifetime management if
/// [engine] is only used in one [CronetClient].
factory CronetClient.fromCronetEngine(CronetEngine engine,
{bool closeEngine = false}) =>
CronetClient._(engine, closeEngine);
/// A [CronetClient] configured with a [Future] containing a [CronetEngine].
///
/// This can be useful in circumstances where a non-Future [CronetClient] is
/// required but you want to configure the [CronetClient] with a custom
/// [CronetEngine]. For example:
/// ```
/// void main() {
/// Client clientFactory() {
/// final engine = CronetEngine.build(
/// cacheMode: CacheMode.memory, userAgent: 'Book Agent');
/// return CronetClient.fromCronetEngineFuture(engine);
/// }
///
/// runWithClient(() => runApp(const BookSearchApp()), clientFactory);
/// }
/// ```
@override
void close() {
if (!_isClosed && _closeEngine) {
_engine?.close();
}
_isClosed = true;
}
HttpClientRequestProfile? _createProfile(BaseRequest request) =>
HttpClientRequestProfile.profile(
requestStartTime: DateTime.now(),
requestMethod: request.method,
requestUri: request.url.toString());
/// Sends an HTTP request and asynchronously returns the response.
@override
Future<CronetStreamedResponse> send(BaseRequest request) async {
if (_isClosed) {
throw ClientException(
'HTTP request failed. Client is already closed.', request.url);
}
final engine = _engine ?? CronetEngine.build();
_engine = engine;
if (engine._isClosed) {
throw ClientException(
'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<CronetStreamedResponse>();
return await using((arena) async {
final jUrl = request.url.toString().toJString()..releasedBy(arena);
final jMethod = request.method.toJString()..releasedBy(arena);
final builder = engine._engine.newUrlRequestBuilder(
jUrl,
jb.UrlRequestCallbackProxy(
_urlRequestCallbacks(request, responseCompleter, profile)),
_executor,
)!
..releasedBy(arena)
..setHttpMethod(jMethod);
var headers = request.headers;
if (body.isNotEmpty &&
!headers.keys.any((h) => h.toLowerCase() == 'content-type')) {
// Cronet requires that requests containing upload data set a
// 'Content-Type' header.
headers = {...headers, 'content-type': 'application/octet-stream'};
}
headers.forEach((k, v) => builder.addHeader(
k.toJString()..releasedBy(arena), v.toJString()..releasedBy(arena)));
if (body.isNotEmpty) {
final JByteBuffer data;
try {
data = body.toJByteBuffer()..releasedBy(arena);
} on JniException catch (e) {
// There are no unit tests for this code. You can verify this behavior
// manually by incrementally increasing the amount of body data in
// `CronetClient.post` until you get this exception.
if (e.message.contains('java.lang.OutOfMemoryError:')) {
throw ClientException(
'Not enough memory for request body: ${e.message}',
request.url);
}
rethrow;
}
builder.setUploadDataProvider(
jb.UploadDataProviders.create$2(data), _executor);
}
// Not releasing `cronetRequest` as it's used in `whenComplete` callback.
final cronetRequest = builder.build()!;
if (request case Abortable(:final abortTrigger?)) {
unawaited(abortTrigger.whenComplete(cronetRequest.cancel));
}
cronetRequest.start();
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);
}