| // Copyright (c) 2023, 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 'dart:isolate'; |
| import 'dart:typed_data'; |
| |
| import 'package:async/async.dart'; |
| import 'package:http/http.dart'; |
| import 'package:jni/jni.dart'; |
| import 'package:path/path.dart'; |
| |
| import 'third_party/java/io/BufferedInputStream.dart'; |
| import 'third_party/java/lang/System.dart'; |
| import 'third_party/java/net/HttpURLConnection.dart'; |
| import 'third_party/java/net/URL.dart'; |
| |
| final _digitRegex = RegExp(r'^\d+$'); |
| |
| // TODO: Add a description of the implementation. |
| // Look at the description of cronet_client.dart and cupertino_client.dart for |
| // examples. |
| // See https://github.com/dart-lang/http/pull/980#discussion_r1253697461. |
| class JavaClient extends BaseClient { |
| void _initJVM() { |
| if (!Platform.isAndroid) { |
| Jni.spawnIfNotExists(dylibDir: join('build', 'jni_libs')); |
| } |
| |
| // TODO: Determine if we can remove this. |
| // It's a workaround to fix the tests not passing on GitHub CI. |
| // See https://github.com/dart-lang/http/pull/987#issuecomment-1636170371. |
| System.setProperty( |
| 'java.net.preferIPv6Addresses'.toJString(), 'true'.toJString()); |
| } |
| |
| @override |
| Future<StreamedResponse> send(BaseRequest request) async { |
| // TODO: Move the call to _initJVM() to the JavaClient constructor. |
| // See https://github.com/dart-lang/http/pull/980#discussion_r1253700470. |
| _initJVM(); |
| |
| final receivePort = ReceivePort(); |
| final events = StreamQueue<dynamic>(receivePort); |
| |
| // We can't send a StreamedRequest to another Isolate. |
| // But we can send Map<String, String>, String, UInt8List, Uri. |
| final isolateRequest = ( |
| sendPort: receivePort.sendPort, |
| url: request.url, |
| method: request.method, |
| headers: request.headers, |
| body: await request.finalize().toBytes(), |
| ); |
| |
| // Could create a new class to hold the data for the isolate instead |
| // of using a record. |
| await Isolate.spawn(_isolateMethod, isolateRequest); |
| |
| final statusCodeEvent = await events.next; |
| late int statusCode; |
| if (statusCodeEvent is ClientException) { |
| // Do we need to close the ReceivePort here as well? |
| receivePort.close(); |
| throw statusCodeEvent; |
| } else { |
| statusCode = statusCodeEvent as int; |
| } |
| |
| final reasonPhrase = await events.next as String?; |
| final responseHeaders = await events.next as Map<String, String>; |
| |
| Stream<List<int>> responseBodyStream(Stream<dynamic> events) async* { |
| try { |
| await for (final event in events) { |
| if (event is List<int>) { |
| yield event; |
| } else if (event is ClientException) { |
| throw event; |
| } else if (event == null) { |
| return; |
| } |
| } |
| } finally { |
| // TODO: Should we kill the isolate here? |
| receivePort.close(); |
| } |
| } |
| |
| return StreamedResponse(responseBodyStream(events.rest), statusCode, |
| contentLength: _parseContentLengthHeader(request.url, responseHeaders), |
| request: request, |
| headers: responseHeaders, |
| reasonPhrase: reasonPhrase); |
| } |
| |
| // TODO: Rename _isolateMethod to something more descriptive. |
| Future<void> _isolateMethod( |
| ({ |
| SendPort sendPort, |
| Uint8List body, |
| Map<String, String> headers, |
| String method, |
| Uri url, |
| }) request, |
| ) async { |
| final httpUrlConnection = URL |
| .ctor3(request.url.toString().toJString()) |
| .openConnection() |
| .castTo(HttpURLConnection.type, deleteOriginal: true); |
| |
| request.headers.forEach((headerName, headerValue) { |
| httpUrlConnection.setRequestProperty( |
| headerName.toJString(), headerValue.toJString()); |
| }); |
| |
| httpUrlConnection.setRequestMethod(request.method.toJString()); |
| _setRequestBody(httpUrlConnection, request.body); |
| |
| try { |
| final statusCode = _statusCode(request.url, httpUrlConnection); |
| request.sendPort.send(statusCode); |
| } on ClientException catch (e) { |
| request.sendPort.send(e); |
| httpUrlConnection.disconnect(); |
| return; |
| } |
| |
| final reasonPhrase = _reasonPhrase(httpUrlConnection); |
| request.sendPort.send(reasonPhrase); |
| |
| final responseHeaders = _responseHeaders(httpUrlConnection); |
| request.sendPort.send(responseHeaders); |
| |
| // TODO: Throws a ClientException if the content length header is invalid. |
| // I think we need to send the ClientException over the SendPort. |
| final contentLengthHeader = _parseContentLengthHeader( |
| request.url, |
| responseHeaders, |
| ); |
| |
| await _responseBody( |
| request.url, |
| httpUrlConnection, |
| request.sendPort, |
| contentLengthHeader, |
| ); |
| |
| httpUrlConnection.disconnect(); |
| |
| // Signals to the receiving isolate that we are done sending events. |
| request.sendPort.send(null); |
| } |
| |
| void _setRequestBody( |
| HttpURLConnection httpUrlConnection, |
| Uint8List requestBody, |
| ) { |
| if (requestBody.isEmpty) return; |
| |
| httpUrlConnection.setDoOutput(true); |
| |
| httpUrlConnection.getOutputStream() |
| ..write1(requestBody.toJArray()) |
| ..flush() |
| ..close(); |
| } |
| |
| int _statusCode(Uri requestUrl, HttpURLConnection httpUrlConnection) { |
| final statusCode = httpUrlConnection.getResponseCode(); |
| |
| if (statusCode == -1) { |
| throw ClientException( |
| 'Status code can not be discerned from the response.', requestUrl); |
| } |
| |
| return statusCode; |
| } |
| |
| String? _reasonPhrase(HttpURLConnection httpUrlConnection) { |
| final reasonPhrase = httpUrlConnection.getResponseMessage(); |
| |
| return reasonPhrase.isNull |
| ? null |
| : reasonPhrase.toDartString(deleteOriginal: true); |
| } |
| |
| Map<String, String> _responseHeaders(HttpURLConnection httpUrlConnection) { |
| final headers = <String, List<String>>{}; |
| |
| for (var i = 0;; i++) { |
| final headerName = httpUrlConnection.getHeaderFieldKey(i); |
| final headerValue = httpUrlConnection.getHeaderField1(i); |
| |
| // If the header name and header value are both null then we have reached |
| // the end of the response headers. |
| if (headerName.isNull && headerValue.isNull) break; |
| |
| // The HTTP response header status line is returned as a header field |
| // where the field key is null and the field is the status line. |
| // Other package:http implementations don't include the status line as a |
| // header. So we don't add the status line to the headers. |
| if (headerName.isNull) continue; |
| |
| headers |
| .putIfAbsent(headerName.toDartString().toLowerCase(), () => []) |
| .add(headerValue.toDartString()); |
| } |
| |
| return headers.map((key, value) => MapEntry(key, value.join(','))); |
| } |
| |
| int? _parseContentLengthHeader( |
| Uri requestUrl, |
| Map<String, String> headers, |
| ) { |
| int? contentLength; |
| switch (headers['content-length']) { |
| case final contentLengthHeader? |
| when !_digitRegex.hasMatch(contentLengthHeader): |
| throw ClientException( |
| 'Invalid content-length header [$contentLengthHeader].', |
| requestUrl, |
| ); |
| case final contentLengthHeader?: |
| contentLength = int.parse(contentLengthHeader); |
| } |
| |
| return contentLength; |
| } |
| |
| Future<void> _responseBody( |
| Uri requestUrl, |
| HttpURLConnection httpUrlConnection, |
| SendPort sendPort, |
| int? expectedBodyLength, |
| ) async { |
| final responseCode = httpUrlConnection.getResponseCode(); |
| |
| final responseBodyStream = (responseCode >= 200 && responseCode <= 299) |
| ? BufferedInputStream(httpUrlConnection.getInputStream()) |
| : BufferedInputStream(httpUrlConnection.getErrorStream()); |
| |
| var actualBodyLength = 0; |
| final bytesBuffer = JArray(jbyte.type, 4096); |
| |
| while (true) { |
| // TODO: read1() could throw IOException. |
| final bytesCount = |
| responseBodyStream.read1(bytesBuffer, 0, bytesBuffer.length); |
| |
| if (bytesCount == -1) { |
| break; |
| } |
| |
| if (bytesCount == 0) { |
| // No more data is available without blocking so give other Isolates an |
| // opportunity to run. |
| await Future<void>.delayed(Duration.zero); |
| continue; |
| } |
| |
| sendPort.send(bytesBuffer.toUint8List(length: bytesCount)); |
| actualBodyLength += bytesCount; |
| } |
| |
| if (expectedBodyLength != null && actualBodyLength < expectedBodyLength) { |
| sendPort.send(ClientException('Unexpected end of body', requestUrl)); |
| } |
| |
| responseBodyStream.close(); |
| } |
| } |
| |
| extension on Uint8List { |
| JArray<jbyte> toJArray() => |
| JArray(jbyte.type, length)..setRange(0, length, this); |
| } |
| |
| extension on JArray<jbyte> { |
| Uint8List toUint8List({int? length}) { |
| length ??= this.length; |
| final list = Uint8List(length); |
| for (var i = 0; i < length; i++) { |
| list[i] = this[i]; |
| } |
| return list; |
| } |
| } |