diff --git a/mobile/lib/features/agents/agent_creation_draft.dart b/mobile/lib/features/agents/agent_creation_draft.dart index d1f4a0f3b3..d4110a9783 100644 --- a/mobile/lib/features/agents/agent_creation_draft.dart +++ b/mobile/lib/features/agents/agent_creation_draft.dart @@ -6,13 +6,14 @@ import 'package:http/http.dart' as http; import '../../shared/deeplink/deep_link.dart'; import '../../shared/hosted/hosted_environment.dart'; +import '../../shared/http/app_http_client.dart'; import '../../shared/utils/bounded_fetch.dart'; import 'agent_avatar_picker.dart'; /// Creates the HTTP client that fetches a card's `agent.json`; overridden in /// tests. final agentCardClientProvider = Provider( - (ref) => http.Client.new, + (ref) => newAppHttpClient, ); /// Largest `agent.json` accepted, far above any real card. diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index af7875badf..0eea0f29a2 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -4,6 +4,7 @@ import 'package:buzz/shared/widgets/adaptive_conversation_pane.dart'; import 'channel_unread.dart'; import '../../shared/widgets/collapsible_section_body.dart'; import '../../shared/widgets/collapsible_sliver_body.dart'; +import '../../shared/http/app_http_client.dart'; import '../../shared/me/me_sync.dart'; import '../../shared/hosted/hosted_relay_auth.dart'; import '../../shared/hosted/organization_environment.dart'; diff --git a/mobile/lib/features/channels/channels_page/badges.dart b/mobile/lib/features/channels/channels_page/badges.dart index e2ae974bc7..680ee9a85a 100644 --- a/mobile/lib/features/channels/channels_page/badges.dart +++ b/mobile/lib/features/channels/channels_page/badges.dart @@ -46,7 +46,8 @@ class _ErrorView extends HookConsumerWidget { } return 'Server error (${error.statusCode}). Try again later.'; } - if (error is SocketException) { + if (error is SocketException || + urlSessionFailure(error) == TransportFailure.unreachable) { return 'Could not reach the relay server.'; } return 'Something went wrong. Check your connection.'; diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index 7b376397c1..49a7b4e6a8 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:url_launcher/url_launcher.dart'; @@ -515,7 +516,15 @@ String _friendlyInviteError(Object error) { if (message.contains('join_policy_required')) { return 'Review this community’s join requirements in your browser, then return to Buzz.'; } - if (message.contains('SocketException') || + final transportFailure = urlSessionFailure(error); + if (transportFailure == TransportFailure.insecure) { + return 'Secure connection failed. Check your network settings and try again.'; + } + if (transportFailure == TransportFailure.timedOut) { + return 'Connection timed out. Check your internet connection and try again.'; + } + if (transportFailure == TransportFailure.unreachable || + message.contains('SocketException') || message.contains('Connection refused') || message.contains('Network is unreachable') || message.contains('No route to host')) { @@ -526,7 +535,8 @@ String _friendlyInviteError(Object error) { String _friendlyStarterSetupError(Object error) { final message = error.toString(); - if (message.contains('SocketException') || + if (urlSessionFailure(error) == TransportFailure.unreachable || + message.contains('SocketException') || message.contains('Connection refused') || message.contains('Network is unreachable') || message.contains('No route to host')) { diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 435b992d2b..f73f9c9100 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/shared/http/app_http_client.dart'; import 'package:nostr/nostr.dart' as nostr; import '../../shared/auth/auth.dart'; diff --git a/mobile/lib/features/pairing/pairing_provider_helpers.dart b/mobile/lib/features/pairing/pairing_provider_helpers.dart index 30d11932a5..e2def264e8 100644 --- a/mobile/lib/features/pairing/pairing_provider_helpers.dart +++ b/mobile/lib/features/pairing/pairing_provider_helpers.dart @@ -2,7 +2,9 @@ part of 'pairing_provider.dart'; String _friendlyErrorMessage(Object error) { final message = error.toString(); - if (message.contains('SocketException') || + final transportFailure = urlSessionFailure(error); + if (transportFailure == TransportFailure.unreachable || + message.contains('SocketException') || message.contains('Connection refused') || message.contains('Network is unreachable') || message.contains('No route to host') || @@ -18,12 +20,15 @@ String _friendlyErrorMessage(Object error) { message.contains('Null check operator used on a null value')) { return 'Pairing stopped because of an internal error. Please try again.'; } - if (message.contains('HandshakeException') || + if (transportFailure == TransportFailure.insecure || + message.contains('HandshakeException') || message.contains('CERTIFICATE_VERIFY_FAILED')) { return 'Secure connection failed. Check your network settings ' 'and try again.'; } - if (message.contains('TimeoutException') || message.contains('timed out')) { + if (transportFailure == TransportFailure.timedOut || + message.contains('TimeoutException') || + message.contains('timed out')) { return 'Connection timed out. Check your internet connection and ' 'try again.'; } diff --git a/mobile/lib/shared/community/community_icon_provider.dart b/mobile/lib/shared/community/community_icon_provider.dart index 9bcf3ecad2..9cce187378 100644 --- a/mobile/lib/shared/community/community_icon_provider.dart +++ b/mobile/lib/shared/community/community_icon_provider.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; import 'community_icon_cache.dart'; import 'community_icon_artwork.dart'; @@ -10,12 +11,14 @@ import 'community_icon_artwork.dart'; const _maximumIconBytes = 256 * 1024; const _lookupTimeout = Duration(seconds: 5); -/// Supplies the unauthenticated client for public NIP-11 documents and icons. -final communityIconHttpClientProvider = Provider((ref) { - final client = http.Client(); - ref.onDispose(client.close); - return client; -}); +/// Supplies the unauthenticated clients for public NIP-11 documents (API +/// traffic) and icon images (media traffic). +final communityIconHttpClientProvider = + Provider.family((ref, traffic) { + final client = newAppHttpClient(traffic); + ref.onDispose(client.close); + return client; + }); /// Immediately presents saved artwork while a public metadata refresh runs. final communityIconPresentationProvider = Provider.autoDispose @@ -36,7 +39,7 @@ final communityIconProvider = FutureProvider.autoDispose final key = uri.toString(); final cache = ref.read(communityIconCacheProvider.notifier); final saved = ref.read(communityIconCacheProvider)[key]; - final client = ref.read(communityIconHttpClientProvider); + final client = ref.read(communityIconHttpClientProvider(HttpTraffic.api)); var disposed = false; final cancelled = Completer(); ref.onDispose(() { @@ -68,7 +71,11 @@ final communityIconProvider = FutureProvider.autoDispose final imageUri = Uri.tryParse(icon); if (imageUri != null && (imageUri.scheme == 'https' || imageUri.scheme == 'http')) { - final image = await _download(client, imageUri, cancelled.future); + final image = await _download( + ref.read(communityIconHttpClientProvider(HttpTraffic.media)), + imageUri, + cancelled.future, + ); if (image != null && image.mimeType.startsWith('image/')) { artwork = Uri.dataFromBytes( image.bytes, diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index 9bf7f0a029..dd4c10ba6f 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; import '../storage/secure_storage.dart'; @@ -62,7 +63,7 @@ class BuilderlabAccountClient { FlutterSecureStorage? storage, Uri? accountOrigin, this.onSessionChanged, - }) : _http = httpClient ?? http.Client(), + }) : _http = httpClient ?? newAppHttpClient(), _storage = storage ?? defaultStorage, accountOrigin = accountOrigin ?? origin; diff --git a/mobile/lib/shared/hosted/hosted_admission.dart b/mobile/lib/shared/hosted/hosted_admission.dart index 2d30d478a6..aaf46f0ea3 100644 --- a/mobile/lib/shared/hosted/hosted_admission.dart +++ b/mobile/lib/shared/hosted/hosted_admission.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; import '../community/community.dart'; import '../deeplink/deep_link.dart'; @@ -26,7 +27,7 @@ class HostedAdmission { Future Function(HostedJoinPolicy)? confirmPolicy, Future Function(Community)? probe, }) : _confirmPolicy = confirmPolicy ?? ((_) async => false), - _http = client ?? http.Client(), + _http = client ?? newAppHttpClient(), _probe = probe; final HostedRelayAuth relayAuth; final BuilderlabAccountClient account; diff --git a/mobile/lib/shared/hosted/hosted_relay_auth.dart b/mobile/lib/shared/hosted/hosted_relay_auth.dart index da81f8c550..99fb416204 100644 --- a/mobile/lib/shared/hosted/hosted_relay_auth.dart +++ b/mobile/lib/shared/hosted/hosted_relay_auth.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; import '../relay/relay_provider.dart'; import 'builderlab_account.dart'; @@ -67,11 +68,13 @@ final hostedRelayAuthProvider = Provider((ref) { }); /// Each transport owns its client lifetime; configuration and auth are shared. -final hostedHttpClientFactoryProvider = Provider((ref) { - final auth = ref.watch(hostedRelayAuthProvider); - final config = ref.watch(relayConfigProvider); - return () => HostedRelayHttpClient(auth, config); -}); +final hostedHttpClientFactoryProvider = + Provider((ref) { + final auth = ref.watch(hostedRelayAuthProvider); + final config = ref.watch(relayConfigProvider); + return ({HttpTraffic traffic = HttpTraffic.api}) => + HostedRelayHttpClient(auth, config, inner: newAppHttpClient(traffic)); + }); /// The build's one Builderlab session, with a short-lived assertion for the /// current relay/key. A public build's hosted communities use [account] when @@ -91,7 +94,7 @@ class HostedRelayAuth extends ChangeNotifier { _discover = discover ?? _fetchRequirement; static Future _fetchRequirement(Uri origin) async { - final client = http.Client(); + final client = newAppHttpClient(); try { return await fetchNipFiRequirement(client, origin); } finally { @@ -500,7 +503,7 @@ class HostedRelayHttpClient extends http.BaseClient { this.config, { http.Client? inner, this.canSend, - }) : _inner = inner ?? http.Client(); + }) : _inner = inner ?? newAppHttpClient(); final HostedRelayAuth auth; final RelayConfig config; final http.Client _inner; diff --git a/mobile/lib/shared/hosted/saved_identity_profile.dart b/mobile/lib/shared/hosted/saved_identity_profile.dart index 8182e6b447..fb730d8c72 100644 --- a/mobile/lib/shared/hosted/saved_identity_profile.dart +++ b/mobile/lib/shared/hosted/saved_identity_profile.dart @@ -15,9 +15,10 @@ import '../relay/relay_query_response.dart'; import '../relay/relay_session.dart'; import 'device_identity_source.dart'; import 'hosted_relay_auth.dart'; +import '../http/app_http_client.dart'; final savedIdentityProfileClientProvider = Provider( - (ref) => http.Client.new, + (ref) => newAppHttpClient, ); final _profileQueueProvider = Provider((ref) => _ProfileQueue()); diff --git a/mobile/lib/shared/http/app_http_client.dart b/mobile/lib/shared/http/app_http_client.dart new file mode 100644 index 0000000000..faf8253769 --- /dev/null +++ b/mobile/lib/shared/http/app_http_client.dart @@ -0,0 +1,209 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:cupertino_http/cupertino_http.dart'; +import 'package:http/http.dart' as http; +import 'package:objective_c/objective_c.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; + +/// Which app-wide connection pool a request belongs to. +enum HttpTraffic { + /// Relay queries, NIP-11 documents, account and admission calls. + api, + + /// Image, video and voice-note downloads and media uploads. + media, +} + +final _sessions = {}; + +/// An HTTP client for [traffic]. Callers own it and close it as usual. +/// +/// On iOS, every client for the same [traffic] shares one `NSURLSession`, so +/// requests to a host multiplex over one HTTP/2 connection instead of opening +/// an HTTP/1.1 connection each, and gzip is inflated natively, off the Dart +/// isolate. Media has its own session, and so its own connection, so image +/// bytes cannot starve queries queued behind them on a shared connection. +/// +/// Elsewhere this is `http.Client()`, which also honors `http.runWithClient`. +http.Client newAppHttpClient([HttpTraffic traffic = HttpTraffic.api]) { + if (!Platform.isIOS) return http.Client(); + return SharedSessionClient(_sessions[traffic] ??= _openUrlSession()); +} + +/// Matches the `dart:io` transport apart from the protocol: no HTTP cache and +/// no cookies. +http.Client _openUrlSession() { + final config = URLSessionConfiguration.ephemeralSessionConfiguration() + ..cache = null + ..httpShouldSetCookies = false + ..httpCookieAcceptPolicy = + NSHTTPCookieAcceptPolicy.NSHTTPCookieAcceptPolicyNever; + return CupertinoClient.fromSessionConfiguration(config); +} + +/// A caller's handle on an app-wide session. Like closing a `dart:io` client, +/// [close] cancels the handle's requests still in flight and refuses later +/// ones, but leaves the session open for other handles. Closing a +/// `CupertinoClient` while any of its requests run would also throw. +/// +/// App Transport Security blocks cleartext `URLSession` loads from public +/// hosts, while saved community icons and remote media may still use +/// `http://`. Those keep the handle's own `dart:io` client, as before. +@visibleForTesting +class SharedSessionClient extends http.BaseClient { + SharedSessionClient(this._session, {http.Client Function()? cleartext}) + : _newCleartext = cleartext ?? http.Client.new; + + final http.Client _session; + final http.Client Function() _newCleartext; + + /// One abort trigger per request still running, so [close] can cancel it. + /// A request leaves the set once its response body is done, failed or + /// cancelled, or once the caller's own abort trigger fires. A body nobody + /// listens to stays until then: the session keeps downloading it, so + /// callers that drop a response unread must abort it. + final _inFlight = >{}; + var _closed = false; + http.Client? _cleartext; + + @override + Future send(http.BaseRequest request) async { + if (_closed) { + throw http.ClientException( + 'HTTP request failed. Client is already closed.', + request.url, + ); + } + if (request.url.scheme != 'https') { + return (_cleartext ??= _newCleartext()).send(request); + } + final abort = Completer(); + void fire() { + _inFlight.remove(abort); + if (!abort.isCompleted) abort.complete(); + } + + _inFlight.add(abort); + if (request case http.Abortable(:final abortTrigger?)) { + abortTrigger.then((_) => fire(), onError: (_) => fire()); + } + final http.StreamedResponse response; + try { + response = await _session.send(_withAbortTrigger(request, abort.future)); + } catch (_) { + _inFlight.remove(abort); + rethrow; + } + // A controller, not an async* wrapper: async* never listens to the + // session's stream when cancelled before its first event, so the + // session would keep downloading a body nobody reads. + late final StreamSubscription> inner; + final body = StreamController>(sync: true); + body + ..onListen = () { + inner = response.stream.listen( + body.add, + onError: body.addError, + onDone: () { + _inFlight.remove(abort); + body.close(); + }, + ); + } + ..onPause = (() => inner.pause()) + ..onResume = (() => inner.resume()) + ..onCancel = () { + _inFlight.remove(abort); + return inner.cancel(); + }; + + return http.StreamedResponse( + http.ByteStream(body.stream), + response.statusCode, + contentLength: response.contentLength, + request: response.request, + headers: response.headers, + isRedirect: response.isRedirect, + persistentConnection: response.persistentConnection, + reasonPhrase: response.reasonPhrase, + ); + } + + /// A copy of [request] that the session cancels when [trigger] completes. + http.BaseRequest _withAbortTrigger( + http.BaseRequest request, + Future trigger, + ) { + if (request is http.Request) { + return http.AbortableRequest( + request.method, + request.url, + abortTrigger: trigger, + ) + ..bodyBytes = request.bodyBytes + ..headers.addAll(request.headers) + ..followRedirects = request.followRedirects + ..maxRedirects = request.maxRedirects + ..persistentConnection = request.persistentConnection; + } + // Finalizing first lets a multipart request set its content type. + final body = request.finalize(); + final copy = + http.AbortableStreamedRequest( + request.method, + request.url, + abortTrigger: trigger, + ) + ..headers.addAll(request.headers) + ..contentLength = request.contentLength + ..followRedirects = request.followRedirects + ..maxRedirects = request.maxRedirects + ..persistentConnection = request.persistentConnection; + // addStream keeps backpressure: the body is read only as the session + // reads the copy, so upload progress tracks bytes the session has taken + // (including its read-ahead buffers), as with plain cupertino_http. + unawaited(copy.sink.addStream(body).whenComplete(copy.sink.close)); + return copy; + } + + @override + void close() { + _closed = true; + for (final abort in _inFlight.toList()) { + if (!abort.isCompleted) abort.complete(); + } + _inFlight.clear(); + _cleartext?.close(); + } +} + +/// How an iOS transport request failed, for user-facing messages. `dart:io` +/// failures are recognized by their messages instead. +enum TransportFailure { unreachable, insecure, timedOut } + +/// The [TransportFailure] for a `URLSession` error, or null for any other +/// error. +TransportFailure? urlSessionFailure(Object error) { + if (error is! NSErrorClientException) return null; + if (error.error.domain.toDartString() != 'NSURLErrorDomain') return null; + return urlErrorFailure(error.error.code); +} + +/// Classifies an `NSURLErrorDomain` code. +@visibleForTesting +TransportFailure? urlErrorFailure(int code) => switch (code) { + -1001 => TransportFailure.timedOut, + // Cannot find host, cannot connect, connection lost, DNS lookup failed, + // international roaming off, cellular data not allowed, offline. + -1003 || + -1004 || + -1005 || + -1006 || + -1018 || + -1020 || + -1009 => TransportFailure.unreachable, + // Secure connection failed and the server certificate errors. + >= -1206 && <= -1200 => TransportFailure.insecure, + _ => null, +}; diff --git a/mobile/lib/shared/identity_archives/identity_archives.dart b/mobile/lib/shared/identity_archives/identity_archives.dart index f1428f6b82..c77bde07e8 100644 --- a/mobile/lib/shared/identity_archives/identity_archives.dart +++ b/mobile/lib/shared/identity_archives/identity_archives.dart @@ -5,12 +5,13 @@ import 'dart:isolate'; import 'package:buzz/shared/crypto/event_signing.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; import '../relay/relay.dart'; import '../me/me_sync.dart'; final relayInformationClientProvider = Provider((ref) { - final client = http.Client(); + final client = newAppHttpClient(); ref.onDispose(client.close); return client; }); @@ -21,11 +22,14 @@ final relayInformationClientProvider = Provider((ref) { final identityArchivesProvider = FutureProvider>((ref) async { final sync = ref.watch(meSyncProvider); final client = ref.watch(relayInformationClientProvider); + // Completed on every exit, so a dropped, refused or timed-out response + // stops downloading on a shared session instead of staying in flight. + final abort = Completer(); try { final uri = Uri.parse(sync.community); final response = await client .send( - http.Request('GET', uri) + http.AbortableRequest('GET', uri, abortTrigger: abort.future) ..headers['Accept'] = 'application/nostr+json', ) .timeout(const Duration(seconds: 3)); @@ -65,6 +69,8 @@ final identityArchivesProvider = FutureProvider>((ref) async { return (await _parseArchives(events, authority))..remove(sync.pubkey); } catch (_) { return {}; + } finally { + if (!abort.isCompleted) abort.complete(); } }); diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index f741f22f09..c93fb4c6c4 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -1,8 +1,10 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:buzz/shared/crypto/event_signing.dart'; import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; import 'package:nostr/nostr.dart' as nostr; import '../crypto/nip44.dart'; @@ -243,10 +245,18 @@ Future fetchBuzzPushLeaseDescriptor( throw FormatException('Invalid relay HTTP URL: $relayBaseUrl'); } final requestUri = uri.resolve('/'); - final ownedClient = client ?? http.Client(); + final ownedClient = client ?? newAppHttpClient(); + // Closing a client does not stop a request on a shared session. + final abort = Completer(); try { + final request = http.AbortableRequest( + 'GET', + requestUri, + abortTrigger: abort.future, + )..headers['Accept'] = 'application/nostr+json'; final response = await ownedClient - .get(requestUri, headers: const {'Accept': 'application/nostr+json'}) + .send(request) + .then(http.Response.fromStream) .timeout(timeout); if (response.statusCode != 200) { throw StateError( @@ -259,6 +269,7 @@ Future fetchBuzzPushLeaseDescriptor( } return BuzzPushLeaseDescriptor.fromRelayInformation(decoded); } finally { + abort.complete(); if (client == null) ownedClient.close(); } } diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index 049c5fef0c..9268e825e8 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; import '../hosted/hosted_relay_auth.dart'; import '../utils/bounded_fetch.dart'; @@ -17,7 +18,9 @@ import 'media_disk_cache.dart'; /// so TLS connections are reused across images instead of re-handshaking per /// fetch. Override in tests to stub the network. final mediaHttpClientProvider = Provider((ref) { - final client = ref.watch(hostedHttpClientFactoryProvider)(); + final client = ref.watch(hostedHttpClientFactoryProvider)( + traffic: HttpTraffic.media, + ); ref.onDispose(client.close); return client; }); diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index c814c899d5..e183b8d3f5 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; import 'package:image_picker/image_picker.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:crypto/crypto.dart' as crypto; @@ -285,7 +286,7 @@ class MediaUploadService { _generateVideoPoster = generateVideoPoster ?? _generatePickedVideoPoster, _readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage, _now = now ?? DateTime.now, - _http = httpClient ?? http.Client(), + _http = httpClient ?? newAppHttpClient(HttpTraffic.media), _ownsHttpClient = httpClient == null; void dispose() { diff --git a/mobile/lib/shared/relay/media_upload/platform_bindings.dart b/mobile/lib/shared/relay/media_upload/platform_bindings.dart index 47e1675510..9c6981f1de 100644 --- a/mobile/lib/shared/relay/media_upload/platform_bindings.dart +++ b/mobile/lib/shared/relay/media_upload/platform_bindings.dart @@ -13,7 +13,9 @@ Future requiresLegacyMediaStoragePermission() async { final mediaUploadServiceProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); - final client = ref.watch(hostedHttpClientFactoryProvider)(); + final client = ref.watch(hostedHttpClientFactoryProvider)( + traffic: HttpTraffic.media, + ); ref.onDispose(client.close); final picker = ImagePicker(); final service = MediaUploadService( diff --git a/mobile/lib/shared/relay/relay_author_provider.dart b/mobile/lib/shared/relay/relay_author_provider.dart index f35b09bddd..01f35cb85a 100644 --- a/mobile/lib/shared/relay/relay_author_provider.dart +++ b/mobile/lib/shared/relay/relay_author_provider.dart @@ -3,9 +3,10 @@ import 'dart:convert'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; final relayInformationClientProvider = Provider((ref) { - final client = http.Client(); + final client = newAppHttpClient(); ref.onDispose(client.close); return client; }); @@ -24,27 +25,35 @@ final relayAuthorProvider = FutureProvider.autoDispose.family(( }, ); final client = ref.watch(relayInformationClientProvider); - final response = await client - .send( - http.Request('GET', uri)..headers['Accept'] = 'application/nostr+json', - ) - .timeout(const Duration(seconds: 3)); - final chunks = StreamIterator( - response.stream.timeout(const Duration(seconds: 3)), - ); + // Completed on every exit, so a dropped, refused or timed-out response + // stops downloading on a shared session instead of staying in flight. + final abort = Completer(); final bytes = []; try { + final response = await client + .send( + http.AbortableRequest('GET', uri, abortTrigger: abort.future) + ..headers['Accept'] = 'application/nostr+json', + ) + .timeout(const Duration(seconds: 3)); if (response.statusCode != 200) return null; - await (() async { - while (await chunks.moveNext()) { - if (bytes.length + chunks.current.length > 256 * 1024) { - throw const FormatException('Relay information exceeds size limit'); + final chunks = StreamIterator( + response.stream.timeout(const Duration(seconds: 3)), + ); + try { + await (() async { + while (await chunks.moveNext()) { + if (bytes.length + chunks.current.length > 256 * 1024) { + throw const FormatException('Relay information exceeds size limit'); + } + bytes.addAll(chunks.current); } - bytes.addAll(chunks.current); - } - })().timeout(const Duration(seconds: 3)); + })().timeout(const Duration(seconds: 3)); + } finally { + await chunks.cancel(); + } } finally { - await chunks.cancel(); + if (!abort.isCompleted) abort.complete(); } final info = jsonDecode(utf8.decode(bytes)); final author = info is Map ? info['self'] : null; diff --git a/mobile/lib/shared/relay/relay_client.dart b/mobile/lib/shared/relay/relay_client.dart index 453e11459e..894aeb2f84 100644 --- a/mobile/lib/shared/relay/relay_client.dart +++ b/mobile/lib/shared/relay/relay_client.dart @@ -1,4 +1,5 @@ import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; /// Lightweight HTTP context for talking to the Buzz relay. /// @@ -12,7 +13,7 @@ class RelayClient { final http.Client _http; RelayClient({required this.baseUrl, http.Client? httpClient}) - : _http = httpClient ?? http.Client(); + : _http = httpClient ?? newAppHttpClient(HttpTraffic.media); /// Shared underlying HTTP client (used by [MediaUploader]). http.Client get httpClient => _http; diff --git a/mobile/lib/shared/relay/relay_http_query_client.dart b/mobile/lib/shared/relay/relay_http_query_client.dart index 06e8dae730..0feea2261b 100644 --- a/mobile/lib/shared/relay/relay_http_query_client.dart +++ b/mobile/lib/shared/relay/relay_http_query_client.dart @@ -3,14 +3,17 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:http/io_client.dart'; +import 'package:buzz/shared/http/app_http_client.dart'; -/// The default transport. `dart:io` would inflate gzip responses on the -/// calling isolate, so leave them compressed for the response decoder. +/// The default transport. On iOS, the app-wide `NSURLSession` inflates gzip +/// natively, off the Dart isolate. `dart:io` would inflate on the calling +/// isolate, so leave its responses compressed for the response decoder. /// /// A non-`dart:io` client supplied through `http.runWithClient`, such as a /// `MockClient`, is used as is. An `IOClient` is replaced, not closed, since /// it may be shared. -http.Client _gzipPassthroughClient() { +http.Client _defaultTransport() { + if (Platform.isIOS) return newAppHttpClient(); final client = http.Client(); if (client is! IOClient) return client; return IOClient(HttpClient()..autoUncompress = false); @@ -25,7 +28,7 @@ class RelayHttpQueryClient { http.Client? client, http.Client Function()? clientFactory, }) : _injectedClient = client, - _clientFactory = clientFactory ?? _gzipPassthroughClient; + _clientFactory = clientFactory ?? _defaultTransport; final http.Client? _injectedClient; final http.Client Function() _clientFactory; @@ -44,12 +47,16 @@ class RelayHttpQueryClient { : null; generation?.acquire(); var active = true; + // Retiring a generation cannot stop a request on a shared session, so a + // timed-out request is aborted rather than left holding bandwidth. + final abort = Completer(); try { - final request = http.Request('POST', url) - ..followRedirects = false - ..headers['Accept-Encoding'] = 'gzip' - ..headers.addAll(headers) - ..bodyBytes = body; + final request = + http.AbortableRequest('POST', url, abortTrigger: abort.future) + ..followRedirects = false + ..headers['Accept-Encoding'] = 'gzip' + ..headers.addAll(headers) + ..bodyBytes = body; final transport = _injectedClient ?? generation!.client; final client = decorate?.call(transport, () => active) ?? transport; return await client @@ -57,6 +64,7 @@ class RelayHttpQueryClient { .then(http.Response.fromStream) .timeout(timeout); } on TimeoutException { + if (!abort.isCompleted) abort.complete(); if (identical(_currentGeneration, generation)) { _currentGeneration = null; } @@ -76,7 +84,17 @@ class RelayHttpQueryClient { required Duration timeout, http.Client Function(http.Client, bool Function())? decorate, }) async { - request.followRedirects = false; + // As in [post], a timed-out request on a shared session is aborted. + final abort = Completer(); + final sent = + http.AbortableRequest( + request.method, + request.url, + abortTrigger: abort.future, + ) + ..followRedirects = false + ..headers.addAll(request.headers) + ..bodyBytes = request.bodyBytes; final generation = _injectedClient == null ? (_currentGeneration ??= _createGeneration()) : null; @@ -86,7 +104,7 @@ class RelayHttpQueryClient { Future receive() async { final transport = _injectedClient ?? generation!.client; final client = decorate?.call(transport, () => !finished) ?? transport; - final response = await client.send(request); + final response = await client.send(sent); if (finished) { await response.stream.listen(null).cancel(); throw StateError('Relay request already timed out'); @@ -115,6 +133,7 @@ class RelayHttpQueryClient { try { return await receive().timeout(timeout); } on TimeoutException { + if (!abort.isCompleted) abort.complete(); if (identical(_currentGeneration, generation)) { _currentGeneration = null; } @@ -122,6 +141,9 @@ class RelayHttpQueryClient { rethrow; } finally { finished = true; + // Cancelling an iterator that never listened leaves the body + // downloading on a shared session, so abort on every exit. + if (!abort.isCompleted) abort.complete(); if (iterator != null) await iterator!.cancel(); generation?.release(); } diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 692441d211..de0820908e 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -1,4 +1,5 @@ import 'package:buzz/shared/crypto/event_signing.dart'; +import 'package:buzz/shared/http/app_http_client.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -151,7 +152,9 @@ final relayClientProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); final client = RelayClient( baseUrl: config.baseUrl, - httpClient: ref.watch(hostedHttpClientFactoryProvider)(), + httpClient: ref.watch(hostedHttpClientFactoryProvider)( + traffic: HttpTraffic.media, + ), ); ref.onDispose(client.dispose); return client; diff --git a/mobile/lib/shared/relay/relay_session_snapshot.dart b/mobile/lib/shared/relay/relay_session_snapshot.dart index 138e5c3f31..8434a3bb82 100644 --- a/mobile/lib/shared/relay/relay_session_snapshot.dart +++ b/mobile/lib/shared/relay/relay_session_snapshot.dart @@ -53,10 +53,10 @@ extension on RelaySessionNotifier { final origin = Uri.parse(config.baseUrl); final discovery = http.Request('GET', origin.resolve('/')) ..headers['Accept'] = 'application/nostr+json'; - // The query transport leaves gzip bodies compressed (see - // RelayHttpQueryClient), and the relay's CDN gzips both responses. The - // inflated size is capped too: the transport cap only sees the - // compressed bytes. + // The relay's CDN gzips both responses. The inflated size is capped too: + // where the query transport leaves gzip bodies compressed (see + // RelayHttpQueryClient), the transport cap only sees the compressed + // bytes. final discovered = await send(discovery, 64 * 1024); final info = jsonDecode( relayResponseText( diff --git a/mobile/lib/shared/verified_profiles/verified_profile_discovery_fetch.dart b/mobile/lib/shared/verified_profiles/verified_profile_discovery_fetch.dart index d0e66d1e08..71bcaa9a8d 100644 --- a/mobile/lib/shared/verified_profiles/verified_profile_discovery_fetch.dart +++ b/mobile/lib/shared/verified_profiles/verified_profile_discovery_fetch.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:buzz/shared/http/app_http_client.dart'; import '../relay/nip11_document.dart'; import '../relay/relay.dart'; @@ -11,7 +12,7 @@ typedef VerifiedProfileDiscoveryFetch = Future Function(); final verifiedProfileHttpClientProvider = Provider((ref) { - final client = http.Client(); + final client = newAppHttpClient(); ref.onDispose(client.close); return client; }); diff --git a/mobile/packages/buzz_secp256k1/pubspec.lock b/mobile/packages/buzz_secp256k1/pubspec.lock index 13db8441b5..a2b85f9d20 100644 --- a/mobile/packages/buzz_secp256k1/pubspec.lock +++ b/mobile/packages/buzz_secp256k1/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: "direct main" description: name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.2.1" collection: dependency: transitive description: @@ -157,10 +157,10 @@ packages: dependency: "direct main" description: name: hooks - sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "2.2.0" http: dependency: transitive description: @@ -229,10 +229,10 @@ packages: dependency: "direct main" description: name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + sha256: a1c26117c48cebe5677b0cf0e33a980a79a7c5577effc86f52e5a0d309cdcb60 url: "https://pub.dev" source: hosted - version: "0.17.6" + version: "0.19.3" node_preamble: dependency: transitive description: @@ -293,10 +293,10 @@ packages: dependency: transitive description: name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "1.1.1" shelf: dependency: transitive description: diff --git a/mobile/packages/buzz_secp256k1/pubspec.yaml b/mobile/packages/buzz_secp256k1/pubspec.yaml index c84ad25671..82b8987192 100644 --- a/mobile/packages/buzz_secp256k1/pubspec.yaml +++ b/mobile/packages/buzz_secp256k1/pubspec.yaml @@ -9,10 +9,10 @@ environment: sdk: ^3.11.4 dependencies: - code_assets: ^1.0.0 + code_assets: ^1.2.0 ffi: ^2.2.0 - hooks: ^1.0.2 - native_toolchain_c: ^0.17.6 + hooks: ^2.0.0 + native_toolchain_c: ^0.19.1 dev_dependencies: test: ^1.26.0 diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 37404e81a1..298254bbbd 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -244,10 +244,10 @@ packages: dependency: transitive description: name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.2.1" collection: dependency: transitive description: @@ -312,6 +312,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_http: + dependency: "direct main" + description: + name: cupertino_http + sha256: d57188b07349a373bec9a78997a80c00e1de08dab0fe454b3f86c84661ac457c + url: "https://pub.dev" + source: hosted + version: "3.1.0" cupertino_icons: dependency: "direct main" description: @@ -659,10 +667,10 @@ packages: dependency: transitive description: name: hooks - sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "2.2.0" hooks_riverpod: dependency: "direct main" description: @@ -703,6 +711,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + http_profile: + dependency: transitive + description: + name: http_profile + sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78" + url: "https://pub.dev" + source: hosted + version: "0.1.0" image: dependency: "direct main" description: @@ -971,10 +987,10 @@ packages: dependency: transitive description: name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + sha256: a1c26117c48cebe5677b0cf0e33a980a79a7c5577effc86f52e5a0d309cdcb60 url: "https://pub.dev" source: hosted - version: "0.17.6" + version: "0.19.3" nm: dependency: transitive description: @@ -1000,13 +1016,13 @@ packages: source: hosted version: "2.0.0" objective_c: - dependency: transitive + dependency: "direct main" description: name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e url: "https://pub.dev" source: hosted - version: "9.3.0" + version: "9.5.0" open_filex: dependency: "direct main" description: @@ -1223,6 +1239,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" record_web: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index cfb45150f6..99811b86a5 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -18,6 +18,10 @@ dependencies: hooks_riverpod: ^3.0.3 flutter_hooks: ^0.21.3 http: ^1.4.0 + # iOS HTTP transport (NSURLSession): HTTP/2 and native gzip. See + # lib/shared/http/app_http_client.dart. + cupertino_http: ^3.1.0 + objective_c: ^9.5.0 crypto: ^3.0.7 flutter_secure_storage: ^10.0.0 flutter_svg: ^2.2.4 diff --git a/mobile/test/features/agents/agent_instructions_test.dart b/mobile/test/features/agents/agent_instructions_test.dart index f7fea7075f..4a6a3df683 100644 --- a/mobile/test/features/agents/agent_instructions_test.dart +++ b/mobile/test/features/agents/agent_instructions_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:buzz/shared/relay/relay_author_provider.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -91,6 +92,34 @@ void main() { } }); + test('a refused relay information response is aborted', () async { + Future? aborted; + final container = ProviderContainer( + overrides: [ + relayInformationClientProvider.overrideWithValue( + MockClient.streaming((request, _) async { + aborted = (request as http.Abortable).abortTrigger; + return http.StreamedResponse( + StreamController>().stream, + 503, + ); + }), + ), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen( + relayAuthorProvider('wss://relay.example'), + (_, _) {}, + ); + addTearDown(subscription.close); + expect( + await container.read(relayAuthorProvider('wss://relay.example').future), + isNull, + ); + await aborted!.timeout(const Duration(seconds: 1)); + }); + test( 'instruction sources preserve precedence and do not revive unavailable config', () async { diff --git a/mobile/test/shared/community/community_icon_provider_test.dart b/mobile/test/shared/community/community_icon_provider_test.dart index aecc3d45d7..2c46a6f52b 100644 --- a/mobile/test/shared/community/community_icon_provider_test.dart +++ b/mobile/test/shared/community/community_icon_provider_test.dart @@ -31,7 +31,9 @@ void main() { final container = ProviderContainer( overrides: [ savedPrefsProvider.overrideWithValue(prefs), - communityIconHttpClientProvider.overrideWithValue(MockClient(handler)), + communityIconHttpClientProvider.overrideWith( + (ref, _) => MockClient(handler), + ), ], ); addTearDown(container.dispose); @@ -246,7 +248,7 @@ void main() { final container = ProviderContainer( overrides: [ savedPrefsProvider.overrideWithValue(prefs), - communityIconHttpClientProvider.overrideWithValue(client), + communityIconHttpClientProvider.overrideWith((ref, _) => client), ], ); addTearDown(container.dispose); @@ -485,7 +487,7 @@ void main() { final container = ProviderContainer( overrides: [ savedPrefsProvider.overrideWithValue(prefs), - communityIconHttpClientProvider.overrideWithValue(client), + communityIconHttpClientProvider.overrideWith((ref, _) => client), ], ); addTearDown(container.dispose); diff --git a/mobile/test/shared/http/app_http_client_test.dart b/mobile/test/shared/http/app_http_client_test.dart new file mode 100644 index 0000000000..a857852c44 --- /dev/null +++ b/mobile/test/shared/http/app_http_client_test.dart @@ -0,0 +1,252 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/http/app_http_client.dart'; +import 'package:buzz/shared/utils/bounded_fetch.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + test('off iOS, clients come from http.Client()', () { + final mock = MockClient((_) async => http.Response('', 200)); + for (final traffic in HttpTraffic.values) { + expect( + http.runWithClient(() => newAppHttpClient(traffic), () => mock), + same(mock), + ); + } + }); + + test('closing a shared-session handle leaves the session usable', () async { + var sessionClosed = false; + final session = _ClosingMock( + (_) async => http.Response('ok', 200), + onClose: () => sessionClosed = true, + ); + final first = SharedSessionClient(session); + final second = SharedSessionClient(session); + + first.close(); + + expect(sessionClosed, isFalse); + expect((await second.get(Uri.parse('https://relay.example/'))).body, 'ok'); + }); + + test('closing a handle cancels its own in-flight requests only', () async { + final session = _AbortRecorder(); + final first = SharedSessionClient(session); + final second = SharedSessionClient(session); + unawaited( + first + .get(Uri.parse('https://relay.example/a')) + .then((_) {}, onError: (_) {}), + ); + unawaited( + second + .get(Uri.parse('https://relay.example/b')) + .then((_) {}, onError: (_) {}), + ); + await pumpEventQueue(); + + first.close(); + await pumpEventQueue(); + + expect(session.aborted, [Uri.parse('https://relay.example/a')]); + await expectLater( + first.get(Uri.parse('https://relay.example/c')), + throwsA(isA()), + ); + }); + + test('finished requests hold no abort trigger until close', () async { + var fired = 0; + var sessionBodyCancelled = false; + final client = SharedSessionClient( + MockClient.streaming((request, _) async { + (request as http.Abortable).abortTrigger?.then((_) => fired++); + if (request.url.path == '/video') { + final body = StreamController>( + onCancel: () => sessionBodyCancelled = true, + ); + return http.StreamedResponse(body.stream, 500); + } + final status = request.url.path == '/missing' ? 404 : 200; + return http.StreamedResponse(Stream.value([1]), status); + }), + ); + for (var i = 0; i < 3; i++) { + await client.get(Uri.parse('https://relay.example/$i')); + } + // A non-200 body is never read, and boundedGet fires its own trigger. + final missing = await boundedGet( + client, + Uri.parse('https://relay.example/missing'), + maxBytes: 10, + ); + expect(missing.statusCode, 404); + await pumpEventQueue(); + // A body cancelled before its first event still cancels the session's + // download. + final video = await client.send( + http.Request('GET', Uri.parse('https://relay.example/video')), + ); + await video.stream.listen(null).cancel(); + expect(sessionBodyCancelled, isTrue); + final ownAborts = fired; + + client.close(); + await pumpEventQueue(); + + expect(fired, ownAborts); + }); + + test('a request\'s own abort trigger still cancels it', () async { + final session = _AbortRecorder(); + final client = SharedSessionClient(session); + final abort = Completer(); + unawaited( + client + .send( + http.AbortableRequest( + 'GET', + Uri.parse('https://relay.example/a'), + abortTrigger: abort.future, + ), + ) + .then((_) {}, onError: (_) {}), + ); + await pumpEventQueue(); + + abort.complete(); + await pumpEventQueue(); + + expect(session.aborted, [Uri.parse('https://relay.example/a')]); + }); + + test('requests reach the session with their headers and body', () async { + final received = + <({String method, Map headers, String body})>[]; + final client = SharedSessionClient( + MockClient.streaming((request, body) async { + received.add(( + method: request.method, + headers: request.headers, + body: utf8.decode(await body.toBytes()), + )); + return http.StreamedResponse(const Stream.empty(), 200); + }), + ); + + await client.post( + Uri.parse('https://relay.example/query'), + headers: {'authorization': 'Nostr x'}, + body: '[]', + ); + final upload = http.MultipartRequest( + 'PUT', + Uri.parse('https://relay.example/upload'), + )..fields['name'] = 'value'; + await client.send(upload); + + expect(received[0].method, 'POST'); + expect(received[0].headers['authorization'], 'Nostr x'); + expect(received[0].body, '[]'); + expect(received[1].method, 'PUT'); + expect( + received[1].headers['content-type'], + startsWith('multipart/form-data'), + ); + expect(received[1].body, contains('value')); + }); + + test( + 'a streamed body is read only as fast as the session reads it', + () async { + var produced = 0; + Stream> chunks() async* { + for (var i = 0; i < 100; i++) { + produced++; + yield [i]; + } + } + + final request = http.StreamedRequest( + 'PUT', + Uri.parse('https://relay.example/upload'), + )..contentLength = 100; + unawaited( + request.sink.addStream(chunks()).whenComplete(request.sink.close), + ); + final client = SharedSessionClient(_AbortRecorder()); + unawaited(client.send(request).then((_) {}, onError: (_) {})); + await pumpEventQueue(); + + // Upload progress counts bytes as they are produced, so a copy that + // drained the body ahead of the transport would report it sent. + expect(produced, lessThan(10)); + client.close(); + }, + ); + + test('cleartext requests use the handle\'s own client', () async { + final sessionUrls = []; + final cleartextUrls = []; + var cleartextClosed = false; + final client = SharedSessionClient( + MockClient((request) async { + sessionUrls.add(request.url); + return http.Response('', 200); + }), + cleartext: () => _ClosingMock((request) async { + cleartextUrls.add(request.url); + return http.Response('', 200); + }, onClose: () => cleartextClosed = true), + ); + + await client.get(Uri.parse('https://relay.example/a')); + await client.get(Uri.parse('http://icons.example/b')); + client.close(); + + expect(sessionUrls, [Uri.parse('https://relay.example/a')]); + expect(cleartextUrls, [Uri.parse('http://icons.example/b')]); + expect(cleartextClosed, isTrue); + }); + + test('URLSession error codes map to user-facing failure kinds', () { + expect(urlErrorFailure(-1009), TransportFailure.unreachable); + expect(urlErrorFailure(-1004), TransportFailure.unreachable); + expect(urlErrorFailure(-1001), TransportFailure.timedOut); + expect(urlErrorFailure(-1200), TransportFailure.insecure); + expect(urlErrorFailure(-1202), TransportFailure.insecure); + expect(urlErrorFailure(-999), isNull); + expect(urlSessionFailure(http.ClientException('offline')), isNull); + }); +} + +class _ClosingMock extends MockClient { + _ClosingMock(super.fn, {required this.onClose}); + + final void Function() onClose; + + @override + void close() { + onClose(); + super.close(); + } +} + +/// Never responds; records which requests were aborted. +class _AbortRecorder extends http.BaseClient { + final aborted = []; + + @override + Future send(http.BaseRequest request) { + final response = Completer(); + (request as http.Abortable).abortTrigger?.then((_) { + aborted.add(request.url); + response.completeError(http.RequestAbortedException(request.url)); + }); + return response.future; + } +} diff --git a/mobile/test/shared/identity_archives/identity_archives_test.dart b/mobile/test/shared/identity_archives/identity_archives_test.dart index 230a4c6189..effd4d1e15 100644 --- a/mobile/test/shared/identity_archives/identity_archives_test.dart +++ b/mobile/test/shared/identity_archives/identity_archives_test.dart @@ -170,6 +170,31 @@ void main() { expect(relay.author, isNull); }); + test('a refused metadata response is aborted, not left unread', () async { + final owner = nostr.Keys.generate(); + final relay = _ArchiveRelay(null); + Future? aborted; + final client = MockClient.streaming((request, _) async { + aborted = (request as http.Abortable).abortTrigger; + return http.StreamedResponse(StreamController>().stream, 503); + }); + final container = ProviderContainer( + overrides: [ + meSyncProvider.overrideWithValue( + MeSync( + relay, + RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec), + ), + ), + relayInformationClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + addTearDown(client.close); + expect(await container.read(identityArchivesProvider.future), isEmpty); + await aborted!.timeout(const Duration(seconds: 1)); + }); + test( 'contact keys and unavailable metadata are not archive authority', () async { diff --git a/mobile/test/shared/relay/relay_http_query_client_test.dart b/mobile/test/shared/relay/relay_http_query_client_test.dart index c3fa2838c1..f57cc6a2be 100644 --- a/mobile/test/shared/relay/relay_http_query_client_test.dart +++ b/mobile/test/shared/relay/relay_http_query_client_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -54,4 +55,77 @@ void main() { '[]', ); }); + + test('a timed-out request is aborted, not left running', () async { + final aborted = Completer(); + final client = RelayHttpQueryClient( + client: _HangingClient(onAbort: aborted.complete), + ); + + await expectLater( + client.post( + Uri.parse('https://relay.example/query'), + headers: const {}, + body: const [], + timeout: const Duration(milliseconds: 10), + ), + throwsA(isA()), + ); + await aborted.future.timeout(const Duration(seconds: 5)); + }); + + test('a timed-out bounded request is aborted, not left running', () async { + final aborted = Completer(); + final client = RelayHttpQueryClient( + client: _HangingClient(onAbort: aborted.complete), + ); + + await expectLater( + client.sendBounded( + http.Request('GET', Uri.parse('https://relay.example/')), + maxBytes: 1024, + timeout: const Duration(milliseconds: 10), + ), + throwsA(isA()), + ); + await aborted.future.timeout(const Duration(seconds: 5)); + }); + + test('an oversized bounded response is aborted before it is read', () async { + Future? aborted; + final client = RelayHttpQueryClient( + client: MockClient.streaming((request, _) async { + aborted = (request as http.Abortable).abortTrigger; + return http.StreamedResponse( + StreamController>().stream, + 200, + contentLength: 4096, + ); + }), + ); + + await expectLater( + client.sendBounded( + http.Request('GET', Uri.parse('https://relay.example/')), + maxBytes: 1024, + timeout: const Duration(seconds: 5), + ), + throwsA(isA()), + ); + await aborted!.timeout(const Duration(seconds: 1)); + }); +} + +/// Never responds; reports when the request's abort trigger fires. +class _HangingClient extends http.BaseClient { + _HangingClient({required this.onAbort}); + + final void Function() onAbort; + + @override + Future send(http.BaseRequest request) { + final trigger = (request as http.Abortable).abortTrigger; + trigger?.then((_) => onAbort()); + return Completer().future; + } }