blob: 7d966712165e409cb497253da5bb720f41727f2d [file] [log] [blame]
// 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.
/// An Android Flutter plugin that exposes the
/// [OkHttp](https://square.github.io/okhttp/) HTTP client.
///
/// The platform interface must be initialized before using this plugin e.g. by
/// calling
/// [`WidgetsFlutterBinding.ensureInitialized`](https://api.flutter.dev/flutter/widgets/WidgetsFlutterBinding/ensureInitialized.html)
/// or
/// [`runApp`](https://api.flutter.dev/flutter/widgets/runApp.html).
library;
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart';
import 'package:http_profile/http_profile.dart';
import 'package:jni/jni.dart';
import 'jni/bindings.dart' as bindings;
import 'jni/bindings.dart' show PrivateKey, X509Certificate;
class _JavaIOException extends IOException {
final String _message;
_JavaIOException(JniException e) : _message = e.message;
@override
String toString() => _message;
}
/// A [bindings.X509TrustManager] that trusts all certificates.
final _allAllTrustManager =
bindings.X509TrustManager.implement(bindings.$X509TrustManager(
checkClientTrusted: (chain, authType) {},
checkServerTrusted: (chain, authType) {},
getAcceptedIssuers: () {
final factory = bindings.TrustManagerFactory.getInstance(
bindings.TrustManagerFactory.getDefaultAlgorithm());
factory!.init(null);
return JArray(bindings.X509Certificate.nullableType, 0);
})).as(bindings.TrustManager.type);
/// Configurations for the [OkHttpClient].
class OkHttpClientConfiguration {
/// The maximum duration to wait for a call to complete.
///
/// If a call does not finish within the specified time, it will throw a
/// [ClientException] with the message "java.io.InterruptedIOException...".
///
/// [Duration.zero] indicates no timeout.
///
/// See [OkHttpClient.Builder.callTimeout](https://square.github.io/okhttp/5.x/okhttp/okhttp3/-ok-http-client/-builder/call-timeout.html).
final Duration callTimeout;
/// The maximum duration to wait while connecting a TCP Socket to the target
/// host.
///
/// See [OkHttpClient.Builder.connectTimeout](https://square.github.io/okhttp/5.x/okhttp/okhttp3/-ok-http-client/-builder/connect-timeout.html).
final Duration connectTimeout;
/// The maximum duration to wait for a TCP Socket and for individual read
/// IO operations.
///
/// See [OkHttpClient.Builder.readTimeout](https://square.github.io/okhttp/5.x/okhttp/okhttp3/-ok-http-client/-builder/read-timeout.html).
final Duration readTimeout;
/// The maximum duration to wait for individual write IO operations.
///
/// See [OkHttpClient.Builder.writeTimeout](https://square.github.io/okhttp/5.x/okhttp/okhttp3/-ok-http-client/-builder/write-timeout.html).
final Duration writeTimeout;
/// The [PrivateKey] used for TLS Client Authentication.
///
/// If set then [clientCertificateChain] must also be set.
///
/// See [Introducing TLS with Client Authentication](https://blog.cloudflare.com/introducing-tls-client-auth/).
final PrivateKey? clientPrivateKey;
/// The certificate chain used for TLS Client Authentication.
///
/// If set then [clientPrivateKey] must also be set.
///
/// See [Introducing TLS with Client Authentication](https://blog.cloudflare.com/introducing-tls-client-auth/).
final List<X509Certificate>? clientCertificateChain;
/// Whether the certificate chain for server certificates will be validated.
///
/// This should only be used in a testing environment.
final bool validateServerCertificates;
const OkHttpClientConfiguration({
this.callTimeout = Duration.zero,
this.connectTimeout = const Duration(milliseconds: 10000),
this.readTimeout = const Duration(milliseconds: 10000),
this.writeTimeout = const Duration(milliseconds: 10000),
this.clientPrivateKey,
this.clientCertificateChain,
this.validateServerCertificates = true,
});
}
/// Launches an Activity for the user to select the alias for a private key and
/// certificate pair for authentication. The selected alias or null will be
/// returned.
///
/// See [`KeyChain.choosePrivateKeyAlias`](https://developer.android.com/reference/android/security/KeyChain#choosePrivateKeyAlias(android.app.Activity,%20android.security.KeyChainAliasCallback,%20java.lang.String[],%20java.security.Principal[],%20android.net.Uri,%20java.lang.String).
Future<String?> choosePrivateKeyAlias({
JObject? activity,
}) async {
final c = Completer<String?>();
activity ??= JObject.fromReference(Jni.getCurrentActivity());
bindings.KeyChain.choosePrivateKeyAlias(activity,
bindings.KeyChainAliasCallback.implement(
bindings.$KeyChainAliasCallback(alias: (alias) {
c.complete(alias?.toDartString());
})), null, null, null, -1, null);
return c.future;
}
/// Load a [PrivateKey] and certificate chain given a `KeyStore` alias.
///
/// See [Android Keystore system](https://developer.android.com/privacy-and-security/keystore).
(PrivateKey, List<X509Certificate>) loadPrivateKeyAndCertificateChainFromAlias(
String alias,
{JObject? context}) {
context ??= JObject.fromReference(Jni.getCachedApplicationContext());
final jAlias = alias.toJString();
final pk = bindings.KeyChain.getPrivateKey(context, jAlias)!;
final chain = bindings.KeyChain.getCertificateChain(context, jAlias)!;
return (pk, chain.toList().cast<X509Certificate>());
}
/// Load a [PrivateKey] and certificate chain from a PKCS 12 archive.
///
/// Throws [IOException] if the password is incorrect or the PKCS12 data is
/// invalid.
(PrivateKey, List<X509Certificate>) loadPrivateKeyAndCertificateChainFromPKCS12(
Uint8List pkcs12Data, String password,
{JObject? context}) {
context ??= JObject.fromReference(Jni.getCachedApplicationContext());
var keyStore = bindings.KeyStore.getInstance('PKCS12'.toJString())!;
final jPassword = JCharArray(password.length);
for (var i = 0; i < password.length; ++i) {
jPassword[i] = password[i].codeUnits[0];
}
try {
keyStore.load(
bindings.ByteArrayInputStream(JByteArray.from(pkcs12Data)), jPassword);
} on JniException catch (e) {
if (e.message.contains('java.io.IOException')) {
throw _JavaIOException(e);
}
}
if (keyStore.size() == 0) {
throw ArgumentError('no key in PKC12 data', 'pkcs12Data');
}
if (keyStore.size() > 1) {
throw ArgumentError('multiple entries in PKC12 data', 'pkcs12Data');
}
final aliases = keyStore.aliases()!;
final jAlias = aliases.nextElement()!;
final pk = keyStore.getKey(jAlias, jPassword);
if (pk == null) {
throw ArgumentError('no key in PKC12 data', 'pkcs12Data');
}
final jCertificates = keyStore.getCertificateChain(jAlias);
if (jCertificates == null) {
throw ArgumentError('no certificate chain in PKC12 data', 'pkcs12Data');
}
if (!pk.isA(PrivateKey.type)) {
throw ArgumentError('certificate key is not a PrivateKey', 'pkcs12Data');
}
final certificates = jCertificates.map((c) {
if (c == null || !c.isA(X509Certificate.type)) {
throw ArgumentError(
'certificate chain contains non-X509 certificates', 'pkcs12Data');
}
return c.as(X509Certificate.type);
}).toList();
return (pk.as(PrivateKey.type), certificates);
}
/// An HTTP [Client] utilizing the [OkHttp](https://square.github.io/okhttp/) client.
///
/// Example Usage:
/// ```
/// void main() async {
/// var client = OkHttpClient();
/// final response = await client.get(
/// Uri.https('www.googleapis.com', '/books/v1/volumes', {'q': '{http}'}));
/// if (response.statusCode != 200) {
/// throw HttpException('bad response: ${response.statusCode}');
/// }
///
/// final decodedResponse =
/// jsonDecode(utf8.decode(response.bodyBytes)) as Map;
///
/// final itemCount = decodedResponse['totalItems'];
/// print('Number of books about http: $itemCount.');
/// for (var i = 0; i < min(itemCount, 10); ++i) {
/// print(decodedResponse['items'][i]['volumeInfo']['title']);
/// }
/// }
/// ```
class OkHttpClient extends BaseClient {
late bindings.OkHttpClient _client;
bool _isClosed = false;
/// The configuration for this client, applied on a per-call basis.
/// It can be updated multiple times during the client's lifecycle.
OkHttpClientConfiguration configuration;
/// Creates a new instance of [OkHttpClient] with the given [configuration].
OkHttpClient({
this.configuration = const OkHttpClientConfiguration(),
// required String alias,
}) {
final clientPrivateKey = configuration.clientPrivateKey;
final clientCertificateChain = configuration.clientCertificateChain;
if (clientPrivateKey != null && clientCertificateChain == null) {
throw ArgumentError(
'OkHttpClientConfiguration.clientCertificateChain must be set '
'if OkHttpClientConfiguration.clientPrivateKey is set');
}
if (clientCertificateChain != null && clientPrivateKey == null) {
throw ArgumentError(
'OkHttpClientConfiguration.clientPrivateKey must be set '
'if OkHttpClientConfiguration.clientCertificateChain is set');
}
final builder = bindings.OkHttpClient$Builder();
if (clientPrivateKey != null ||
clientCertificateChain != null ||
!configuration.validateServerCertificates) {
JArray<bindings.KeyManager>? keyManagers;
final trustManagers = JArray(bindings.TrustManager.nullableType, 1);
if (clientPrivateKey != null && clientCertificateChain != null) {
final chain =
JArray.of(bindings.X509Certificate.type, clientCertificateChain);
final keyManager = bindings.FixedResponseX509ExtendedKeyManager(
chain, clientPrivateKey, 'DUMMY'.toJString());
keyManagers = JArray.filled(1, keyManager.as(bindings.KeyManager.type),
E: bindings.KeyManager.type);
}
if (!configuration.validateServerCertificates) {
trustManagers[0] = _allAllTrustManager;
} else {
final tms = bindings.TrustManagerFactory.getInstance(
bindings.TrustManagerFactory.getDefaultAlgorithm())!
.getTrustManagers()!;
if (tms.length != 1) {
throw StateError('unexpected XXX');
}
trustManagers[0] = tms[0]!;
}
final sslContext = bindings.SSLContext.getInstance('TLS'.toJString())!
..init(keyManagers, trustManagers, null);
builder.sslSocketFactory$1(sslContext.getSocketFactory()!,
trustManagers[0]!.as(bindings.X509TrustManager.type));
}
_client = builder.build();
}
@override
void close() {
if (!_isClosed) {
// Refer to OkHttp documentation for the shutdown procedure:
// https://square.github.io/okhttp/5.x/okhttp/okhttp3/-ok-http-client/index.html#:~:text=Shutdown
_client.dispatcher().executorService().shutdown();
// Remove all idle connections from the resource pool.
_client.connectionPool().evictAll();
// Close the cache and release the JNI reference to the client.
var cache = _client.cache();
if (cache != null) {
cache.close();
}
_client.release();
}
_isClosed = true;
}
HttpClientRequestProfile? _createProfile(BaseRequest request) =>
HttpClientRequestProfile.profile(
requestStartTime: DateTime.now(),
requestMethod: request.method,
requestUri: request.url.toString());
void addProfileError(HttpClientRequestProfile? profile, Exception error) {
if (profile != null) {
if (profile.requestData.endTime == null) {
profile.requestData.closeWithError(error.toString());
} else {
profile.responseData.closeWithError(error.toString());
}
}
}
@override
Future<StreamedResponse> send(BaseRequest request) async {
if (_isClosed) {
throw ClientException(
'HTTP request failed. Client is already closed.', request.url);
}
final profile = _createProfile(request);
profile?.connectionInfo = {
'package': 'package:ok_http',
'client': 'OkHttpClient',
};
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!
};
}
var requestUrl = request.url.toString();
var requestHeaders = request.headers;
var requestMethod = request.method;
var requestBody = await request.finalize().toBytes();
var maxRedirects = request.maxRedirects;
var followRedirects = request.followRedirects;
profile?.requestData.bodySink.add(requestBody);
var profileRespClosed = false;
final responseCompleter = Completer<StreamedResponse>();
var reqBuilder = bindings.Request$Builder().url$1(requestUrl.toJString());
requestHeaders.forEach((headerName, headerValue) {
reqBuilder.addHeader(headerName.toJString(), headerValue.toJString());
});
// OkHttp doesn't allow a non-null RequestBody for GET and HEAD requests.
// So, we need to handle this case separately.
bindings.RequestBody? okReqBody;
if (requestMethod != 'GET' && requestMethod != 'HEAD') {
okReqBody = bindings.RequestBody.create$10(JByteArray.from(requestBody));
}
reqBuilder.method(
requestMethod.toJString(),
okReqBody,
);
// To configure the client per-request, we create a new client with the
// builder associated with `_client`.
// They share the same connection pool and dispatcher.
// https://square.github.io/okhttp/recipes/#per-call-configuration-kt-java
//
// `followRedirects` is set to `false` to handle redirects manually.
// (Since OkHttp sets a hard limit of 20 redirects.)
// https://github.com/square/okhttp/blob/54238b4c713080c3fd32fb1a070fb5d6814c9a09/okhttp/src/main/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt#L350
final reqConfiguredClient = bindings.RedirectInterceptor.Companion
.addRedirectInterceptor(
_client.newBuilder().followRedirects(false),
maxRedirects,
followRedirects, bindings.RedirectReceivedCallback.implement(
bindings.$RedirectReceivedCallback(
onRedirectReceived: (response, newLocation) {
profile?.responseData.addRedirect(HttpProfileRedirectData(
statusCode: response.code(),
method: response
.request()
.method()
.toDartString(releaseOriginal: true),
location: newLocation.toDartString(releaseOriginal: true),
));
},
)))
.callTimeout(configuration.callTimeout.inMilliseconds,
bindings.TimeUnit.MILLISECONDS)
.connectTimeout(configuration.connectTimeout.inMilliseconds,
bindings.TimeUnit.MILLISECONDS)
.readTimeout(configuration.readTimeout.inMilliseconds,
bindings.TimeUnit.MILLISECONDS)
.writeTimeout(configuration.writeTimeout.inMilliseconds,
bindings.TimeUnit.MILLISECONDS)
.build();
// `enqueue()` schedules the request to be executed in the future.
// https://square.github.io/okhttp/5.x/okhttp/okhttp3/-call/enqueue.html
reqConfiguredClient
.newCall(reqBuilder.build())
.enqueue(bindings.Callback.implement(bindings.$Callback(
onResponse: (bindings.Call call, bindings.Response response) {
var reader = bindings.AsyncInputStreamReader();
var respBodyStreamController = StreamController<List<int>>();
var responseHeaders = <String, String>{};
response.headers().toMultimap().forEach((key, value) {
responseHeaders[key.toDartString(releaseOriginal: true)] =
value.join(',');
});
int? contentLength;
if (responseHeaders.containsKey('content-length')) {
contentLength = int.tryParse(responseHeaders['content-length']!);
// To be conformant with RFC 2616 14.13, we need to check if the
// content-length is a non-negative integer.
if (contentLength == null || contentLength < 0) {
responseCompleter.completeError(ClientException(
'Invalid content-length header', request.url));
return;
}
}
var responseBodyByteStream = response.body()!.byteStream();
reader.readAsync(
responseBodyByteStream,
bindings.DataCallback.implement(
bindings.$DataCallback(
onDataRead: (bytesRead) {
var data = bytesRead.toList(growable: false);
respBodyStreamController.sink.add(data);
profile?.responseData.bodySink.add(data);
},
onFinished: () {
reader.shutdown();
respBodyStreamController.sink.close();
if (!profileRespClosed) {
profile?.responseData.close();
profileRespClosed = true;
}
},
onError: (iOException) {
var exception =
ClientException(iOException.toString(), request.url);
respBodyStreamController.sink.addError(exception);
addProfileError(profile, exception);
profileRespClosed = true;
reader.shutdown();
respBodyStreamController.sink.close();
},
),
));
responseCompleter.complete(StreamedResponse(
respBodyStreamController.stream,
response.code(),
reasonPhrase:
response.message().toDartString(releaseOriginal: true),
headers: responseHeaders,
request: request,
contentLength: contentLength,
isRedirect: response.isRedirect(),
));
profile?.requestData.close();
profile?.responseData
?..contentLength = contentLength
..headersCommaValues = responseHeaders
..isRedirect = response.isRedirect()
..reasonPhrase =
response.message().toDartString(releaseOriginal: true)
..startTime = DateTime.now()
..statusCode = response.code();
},
onFailure: (bindings.Call call, JObject ioException) {
var msg = ioException.toString();
if (msg.contains('Redirect limit exceeded')) {
msg = 'Redirect limit exceeded';
}
var exception = ClientException(msg, request.url);
responseCompleter.completeError(exception);
addProfileError(profile, exception);
profileRespClosed = true;
},
)));
return responseCompleter.future;
}
}
/// A test-only class that makes the [HttpClientRequestProfile] data available.
class OkHttpClientWithProfile extends OkHttpClient {
HttpClientRequestProfile? profile;
@override
HttpClientRequestProfile? _createProfile(BaseRequest request) =>
profile = super._createProfile(request);
OkHttpClientWithProfile() : super();
}