diff --git a/pkgs/cupertino_http/example/integration_test/utils_test.dart b/pkgs/cupertino_http/example/integration_test/utils_test.dart index 1bf1ca6f40..315282a6be 100644 --- a/pkgs/cupertino_http/example/integration_test/utils_test.dart +++ b/pkgs/cupertino_http/example/integration_test/utils_test.dart @@ -20,11 +20,11 @@ void main() { }); }); - group('stringDictToMap', () { + group('stringNSDictionaryToMap', () { test('empty input', () { final d = ncb.NSMutableDictionary.new1(linkedLibs); - expect(stringDictToMap(d), {}); + expect(stringNSDictionaryToMap(d), {}); }); test('single string input', () { @@ -32,7 +32,7 @@ void main() { ..setObject_forKey_( 'value'.toNSString(linkedLibs), 'key'.toNSString(linkedLibs)); - expect(stringDictToMap(d), {'key': 'value'}); + expect(stringNSDictionaryToMap(d), {'key': 'value'}); }); test('multiple string input', () { @@ -43,8 +43,31 @@ void main() { 'value2'.toNSString(linkedLibs), 'key2'.toNSString(linkedLibs)) ..setObject_forKey_( 'value3'.toNSString(linkedLibs), 'key3'.toNSString(linkedLibs)); - expect(stringDictToMap(d), + expect(stringNSDictionaryToMap(d), {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'}); }); }); + + group('stringIterableToNSArray', () { + test('empty input', () { + final array = stringIterableToNSArray([]); + expect(array.count, 0); + }); + + test('single string input', () { + final array = stringIterableToNSArray(['apple']); + expect(array.count, 1); + expect( + ncb.NSString.castFrom(array.objectAtIndex_(0)).toString(), 'apple'); + }); + + test('multiple string input', () { + final array = stringIterableToNSArray(['apple', 'banana']); + expect(array.count, 2); + expect( + ncb.NSString.castFrom(array.objectAtIndex_(0)).toString(), 'apple'); + expect( + ncb.NSString.castFrom(array.objectAtIndex_(1)).toString(), 'banana'); + }); + }); } diff --git a/pkgs/cupertino_http/example/integration_test/web_socket_conformance_test.dart b/pkgs/cupertino_http/example/integration_test/web_socket_conformance_test.dart new file mode 100644 index 0000000000..68e5f80322 --- /dev/null +++ b/pkgs/cupertino_http/example/integration_test/web_socket_conformance_test.dart @@ -0,0 +1,10 @@ +// 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 'package:cupertino_http/cupertino_http.dart'; +import 'package:web_socket_conformance_tests/web_socket_conformance_tests.dart'; + +void main() { + testAll(CupertinoWebSocket.connect); +} diff --git a/pkgs/cupertino_http/example/pubspec.yaml b/pkgs/cupertino_http/example/pubspec.yaml index 8d61e62bed..f394051994 100644 --- a/pkgs/cupertino_http/example/pubspec.yaml +++ b/pkgs/cupertino_http/example/pubspec.yaml @@ -31,6 +31,8 @@ dev_dependencies: integration_test: sdk: flutter test: ^1.21.1 + web_socket_conformance_tests: + path: ../../web_socket_conformance_tests/ flutter: uses-material-design: true diff --git a/pkgs/cupertino_http/lib/cupertino_http.dart b/pkgs/cupertino_http/lib/cupertino_http.dart index 243ac81436..68691b8ab4 100644 --- a/pkgs/cupertino_http/lib/cupertino_http.dart +++ b/pkgs/cupertino_http/lib/cupertino_http.dart @@ -88,3 +88,4 @@ import 'src/cupertino_client.dart'; export 'src/cupertino_api.dart'; export 'src/cupertino_client.dart'; +export 'src/cupertino_web_socket.dart'; diff --git a/pkgs/cupertino_http/lib/src/cupertino_api.dart b/pkgs/cupertino_http/lib/src/cupertino_api.dart index 5cebd7ed42..780ddde0ec 100644 --- a/pkgs/cupertino_http/lib/src/cupertino_api.dart +++ b/pkgs/cupertino_http/lib/src/cupertino_api.dart @@ -352,7 +352,7 @@ class URLSessionConfiguration Map? get httpAdditionalHeaders { if (_nsObject.HTTPAdditionalHeaders case var additionalHeaders?) { final headers = ncb.NSDictionary.castFrom(additionalHeaders); - return stringDictToMap(headers); + return stringNSDictionaryToMap(headers); } return null; } @@ -628,7 +628,7 @@ class HTTPURLResponse extends URLResponse { Map get allHeaderFields { final headers = ncb.NSDictionary.castFrom(_httpUrlResponse.allHeaderFields!); - return stringDictToMap(headers); + return stringNSDictionaryToMap(headers); } @override @@ -992,7 +992,7 @@ class URLRequest extends _ObjectHolder { return null; } else { final headers = ncb.NSDictionary.castFrom(_nsObject.allHTTPHeaderFields!); - return stringDictToMap(headers); + return stringNSDictionaryToMap(headers); } } @@ -1584,4 +1584,35 @@ class URLSession extends _ObjectHolder { onWebSocketTaskClosed: _onWebSocketTaskClosed); return task; } + + /// Creates a [URLSessionWebSocketTask] that represents a connection to a + /// WebSocket endpoint. + /// + /// See [NSURLSession webSocketTaskWithURL:protocols:](https://developer.apple.com/documentation/foundation/nsurlsession/3181172-websockettaskwithurl) + URLSessionWebSocketTask webSocketTaskWithURL(Uri uri, + {Iterable? protocols}) { + if (_isBackground) { + throw UnsupportedError( + 'WebSocket tasks are not supported in background sessions'); + } + + final URLSessionWebSocketTask task; + if (protocols == null) { + task = URLSessionWebSocketTask._( + _nsObject.webSocketTaskWithURL_(uriToNSURL(uri))); + } else { + task = URLSessionWebSocketTask._( + _nsObject.webSocketTaskWithURL_protocols_( + uriToNSURL(uri), stringIterableToNSArray(protocols))); + } + _setupDelegation(_delegate, this, task, + onComplete: _onComplete, + onData: _onData, + onFinishedDownloading: _onFinishedDownloading, + onRedirect: _onRedirect, + onResponse: _onResponse, + onWebSocketTaskOpened: _onWebSocketTaskOpened, + onWebSocketTaskClosed: _onWebSocketTaskClosed); + return task; + } } diff --git a/pkgs/cupertino_http/lib/src/cupertino_web_socket.dart b/pkgs/cupertino_http/lib/src/cupertino_web_socket.dart new file mode 100644 index 0000000000..6b4cab070c --- /dev/null +++ b/pkgs/cupertino_http/lib/src/cupertino_web_socket.dart @@ -0,0 +1,178 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:web_socket/web_socket.dart'; + +import 'cupertino_api.dart'; + +/// An error occurred while connecting to the peer. +class ConnectionErrorException extends WebSocketException { + final Error error; + + ConnectionErrorException(super.message, this.error); + + @override + String toString() => 'CupertinoErrorWebSocketException: $message $error'; +} + +/// A [WebSocket] using the +/// [NSURLSessionWebSocketTask API](https://developer.apple.com/documentation/foundation/nsurlsessionwebsockettask). +class CupertinoWebSocket implements WebSocket { + /// Create a new WebSocket connection using the + /// [NSURLSessionWebSocketTask API](https://developer.apple.com/documentation/foundation/nsurlsessionwebsockettask). + /// + /// The URL supplied in [url] must use the scheme ws or wss. + /// + /// If provided, the [protocols] argument indicates that subprotocols that + /// the peer is able to select. See + /// [RFC-6455 1.9](https://datatracker.ietf.org/doc/html/rfc6455#section-1.9). + static Future connect(Uri url, + {Iterable? protocols}) async { + if (!url.isScheme('ws') && !url.isScheme('wss')) { + throw ArgumentError.value( + url, 'url', 'only ws: and wss: schemes are supported'); + } + + final readyCompleter = Completer(); + late CupertinoWebSocket webSocket; + + final session = URLSession.sessionWithConfiguration( + URLSessionConfiguration.defaultSessionConfiguration(), + onComplete: (session, task, error) { + if (!readyCompleter.isCompleted) { + if (error != null) { + readyCompleter.completeError( + ConnectionErrorException('connection ended unexpectedly', error)); + } else { + webSocket = CupertinoWebSocket._(task as URLSessionWebSocketTask, ''); + readyCompleter.complete(webSocket); + } + } else { + webSocket._connectionClosed( + 1006, Data.fromList('abnormal close'.codeUnits)); + } + }, onWebSocketTaskOpened: (session, task, protocol) { + webSocket = CupertinoWebSocket._(task, protocol ?? ''); + readyCompleter.complete(webSocket); + }, onWebSocketTaskClosed: (session, task, closeCode, reason) { + webSocket._connectionClosed(closeCode, reason); + }); + + session.webSocketTaskWithURL(url, protocols: protocols).resume(); + return readyCompleter.future; + } + + final URLSessionWebSocketTask _task; + final String _protocol; + final _events = StreamController(); + + CupertinoWebSocket._(this._task, this._protocol) { + _scheduleReceive(); + } + + /// Handle an incoming message from the peer and schedule receiving the next + /// message. + void _handleMessage(URLSessionWebSocketMessage value) { + late WebSocketEvent event; + switch (value.type) { + case URLSessionWebSocketMessageType.urlSessionWebSocketMessageTypeString: + event = TextDataReceived(value.string!); + break; + case URLSessionWebSocketMessageType.urlSessionWebSocketMessageTypeData: + event = BinaryDataReceived(value.data!.bytes); + break; + } + _events.add(event); + _scheduleReceive(); + } + + void _scheduleReceive() { + unawaited(_task + .receiveMessage() + .then(_handleMessage, onError: _closeConnectionWithError)); + } + + /// Close the WebSocket connection due to an error and send the + /// [CloseReceived] event. + void _closeConnectionWithError(Object e) { + if (e is Error) { + if (e.domain == 'NSPOSIXErrorDomain' && e.code == 57) { + // Socket is not connected. + // onWebSocketTaskClosed/onComplete will be invoked and may indicate a + // close code. + return; + } + var (int code, String? reason) = switch ([e.domain, e.code]) { + ['NSPOSIXErrorDomain', 100] => (1002, e.localizedDescription), + _ => (1006, e.localizedDescription) + }; + _task.cancel(); + _connectionClosed( + code, reason == null ? null : Data.fromList(reason.codeUnits)); + } else { + throw StateError('unexpected error: $e'); + } + } + + void _connectionClosed(int? closeCode, Data? reason) { + if (!_events.isClosed) { + final closeReason = reason == null ? '' : utf8.decode(reason.bytes); + + _events + ..add(CloseReceived(closeCode, closeReason)) + ..close(); + } + } + + @override + void sendBytes(Uint8List b) { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + _task + .sendMessage(URLSessionWebSocketMessage.fromData(Data.fromList(b))) + .then((_) => _, onError: _closeConnectionWithError); + } + + @override + void sendText(String s) { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + _task + .sendMessage(URLSessionWebSocketMessage.fromString(s)) + .then((_) => _, onError: _closeConnectionWithError); + } + + @override + Future close([int? code, String? reason]) async { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + + if (code != null) { + RangeError.checkValueInInterval(code, 3000, 4999, 'code'); + } + if (reason != null && utf8.encode(reason).length > 123) { + throw ArgumentError.value(reason, 'reason', + 'reason must be <= 123 bytes long when encoded as UTF-8'); + } + + if (!_events.isClosed) { + unawaited(_events.close()); + if (code != null) { + reason = reason ?? ''; + _task.cancelWithCloseCode(code, Data.fromList(reason.codeUnits)); + } else { + _task.cancel(); + } + } + } + + @override + Stream get events => _events.stream; + + @override + String get protocol => _protocol; +} diff --git a/pkgs/cupertino_http/lib/src/utils.dart b/pkgs/cupertino_http/lib/src/utils.dart index be23e5cdcb..02fc5489b1 100644 --- a/pkgs/cupertino_http/lib/src/utils.dart +++ b/pkgs/cupertino_http/lib/src/utils.dart @@ -59,7 +59,7 @@ String? toStringOrNull(ncb.NSString? s) { /// Converts a NSDictionary containing NSString keys and NSString values into /// an equivalent map. -Map stringDictToMap(ncb.NSDictionary d) { +Map stringNSDictionaryToMap(ncb.NSDictionary d) { // TODO(https://github.com/dart-lang/ffigen/issues/374): Make this // function type safe. Currently it will unconditionally cast both keys and // values to NSString with a likely crash down the line if that isn't their @@ -78,5 +78,16 @@ Map stringDictToMap(ncb.NSDictionary d) { return m; } +ncb.NSArray stringIterableToNSArray(Iterable strings) { + final array = + ncb.NSMutableArray.arrayWithCapacity_(linkedLibs, strings.length); + + var index = 0; + for (var s in strings) { + array.setObject_atIndexedSubscript_(s.toNSString(linkedLibs), index++); + } + return array; +} + ncb.NSURL uriToNSURL(Uri uri) => ncb.NSURL.URLWithString_(linkedLibs, uri.toString().toNSString(linkedLibs)); diff --git a/pkgs/cupertino_http/pubspec.yaml b/pkgs/cupertino_http/pubspec.yaml index 2a819e120b..13255127b8 100644 --- a/pkgs/cupertino_http/pubspec.yaml +++ b/pkgs/cupertino_http/pubspec.yaml @@ -15,6 +15,8 @@ dependencies: flutter: sdk: flutter http: ^1.2.0 + web_socket: + path: ../web_socket dev_dependencies: dart_flutter_team_lints: ^2.0.0