diff --git a/lib/networking/tor_aware_network_image.dart b/lib/networking/tor_aware_network_image.dart new file mode 100644 index 0000000000..ef4cb03eaa --- /dev/null +++ b/lib/networking/tor_aware_network_image.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../app_config.dart'; +import '../services/tor_service.dart'; +import '../utilities/prefs.dart'; +import 'http.dart'; + +/// Fetches [url] through the app's [HTTP] client, routing over Tor when it is +/// enabled. Throws if Tor is enabled but not connected, so nothing falls back +/// to clearnet. +Future fetchTorAwareBytes(String url) async { + final response = await const HTTP().get( + url: Uri.parse(url), + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + + if (response.code != 200) { + throw Exception("Failed to load $url: status=${response.code}"); + } + + return Uint8List.fromList(response.bodyBytes); +} + +/// Drop-in replacement for [NetworkImage] that respects the Tor preference. +/// Use with [Image] instead of [Image.network], which always goes clearnet. +@immutable +class TorAwareNetworkImage extends ImageProvider { + const TorAwareNetworkImage(this.url); + + final String url; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + TorAwareNetworkImage key, + ImageDecoderCallback decode, + ) { + final chunkEvents = StreamController(); + return MultiFrameImageStreamCompleter( + codec: _load(key, decode, chunkEvents), + chunkEvents: chunkEvents.stream, + scale: 1.0, + debugLabel: key.url, + ); + } + + Future _load( + TorAwareNetworkImage key, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async { + try { + // Lets loadingBuilder show its indicator while the request runs. + chunkEvents.add( + const ImageChunkEvent( + cumulativeBytesLoaded: 0, + expectedTotalBytes: null, + ), + ); + final bytes = await fetchTorAwareBytes(key.url); + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + return await decode(buffer); + } catch (_) { + // Don't keep a failed load in the cache. + scheduleMicrotask(() { + PaintingBinding.instance.imageCache.evict(key); + }); + rethrow; + } finally { + unawaited(chunkEvents.close()); + } + } + + @override + bool operator ==(Object other) => + other is TorAwareNetworkImage && other.url == url; + + @override + int get hashCode => url.hashCode; + + @override + String toString() => + "${objectRuntimeType(this, "TorAwareNetworkImage")}" + "(\"$url\")"; +} + +/// Drop-in replacement for the loader behind [SvgPicture.network] that +/// respects the Tor preference. +/// Use as `SvgPicture(TorAwareSvgNetworkLoader(url))`. +@immutable +class TorAwareSvgNetworkLoader extends SvgLoader { + const TorAwareSvgNetworkLoader(this.url); + + final String url; + + @override + Future prepareMessage(BuildContext? context) => + fetchTorAwareBytes(url); + + @override + String provideSvg(Uint8List? message) => + utf8.decode(message!, allowMalformed: true); + + @override + bool operator ==(Object other) => + other is TorAwareSvgNetworkLoader && other.url == url; + + @override + int get hashCode => url.hashCode; +} diff --git a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart index 65088fbc94..a2b327b563 100644 --- a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart +++ b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart @@ -16,6 +16,7 @@ import 'package:isar_community/isar.dart'; import '../../../../models/isar/exchange_cache/currency.dart'; import '../../../../models/isar/models/contract.dart'; import '../../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../../networking/tor_aware_network_image.dart'; import '../../../../services/exchange/change_now/change_now_exchange.dart'; import '../../../../services/exchange/exchange_data_loading_service.dart'; import '../../../../themes/stack_colors.dart'; @@ -101,8 +102,8 @@ class _AddTokenListElementState extends ConsumerState { ) else currency != null - ? SvgPicture.network( - currency!.image, + ? SvgPicture( + TorAwareSvgNetworkLoader(currency!.image), width: iconSize, height: iconSize, placeholderBuilder: (_) => diff --git a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart index 95f3f4a790..2ac222b7c7 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart @@ -19,6 +19,7 @@ import '../../../../models/add_wallet_list_entity/add_wallet_list_entity.dart'; import '../../../../models/add_wallet_list_entity/sub_classes/eth_token_entity.dart'; import '../../../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; import '../../../../models/isar/exchange_cache/currency.dart'; +import '../../../../networking/tor_aware_network_image.dart'; import '../../../../providers/providers.dart'; import '../../../../services/exchange/change_now/change_now_exchange.dart'; import '../../../../services/exchange/exchange_data_loading_service.dart'; @@ -146,15 +147,15 @@ class _CoinSelectItemState extends ConsumerState { else tokenImageUri != null ? tokenImageUri!.toLowerCase().endsWith(".svg") - ? SvgPicture.network( - tokenImageUri!, + ? SvgPicture( + TorAwareSvgNetworkLoader(tokenImageUri!), width: 26, height: 26, placeholderBuilder: (_) => const AppIcon(width: 26, height: 26), ) - : Image.network( - tokenImageUri!, + : Image( + image: TorAwareNetworkImage(tokenImageUri!), width: 26, height: 26, errorBuilder: (_, _, _) => SvgPicture.file( diff --git a/lib/pages/cakepay/cakepay_card_detail_view.dart b/lib/pages/cakepay/cakepay_card_detail_view.dart index c53633ef21..eec53dc0db 100644 --- a/lib/pages/cakepay/cakepay_card_detail_view.dart +++ b/lib/pages/cakepay/cakepay_card_detail_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../providers/global/locale_provider.dart'; import '../../services/cakepay/cakepay_service.dart'; import '../../services/cakepay/src/models/card.dart'; @@ -340,8 +341,8 @@ class _CardImage extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.network( - imageUrl, + child: Image( + image: TorAwareNetworkImage(imageUrl), width: isDesktop ? 300 : null, fit: isDesktop ? .contain : .fitWidth, loadingBuilder: (_, child, event) { diff --git a/lib/pages/cakepay/cakepay_vendors_view.dart b/lib/pages/cakepay/cakepay_vendors_view.dart index 59b145825b..c3c0dbc13a 100644 --- a/lib/pages/cakepay/cakepay_vendors_view.dart +++ b/lib/pages/cakepay/cakepay_vendors_view.dart @@ -2,6 +2,7 @@ import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../services/cakepay/cakepay_service.dart'; import '../../services/cakepay/src/models/card.dart'; import '../../themes/stack_colors.dart'; @@ -87,9 +88,8 @@ class _CakePayVendorsViewState extends State { } Future _onCardTapped(CakePayCard card) async { - await Navigator.of( - context, - ).pushNamed(CakePayCardDetailView.routeName, arguments: card); + await Navigator.of(context) + .pushNamed(CakePayCardDetailView.routeName, arguments: card); } @override @@ -130,9 +130,9 @@ class _CakePayVendorsViewState extends State { condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () => Navigator.of(context).pop(), @@ -210,9 +210,9 @@ class _CakePayVendorsViewState extends State { child: Text( "No items", style: STextStyles.w500_14(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ), ), @@ -226,9 +226,9 @@ class _CakePayVendorsViewState extends State { Text( error.toString(), style: STextStyles.w500_14(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ), const SizedBox(height: 16), @@ -276,9 +276,9 @@ class _SearchField extends StatelessWidget { focusNode: focusNode, style: isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of(context) + .extension()! + .textFieldActiveText, ) : STextStyles.field(context), decoration: @@ -327,9 +327,8 @@ class _CountryDropdown extends StatelessWidget { ); final itemStyle = isDesktop - ? STextStyles.desktopTextExtraSmall( - context, - ).copyWith(color: colors.textFieldActiveText) + ? STextStyles.desktopTextExtraSmall(context) + .copyWith(color: colors.textFieldActiveText) : STextStyles.w500_14(context); return ClipRRect( @@ -341,9 +340,8 @@ class _CountryDropdown extends StatelessWidget { hint: Text( "All countries", style: isDesktop - ? STextStyles.desktopTextExtraSmall( - context, - ).copyWith(color: colors.textFieldDefaultSearchIconLeft) + ? STextStyles.desktopTextExtraSmall(context) + .copyWith(color: colors.textFieldDefaultSearchIconLeft) : STextStyles.fieldLabel(context), ), items: [ @@ -446,8 +444,8 @@ class _CardTile extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(4), child: card.cardImageUrl != null - ? Image.network( - card.cardImageUrl!, + ? Image( + image: TorAwareNetworkImage(card.cardImageUrl!), width: isDesktop ? 60 : 48, height: isDesktop ? 40 : 32, fit: BoxFit.cover, @@ -483,9 +481,8 @@ class _CardTile extends StatelessWidget { ].join(' '), style: isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12( - context, - ).copyWith(color: colors.textSubtitle1), + : STextStyles.itemSubtitle12(context) + .copyWith(color: colors.textSubtitle1), ), ], ), diff --git a/lib/pages/exchange_view/exchange_coin_selection/exchange_currency_selection_view.dart b/lib/pages/exchange_view/exchange_coin_selection/exchange_currency_selection_view.dart index 4a539ec9d4..b5ebfc9b34 100644 --- a/lib/pages/exchange_view/exchange_coin_selection/exchange_currency_selection_view.dart +++ b/lib/pages/exchange_view/exchange_coin_selection/exchange_currency_selection_view.dart @@ -19,6 +19,7 @@ import '../../../app_config.dart'; import '../../../models/exchange/aggregate_currency.dart'; import '../../../models/isar/exchange_cache/currency.dart'; import '../../../models/isar/exchange_cache/pair.dart'; +import '../../../networking/tor_aware_network_image.dart'; import '../../../services/exchange/exchange.dart'; import '../../../services/exchange/exchange_data_loading_service.dart'; import '../../../themes/stack_colors.dart'; @@ -73,19 +74,19 @@ class _ExchangeCurrencySelectionViewState showDialog( context: context, barrierDismissible: false, - builder: - (_) => WillPopScope( - onWillPop: () async => false, - child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.6), - child: const CustomLoadingOverlay( - message: "Loading currencies", - eventBus: null, - ), - ), + builder: (_) => WillPopScope( + onWillPop: () async => false, + child: Container( + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.6), + child: const CustomLoadingOverlay( + message: "Loading currencies", + eventBus: null, ), + ), + ), ), ); @@ -101,36 +102,33 @@ class _ExchangeCurrencySelectionViewState Future> _loadCurrencies() async { await ExchangeDataLoadingService.instance.initDB(); final isar = await ExchangeDataLoadingService.instance.isar; - final currencies = - await isar.currencies - .where() - .filter() - .isFiatEqualTo(false) - .and() - .group( - (q) => - widget.isFixedRate - ? q - .rateTypeEqualTo(SupportedRateType.both) - .or() - .rateTypeEqualTo(SupportedRateType.fixed) - : q - .rateTypeEqualTo(SupportedRateType.both) - .or() - .rateTypeEqualTo(SupportedRateType.estimated), - ) - .sortByIsStackCoin() - .thenByName() - .findAll(); + final currencies = await isar.currencies + .where() + .filter() + .isFiatEqualTo(false) + .and() + .group( + (q) => widget.isFixedRate + ? q + .rateTypeEqualTo(SupportedRateType.both) + .or() + .rateTypeEqualTo(SupportedRateType.fixed) + : q + .rateTypeEqualTo(SupportedRateType.both) + .or() + .rateTypeEqualTo(SupportedRateType.estimated), + ) + .sortByIsStackCoin() + .thenByName() + .findAll(); // If using Tor, filter exchanges which do not support Tor. if (Prefs.instance.useTor) { if (Exchange.exchangeNamesWithTorSupport.isNotEmpty) { currencies.removeWhere( - (element) => - !Exchange.exchangeNamesWithTorSupport.contains( - element.exchangeName, - ), + (element) => !Exchange.exchangeNamesWithTorSupport.contains( + element.exchangeName, + ), ); } } @@ -163,20 +161,18 @@ class _ExchangeCurrencySelectionViewState results.remove(widget.pairedCurrency); } - final walletCoins = - results - .where( - (currency) => - AppConfig.coins - .where( - (coin) => - coin.ticker.toLowerCase() == - currency.ticker.toLowerCase() && - currency.fuzzyNet == coin.ticker.toLowerCase(), - ) - .isNotEmpty, - ) - .toList(); + final walletCoins = results + .where( + (currency) => AppConfig.coins + .where( + (coin) => + coin.ticker.toLowerCase() == + currency.ticker.toLowerCase() && + currency.fuzzyNet == coin.ticker.toLowerCase(), + ) + .isNotEmpty, + ) + .toList(); final list = results.toList(); @@ -240,8 +236,9 @@ class _ExchangeCurrencySelectionViewState builder: (child) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -286,45 +283,45 @@ class _ExchangeCurrencySelectionViewState focusNode: _searchFocusNode, onChanged: (value) => setState(() => _searchString = value), style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + suffixIcon: _searchController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchController.text = ""; - _searchString = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchController.text = ""; + _searchString = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 20), @@ -358,23 +355,20 @@ class _ExchangeCurrencySelectionViewState height: 24, child: AppConfig.isStackCoin( - items[index].ticker, - ) - ? CoinIconForTicker( - ticker: items[index].ticker, - size: 24, - ) - : hasImageUrl - ? _NetImage( - url: image, - key: ValueKey( - image + items[index].fuzzyNet, - ), - ) - : const SizedBox( - width: 24, - height: 24, + items[index].ticker, + ) + ? CoinIconForTicker( + ticker: items[index].ticker, + size: 24, + ) + : hasImageUrl + ? _NetImage( + url: image, + key: ValueKey( + image + items[index].fuzzyNet, ), + ) + : const SizedBox(width: 24, height: 24), ), const SizedBox(width: 10), Expanded( @@ -399,9 +393,8 @@ class _ExchangeCurrencySelectionViewState left: 12, ), child: CoinTickerTag( - ticker: - items[index].fuzzyNet - .toUpperCase(), + ticker: items[index].fuzzyNet + .toUpperCase(), ), ), ], @@ -409,14 +402,12 @@ class _ExchangeCurrencySelectionViewState const SizedBox(height: 2), Text( items[index].ticker.toUpperCase(), - style: STextStyles.smallMed12( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.smallMed12(context) + .copyWith( + color: Theme.of(context) .extension()! .textSubtitle1, - ), + ), ), ], ), @@ -447,15 +438,20 @@ class _NetImage extends StatelessWidget { @override Widget build(BuildContext context) { if (url.endsWith(".svg")) { - return SvgPicture.network( + return SvgPicture( key: key, - url, + TorAwareSvgNetworkLoader(url), width: 24, height: 24, placeholderBuilder: (_) => const LoadingIndicator(), ); } else { - return Image.network(url, width: 24, height: 24, key: key); + return Image( + image: TorAwareNetworkImage(url), + width: 24, + height: 24, + key: key, + ); } } } diff --git a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart index 700088664c..95d2ee5265 100644 --- a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart +++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart @@ -15,6 +15,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app_config.dart'; import '../../../models/exchange/aggregate_currency.dart'; import '../../../models/exchange/response_objects/estimate.dart'; +import '../../../networking/tor_aware_network_image.dart'; import '../../../providers/exchange/exchange_form_state_provider.dart'; import '../../../providers/global/locale_provider.dart'; import '../../../services/exchange/exchange.dart'; @@ -164,9 +165,9 @@ class _ExchangeOptionState extends ConsumerState { isDesktop ? Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ) : const SizedBox(height: 16), child, @@ -215,9 +216,9 @@ class _ExchangeOptionState extends ConsumerState { estimate: null, pair: pair, rateString: message ?? "Failed to fetch rate", - rateColor: Theme.of( - context, - ).extension()!.textError, + rateColor: Theme.of(context) + .extension()! + .textError, ); }, ); @@ -354,9 +355,9 @@ class _ProviderOptionState extends ConsumerState { child: Padding( padding: EdgeInsets.only(top: isDesktop ? 20.0 : 15.0), child: Radio( - activeColor: Theme.of( - context, - ).extension()!.radioButtonIconEnabled, + activeColor: Theme.of(context) + .extension()! + .radioButtonIconEnabled, value: _id, groupValue: groupValue, onChanged: (_) { @@ -382,8 +383,10 @@ class _ProviderOptionState extends ConsumerState { widget.estimate!.exchangeProviderLogo!.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(5), - child: Image.network( - widget.estimate!.exchangeProviderLogo!, + child: Image( + image: TorAwareNetworkImage( + widget.estimate!.exchangeProviderLogo!, + ), loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) { @@ -427,9 +430,9 @@ class _ProviderOptionState extends ConsumerState { widget.estimate?.exchangeProvider ?? widget.exchange.name, style: STextStyles.titleBold12(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark2, + color: Theme.of(context) + .extension()! + .textDark2, ), ), ), @@ -443,9 +446,9 @@ class _ProviderOptionState extends ConsumerState { ], style: STextStyles.itemSubtitle12(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ) : Text( diff --git a/lib/pages/settings_views/global_settings_view/appearance_settings/sub_widgets/stack_theme_card.dart b/lib/pages/settings_views/global_settings_view/appearance_settings/sub_widgets/stack_theme_card.dart index b350fa527b..93e0f71da4 100644 --- a/lib/pages/settings_views/global_settings_view/appearance_settings/sub_widgets/stack_theme_card.dart +++ b/lib/pages/settings_views/global_settings_view/appearance_settings/sub_widgets/stack_theme_card.dart @@ -17,6 +17,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; import '../../../../../models/isar/stack_theme.dart'; +import '../../../../../networking/tor_aware_network_image.dart'; import '../../../../../notifications/show_flush_bar.dart'; import '../../../../../providers/db/main_db_provider.dart'; import '../../../../../providers/global/prefs_provider.dart'; @@ -69,18 +70,16 @@ class _StackThemeCardState extends ConsumerState { } Future _downloadPressed() async { - final result = - (await showLoading( - whileFuture: _downloadAndInstall(), - context: context, - message: "Downloading and installing theme...", - ))!; + final result = (await showLoading( + whileFuture: _downloadAndInstall(), + context: context, + message: "Downloading and installing theme...", + ))!; if (mounted) { - final message = - result - ? "${widget.data.name} theme installed!" - : "Failed to install ${widget.data.name} theme"; + final message = result + ? "${widget.data.name} theme installed!" + : "Failed to install ${widget.data.name} theme"; if (isDesktop) { await showFloatingFlushBar( type: result ? FlushBarType.success : FlushBarType.warning, @@ -90,16 +89,15 @@ class _StackThemeCardState extends ConsumerState { } else { await showDialog( context: context, - builder: - (_) => StackOkDialog( - title: message, - onOkPressed: (_) { - setState(() { - _needsUpdate = !result; - _hasTheme = result; - }); - }, - ), + builder: (_) => StackOkDialog( + title: message, + onOkPressed: (_) { + setState(() { + _needsUpdate = !result; + _hasTheme = result; + }); + }, + ), ); } } @@ -155,14 +153,13 @@ class _StackThemeCardState extends ConsumerState { } } - StackTheme? getInstalled() => - ref - .read(mainDBProvider) - .isar - .stackThemes - .where() - .themeIdEqualTo(widget.data.id) - .findFirstSync(); + StackTheme? getInstalled() => ref + .read(mainDBProvider) + .isar + .stackThemes + .where() + .themeIdEqualTo(widget.data.id) + .findFirstSync(); @override void initState() { @@ -207,39 +204,41 @@ class _StackThemeCardState extends ConsumerState { Widget build(BuildContext context) { return RoundedWhiteContainer( radiusMultiplier: isDesktop ? 2.5 : 1, - borderColor: - isDesktop - ? Theme.of(context).extension()!.textSubtitle6 - : null, + borderColor: isDesktop + ? Theme.of(context).extension()!.textSubtitle6 + : null, child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 18), - child: - widget.data.previewImageUrl.isNotEmpty - ? AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(100), - child: Image.network(widget.data.previewImageUrl), + child: widget.data.previewImageUrl.isNotEmpty + ? AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(100), + child: Image( + image: TorAwareNetworkImage( + widget.data.previewImageUrl, + ), ), - ) - : Builder( - builder: (context) { - final themePreview = - ref - .watch(pThemeService) - .getTheme(themeId: widget.data.id) - ?.assets - .themePreview ?? - ""; - - return (themePreview.endsWith(".png")) - ? Image.file(File(themePreview), height: 100) - : SvgPicture.file(File(themePreview), height: 100); - }, ), + ) + : Builder( + builder: (context) { + final themePreview = + ref + .watch(pThemeService) + .getTheme(themeId: widget.data.id) + ?.assets + .themePreview ?? + ""; + + return (themePreview.endsWith(".png")) + ? Image.file(File(themePreview), height: 100) + : SvgPicture.file(File(themePreview), height: 100); + }, + ), ), const SizedBox(height: 12), Text(widget.data.name, style: STextStyles.itemSubtitle12(context)), @@ -276,10 +275,9 @@ class _StackThemeCardState extends ConsumerState { const SizedBox(height: 12), AnimatedCrossFade( duration: const Duration(milliseconds: 300), - crossFadeState: - _hasTheme - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, + crossFadeState: _hasTheme + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, firstChild: PrimaryButton( label: "Download", buttonHeight: isDesktop ? ButtonHeight.s : ButtonHeight.l, diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 2a558ef5dc..5d99517f59 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -10,6 +10,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../db/drift/shared_db/shared_database.dart'; import '../../models/shopinbit/shopinbit_enums.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/db/drift_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; @@ -432,9 +433,8 @@ class _ShopInBitTicketDetailState extends ConsumerState ); } if (context.mounted) { - await Navigator.of( - context, - ).pushNamed(ShopInBitOfferView.routeName, arguments: _id); + await Navigator.of(context) + .pushNamed(ShopInBitOfferView.routeName, arguments: _id); } } @@ -443,9 +443,9 @@ class _ShopInBitTicketDetailState extends ConsumerState padding: .only(bottom: isDesktop ? 12 : 8), child: RoundedWhiteContainer( borderColor: isDesktop - ? Theme.of( - context, - ).extension()!.textFieldDefaultBG + ? Theme.of(context) + .extension()! + .textFieldDefaultBG : null, child: ConditionalParent( condition: Util.isDesktop, @@ -559,9 +559,9 @@ class _ShopInBitTicketDetailState extends ConsumerState ? STextStyles.desktopTextExtraSmall(context) : STextStyles.field(context)) .copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), decoration: InputDecoration( hintText: "Type a message...", @@ -585,9 +585,9 @@ class _ShopInBitTicketDetailState extends ConsumerState Assets.svg.send, width: 24, height: 24, - color: Theme.of( - context, - ).extension()!.accentColorBlue, + color: Theme.of(context) + .extension()! + .accentColorBlue, ), ), ], @@ -600,9 +600,9 @@ class _ShopInBitTicketDetailState extends ConsumerState padding: EdgeInsets.only(bottom: isDesktop ? 12 : 8), child: RoundedWhiteContainer( borderColor: isDesktop - ? Theme.of( - context, - ).extension()!.textFieldDefaultBG + ? Theme.of(context) + .extension()! + .textFieldDefaultBG : null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -698,9 +698,9 @@ class _ShopInBitTicketDetailState extends ConsumerState condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () => Navigator.of(context).pop(), @@ -892,10 +892,13 @@ class _ProxyImageState extends State<_ProxyImage> { if (resp.hasError || resp.value == null) { return widget.fallback(); } - return Image.network( - resp.value!.toString(), + return Image( + image: ResizeImage.resizeIfNeeded( + null, + _kAttachmentDecodeHeight, + TorAwareNetworkImage(resp.value!.toString()), + ), fit: BoxFit.contain, - cacheHeight: _kAttachmentDecodeHeight, semanticLabel: "Image attachment", errorBuilder: (_, _, _) => widget.fallback(), ); diff --git a/lib/utilities/test_eth_node_connection.dart b/lib/utilities/test_eth_node_connection.dart index be95a19ceb..e8c4473b99 100644 --- a/lib/utilities/test_eth_node_connection.dart +++ b/lib/utilities/test_eth_node_connection.dart @@ -1,12 +1,19 @@ -import 'package:http/http.dart'; -import 'package:web3dart/web3dart.dart' as web3; +import '../services/tor_service.dart'; +import '../wallets/wallet/impl/ethereum_wallet.dart'; +import 'prefs.dart'; -Future testEthNodeConnection(String host) async { - final web3.Web3Client client = web3.Web3Client(host, Client()); +Future testEthNodeConnection( + String host, { + required Prefs prefs, + required TorService torService, +}) async { + final client = EthereumWallet.createWeb3Client(host, prefs, torService); try { await client.getBlockNumber(); return true; } catch (_) { return false; + } finally { + await client.dispose(); } } diff --git a/lib/utilities/test_node_connection.dart b/lib/utilities/test_node_connection.dart index 0b3504ce21..80068fce59 100644 --- a/lib/utilities/test_node_connection.dart +++ b/lib/utilities/test_node_connection.dart @@ -235,7 +235,11 @@ Future testNodeConnection({ case Ethereum(): try { - testPassed = await testEthNodeConnection(formData.host!); + testPassed = await testEthNodeConnection( + formData.host!, + prefs: read(prefsChangeNotifierProvider), + torService: read(pTorService), + ); } catch (_) { testPassed = false; } diff --git a/lib/utilities/test_stellar_node_connection.dart b/lib/utilities/test_stellar_node_connection.dart index 3af380f633..b61f7a3c7c 100644 --- a/lib/utilities/test_stellar_node_connection.dart +++ b/lib/utilities/test_stellar_node_connection.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:socks5_proxy/socks.dart'; import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; import '../app_config.dart'; @@ -28,7 +30,15 @@ Future testStellarNodeConnection(String host, int port) async { if (response.code == 200) { //Get chain height for sdk - final StellarSDK stellarSdk = StellarSDK(host); + HttpClient? httpClient; + if (AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor) { + final proxyInfo = TorService.sharedInstance.getProxyInfo(); + httpClient = HttpClient(); + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxyInfo.host, proxyInfo.port), + ]); + } + final StellarSDK stellarSdk = StellarSDK(host, httpClient: httpClient); final height = await stellarSdk.ledgers .order(RequestBuilderOrder.DESC) .limit(1) diff --git a/lib/wallets/crypto_currency/coins/ethereum.dart b/lib/wallets/crypto_currency/coins/ethereum.dart index 448f4c9ff9..57b79a2be4 100644 --- a/lib/wallets/crypto_currency/coins/ethereum.dart +++ b/lib/wallets/crypto_currency/coins/ethereum.dart @@ -47,6 +47,9 @@ class Ethereum extends Bip39Currency { @override bool get hasTokenSupport => true; + @override + bool get torSupport => true; + @override NodeModel defaultNode({required bool isPrimary}) => NodeModel( host: "https://eth2.stackwallet.com", diff --git a/lib/wallets/crypto_currency/coins/fact0rn.dart b/lib/wallets/crypto_currency/coins/fact0rn.dart index 1ef113ac04..b6687c8ac8 100644 --- a/lib/wallets/crypto_currency/coins/fact0rn.dart +++ b/lib/wallets/crypto_currency/coins/fact0rn.dart @@ -48,7 +48,7 @@ class Fact0rn extends Bip39HDCurrency with ElectrumXCurrencyInterface { String get ticker => _ticker; @override - bool get torSupport => false; + bool get torSupport => true; @override List get supportedDerivationPathTypes => [ @@ -184,7 +184,7 @@ class Fact0rn extends Bip39HDCurrency with ElectrumXCurrencyInterface { coinName: identifier, isFailover: true, isDown: false, - torEnabled: false, + torEnabled: true, clearnetEnabled: true, isPrimary: isPrimary, ); diff --git a/lib/wallets/crypto_currency/intermediate/nano_currency.dart b/lib/wallets/crypto_currency/intermediate/nano_currency.dart index a553a6d6c9..573e91d7c0 100644 --- a/lib/wallets/crypto_currency/intermediate/nano_currency.dart +++ b/lib/wallets/crypto_currency/intermediate/nano_currency.dart @@ -18,6 +18,9 @@ abstract class NanoCurrency extends Bip39Currency { @override bool get hasBuySupport => false; + @override + bool get torSupport => true; + @override int get defaultSeedPhraseLength => 24; diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index f5444c7428..e2b0674509 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -1,16 +1,20 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:decimal/decimal.dart'; import 'package:ethereum_addresses/ethereum_addresses.dart'; import 'package:flutter/foundation.dart'; -import 'package:http/http.dart'; +import 'package:http/io_client.dart'; import 'package:isar_community/isar.dart'; +import 'package:socks5_proxy/socks.dart'; import 'package:wallet/wallet.dart' as eth_wallet; import 'package:web3dart/json_rpc.dart' show RPCError; import 'package:web3dart/web3dart.dart' as web3; +import '../../../app_config.dart'; import '../../../dto/ethereum/eth_tx_dto.dart'; +import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart'; import '../../../models/balance.dart'; import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/isar/models/blockchain_data/transaction.dart'; @@ -21,9 +25,12 @@ import '../../../models/paymint/fee_object_model.dart'; import '../../../services/ethereum/ethereum_api.dart'; import '../../../services/event_bus/events/global/updated_in_background_event.dart'; import '../../../services/event_bus/global_event_bus.dart'; +import '../../../services/tor_service.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/eth_commons.dart'; import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../../utilities/tor_plain_net_option_enum.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../../models/tx_data.dart'; import '../intermediate/bip39_wallet.dart'; @@ -85,8 +92,6 @@ Future> findReplacedPendingEthereumTransactions({ return replacedTransactions; } -// Eth can not use tor with web3dart - @visibleForTesting ({BigInt maxFeePerGas, BigInt maxPriorityFeePerGas}) resolveEip1559FeeCaps({ required BigInt baseFee, @@ -144,10 +149,45 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { web3.Web3Client getEthClient() { final node = getCurrentNode(); - // Eth can not use tor with web3dart as Client does not support proxies - final client = Client(); + final netOption = TorPlainNetworkOption.fromNodeData( + node.torEnabled, + node.clearnetEnabled, + ); - return web3.Web3Client(node.host, client); + if (prefs.useTor) { + if (netOption == TorPlainNetworkOption.clear) { + throw NodeTorMismatchConfigException( + message: "TOR enabled but node set to clearnet only", + ); + } + } else { + if (netOption == TorPlainNetworkOption.tor) { + throw NodeTorMismatchConfigException( + message: "TOR off but node set to TOR only", + ); + } + } + + return createWeb3Client(node.host, prefs, TorService.sharedInstance); + } + + // static helper function for building a web3 client, proxied over tor if + // enabled + static web3.Web3Client createWeb3Client( + String url, + Prefs prefs, + TorService torService, + ) { + final httpClient = HttpClient(); + + if (AppConfig.hasFeature(AppFeature.tor) && prefs.useTor) { + final proxyInfo = torService.getProxyInfo(); + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxyInfo.host, proxyInfo.port), + ]); + } + + return web3.Web3Client(url, IOClient(httpClient)); } Amount estimateEthFee(BigInt feeRate, int gasLimit, int decimals) { @@ -292,8 +332,8 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { @override Future pingCheck() async { - final web3.Web3Client client = getEthClient(); try { + final client = getEthClient(); await client.getBlockNumber(); return true; } catch (_) { diff --git a/lib/widgets/icon_widgets/eth_token_icon.dart b/lib/widgets/icon_widgets/eth_token_icon.dart index eb88200916..4162ed6e23 100644 --- a/lib/widgets/icon_widgets/eth_token_icon.dart +++ b/lib/widgets/icon_widgets/eth_token_icon.dart @@ -16,6 +16,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; import '../../models/isar/exchange_cache/currency.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../services/exchange/change_now/change_now_exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../themes/coin_icon_provider.dart'; @@ -87,8 +88,8 @@ class _EthTokenIconState extends ConsumerState { height: widget.size, ); } else { - return SvgPicture.network( - imageUrl!, + return SvgPicture( + TorAwareSvgNetworkLoader(imageUrl!), width: widget.size, height: widget.size, placeholderBuilder: (_) => const LoadingIndicator(), diff --git a/lib/widgets/icon_widgets/sol_token_icon.dart b/lib/widgets/icon_widgets/sol_token_icon.dart index 8907651c3b..24b8e71333 100644 --- a/lib/widgets/icon_widgets/sol_token_icon.dart +++ b/lib/widgets/icon_widgets/sol_token_icon.dart @@ -15,6 +15,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; import '../../models/isar/exchange_cache/currency.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../services/exchange/change_now/change_now_exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../themes/coin_icon_provider.dart'; @@ -84,8 +85,8 @@ class _SolTokenIconState extends ConsumerState { ); } else { // Display token icon from network. - return SvgPicture.network( - imageUrl!, + return SvgPicture( + TorAwareSvgNetworkLoader(imageUrl!), width: widget.size, height: widget.size, placeholderBuilder: (_) => const LoadingIndicator(), diff --git a/lib/widgets/textfields/exchange_textfield.dart b/lib/widgets/textfields/exchange_textfield.dart index 3229b28d5c..b9f3530386 100644 --- a/lib/widgets/textfields/exchange_textfield.dart +++ b/lib/widgets/textfields/exchange_textfield.dart @@ -14,6 +14,7 @@ import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; import '../../models/exchange/aggregate_currency.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../pages/buy_view/sub_widgets/crypto_selection_view.dart'; import '../../providers/global/locale_provider.dart'; import '../../themes/stack_colors.dart'; @@ -181,8 +182,10 @@ class _ExchangeTextFieldState extends ConsumerState { } else if (widget.currency != null && widget.currency!.image.isNotEmpty) { return Center( - child: SvgPicture.network( - widget.currency!.image, + child: SvgPicture( + TorAwareSvgNetworkLoader( + widget.currency!.image, + ), height: 18, placeholderBuilder: (_) => Container( width: 18, diff --git a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart index e5450c39ad..e341a23ea1 100644 --- a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart +++ b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart @@ -16,6 +16,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; import '../../../models/isar/exchange_cache/currency.dart'; +import '../../../networking/tor_aware_network_image.dart'; import '../../../services/exchange/change_now/change_now_exchange.dart'; import '../../../services/exchange/exchange_data_loading_service.dart'; import '../../../themes/coin_icon_provider.dart'; @@ -48,18 +49,14 @@ class _WalletInfoCoinIconState extends ConsumerState { ExchangeDataLoadingService.instance.isar.then((isar) async { if (widget.contractAddress != null) { - final currency = - await isar.currencies - .where() - .exchangeNameEqualTo(ChangeNowExchange.exchangeName) - .filter() - .tokenContractEqualTo( - widget.contractAddress!, - caseSensitive: false, - ) - .and() - .imageIsNotEmpty() - .findFirst(); + final currency = await isar.currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .filter() + .tokenContractEqualTo(widget.contractAddress!, caseSensitive: false) + .and() + .imageIsNotEmpty() + .findFirst(); if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -87,14 +84,17 @@ class _WalletInfoCoinIconState extends ConsumerState { ), child: Padding( padding: EdgeInsets.all(widget.size / 5), - child: - imageUrl != null && imageUrl!.isNotEmpty - ? SvgPicture.network(imageUrl!, width: 20, height: 20) - : SvgPicture.file( - File(ref.watch(coinIconProvider(widget.coin))), - width: 20, - height: 20, - ), + child: imageUrl != null && imageUrl!.isNotEmpty + ? SvgPicture( + TorAwareSvgNetworkLoader(imageUrl!), + width: 20, + height: 20, + ) + : SvgPicture.file( + File(ref.watch(coinIconProvider(widget.coin))), + width: 20, + height: 20, + ), ), ); } diff --git a/test/wallets/ethereum_tor_proxy_test.dart b/test/wallets/ethereum_tor_proxy_test.dart new file mode 100644 index 0000000000..a532408c8b --- /dev/null +++ b/test/wallets/ethereum_tor_proxy_test.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart'; + +class _MockPrefs extends Mock implements Prefs { + _MockPrefs(this._useTor); + + final bool _useTor; + + @override + bool get useTor => _useTor; +} + +class _FakeTorService extends Fake implements TorService { + _FakeTorService(this.proxyInfo); + + final ({InternetAddress host, int port}) proxyInfo; + int proxyInfoReads = 0; + + @override + ({InternetAddress host, int port}) getProxyInfo() { + proxyInfoReads++; + return proxyInfo; + } +} + +const _rpcBody = '{"jsonrpc":"2.0","id":0,"result":"0x2a"}'; + +String _rpcHttpResponse() => + 'HTTP/1.1 200 OK\r\n' + 'Content-Type: application/json\r\n' + 'Content-Length: ${_rpcBody.length}\r\n' + 'Connection: close\r\n\r\n' + '$_rpcBody'; + +// Minimal SOCKS5 server that records the CONNECT target and answers the +// tunnelled HTTP request with a canned JSON-RPC eth_blockNumber result. +class _FakeSocksServer { + late final ServerSocket _server; + int? addressType; + String? target; + int? port; + final request = StringBuffer(); + + int get listeningPort => _server.port; + + Future start() async { + _server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + _server.listen((socket) { + var stage = 0; + socket.listen((Uint8List bytes) { + switch (stage) { + case 0: + socket.add([0x05, 0x00]); + stage = 1; + case 1: + addressType = bytes[3]; + if (addressType == 0x03) { + target = String.fromCharCodes(bytes.sublist(5, 5 + bytes[4])); + } else { + target = bytes.sublist(4, bytes.length - 2).join('.'); + } + port = (bytes[bytes.length - 2] << 8) | bytes[bytes.length - 1]; + socket.add([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + stage = 2; + case 2: + request.write(utf8.decode(bytes, allowMalformed: true)); + socket.write(_rpcHttpResponse()); + socket.close(); + stage = 3; + default: + request.write(utf8.decode(bytes, allowMalformed: true)); + } + }); + }); + } + + Future stop() => _server.close(); +} + +void main() { + late _FakeSocksServer socks; + + setUp(() async { + socks = _FakeSocksServer(); + await socks.start(); + }); + + tearDown(() => socks.stop()); + + test('web3 client sends RPC through the Tor SOCKS5 proxy', () async { + final torService = _FakeTorService(( + host: InternetAddress.loopbackIPv4, + port: socks.listeningPort, + )); + final client = EthereumWallet.createWeb3Client( + 'http://eth.example.invalid:8545', + _MockPrefs(true), + torService, + ); + + final height = await client.getBlockNumber(); + await client.dispose(); + + expect(height, 42); + expect(torService.proxyInfoReads, 1); + // Hostname goes to the proxy, so there is no local DNS lookup. + expect(socks.addressType, 0x03); + expect(socks.target, 'eth.example.invalid'); + expect(socks.port, 8545); + expect(socks.request.toString(), contains('eth_blockNumber')); + }); + + test('web3 client connects directly when Tor is off', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((req) async { + await utf8.decoder.bind(req).join(); + req.response + ..headers.contentType = ContentType.json + ..write(_rpcBody); + await req.response.close(); + }); + + final torService = _FakeTorService(( + host: InternetAddress.loopbackIPv4, + port: socks.listeningPort, + )); + final client = EthereumWallet.createWeb3Client( + 'http://127.0.0.1:${server.port}', + _MockPrefs(false), + torService, + ); + + final height = await client.getBlockNumber(); + await client.dispose(); + await server.close(force: true); + + expect(height, 42); + expect(torService.proxyInfoReads, 0); + expect(socks.target, isNull); + }); +}