From 71609c34b0d9c106444e1ec8627903432fd3ca95 Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 10 Dec 2024 14:51:19 -0600 Subject: [PATCH 01/12] feat: allow setting the restore/refresh height in xmr/wow wallets --- .../edit_refresh_height_view.dart | 238 ++++++++++++++++++ .../wallet_settings_wallet_settings_view.dart | 38 +++ .../sub_widgets/wallet_options_button.dart | 70 +++++- lib/route_generator.dart | 15 ++ 4 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart new file mode 100644 index 000000000..61d497452 --- /dev/null +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart @@ -0,0 +1,238 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../notifications/show_flush_bar.dart'; +import '../../../../providers/db/main_db_provider.dart'; +import '../../../../providers/global/wallets_provider.dart'; +import '../../../../themes/stack_colors.dart'; +import '../../../../utilities/constants.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../utilities/util.dart'; +import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; +import '../../../../widgets/background.dart'; +import '../../../../widgets/conditional_parent.dart'; +import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../../../widgets/desktop/desktop_dialog.dart'; +import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/icon_widgets/x_icon.dart'; +import '../../../../widgets/stack_text_field.dart'; +import '../../../../widgets/textfield_icon_button.dart'; + +class EditRefreshHeightView extends ConsumerStatefulWidget { + const EditRefreshHeightView({ + super.key, + required this.walletId, + }); + + static const String routeName = "/editRefreshHeightView"; + + final String walletId; + + @override + ConsumerState createState() => + _EditRefreshHeightViewState(); +} + +class _EditRefreshHeightViewState extends ConsumerState { + late final LibMoneroWallet _wallet; + late final TextEditingController _controller; + final _focusNode = FocusNode(); + + bool _saveLock = false; + + void _save() async { + if (_saveLock) return; + _saveLock = true; + try { + String? errMessage; + try { + final newHeight = int.tryParse(_controller.text); + if (newHeight != null && newHeight >= 0) { + await _wallet.info.updateRestoreHeight( + newRestoreHeight: newHeight, + isar: ref.read(mainDBProvider).isar, + ); + _wallet.libMoneroWallet!.setRefreshFromBlockHeight(newHeight); + } else { + errMessage = "Invalid height: ${_controller.text}"; + } + } catch (e) { + errMessage = e.toString(); + } + + if (mounted) { + if (errMessage == null) { + Navigator.of(context).pop(); + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Refresh height updated", + context: context, + ), + ); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: errMessage, + context: context, + ), + ); + } + } + } finally { + _saveLock = false; + } + } + + @override + void initState() { + super.initState(); + _wallet = ref.read(pWallets).getWallet(widget.walletId) as LibMoneroWallet; + _controller = TextEditingController() + ..text = _wallet.libMoneroWallet!.getRefreshFromBlockHeight().toString(); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) { + return DesktopDialog( + maxWidth: 500, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + DesktopDialogCloseButton( + onPressedOverride: Navigator.of( + context, + rootNavigator: true, + ).pop, + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: child, + ), + const SizedBox( + height: 32, + ), + ], + ), + ); + }, + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) { + return Background( + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Restore height", + style: STextStyles.navBarTitle(context), + ), + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: child, + ), + ), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + key: const Key("restoreHeightFieldKey"), + controller: _controller, + focusNode: _focusNode, + style: Util.isDesktop + ? STextStyles.desktopTextMedium(context).copyWith( + height: 2, + ) + : STextStyles.field(context), + enableSuggestions: false, + autocorrect: false, + autofocus: true, + onSubmitted: (_) => _save(), + onChanged: (_) => setState(() {}), + decoration: standardInputDecoration( + "Restore height", + _focusNode, + context, + ).copyWith( + suffixIcon: _controller.text.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => SizedBox( + height: 70, + child: child, + ), + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _controller.text = ""; + }); + }, + ), + ], + ), + ), + ), + ) + : Util.isDesktop + ? const SizedBox( + height: 70, + ) + : null, + ), + ), + ), + Util.isDesktop + ? const SizedBox( + height: 32, + ) + : const Spacer(), + PrimaryButton( + label: "Save", + onPressed: _save, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart index bf41d6261..c689baf30 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart @@ -20,6 +20,7 @@ import '../../../../utilities/constants.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../wallets/isar/models/wallet_info.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/lelantus_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart'; @@ -32,6 +33,7 @@ import '../../../../widgets/rounded_white_container.dart'; import '../../../../widgets/stack_dialog.dart'; import '../../../pinpad_views/lock_screen_view.dart'; import 'delete_wallet_warning_view.dart'; +import 'edit_refresh_height_view.dart'; import 'lelantus_settings_view.dart'; import 'rbf_settings_view.dart'; import 'rename_wallet_view.dart'; @@ -354,6 +356,42 @@ class _WalletSettingsWalletSettingsViewState ), ), ), + if (wallet is LibMoneroWallet) + const SizedBox( + height: 8, + ), + if (wallet is LibMoneroWallet) + RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: RawMaterialButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () { + Navigator.of(context).pushNamed( + EditRefreshHeightView.routeName, + arguments: widget.walletId, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 20, + ), + child: Row( + children: [ + Text( + "Restore height", + style: STextStyles.titleBold12(context), + ), + ], + ), + ), + ), + ), const SizedBox( height: 8, ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart index e0da6d5e9..f0756370f 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart @@ -17,6 +17,7 @@ import 'package:flutter_svg/svg.dart'; import '../../../../pages/settings_views/wallet_settings_view/frost_ms/frost_ms_options_view.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/change_representative_view.dart'; +import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart'; import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart'; import '../../../../providers/global/wallets_provider.dart'; import '../../../../route_generator.dart'; @@ -30,6 +31,7 @@ import '../../../../wallets/crypto_currency/coins/firo.dart'; import '../../../../wallets/crypto_currency/intermediate/frost_currency.dart'; import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart'; import '../../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../../wallets/wallet/intermediate/lib_monero_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../../addresses/desktop_wallet_addresses_view.dart'; @@ -44,7 +46,8 @@ enum _WalletOptions { showXpub, lelantusCoins, sparkCoins, - frostOptions; + frostOptions, + refreshFromHeight; String get prettyName { switch (this) { @@ -62,6 +65,8 @@ enum _WalletOptions { return "Spark Coins"; case _WalletOptions.frostOptions: return "FROST settings"; + case _WalletOptions.refreshFromHeight: + return "Refresh height"; } } } @@ -111,6 +116,9 @@ class WalletOptionsButton extends ConsumerWidget { onFrostMSWalletOptionsPressed: () async { Navigator.of(context).pop(_WalletOptions.frostOptions); }, + onRefreshHeightPressed: () async { + Navigator.of(context).pop(_WalletOptions.refreshFromHeight); + }, walletId: walletId, ); }, @@ -243,6 +251,26 @@ class WalletOptionsButton extends ConsumerWidget { ), ); break; + + case _WalletOptions.refreshFromHeight: + if (Util.isDesktop) { + unawaited( + showDialog( + context: context, + builder: (context) => EditRefreshHeightView( + walletId: walletId, + ), + ), + ); + } else { + unawaited( + Navigator.of(context).pushNamed( + EditRefreshHeightView.routeName, + arguments: walletId, + ), + ); + } + break; } } }, @@ -278,6 +306,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget { required this.onFiroShowLelantusCoins, required this.onFiroShowSparkCoins, required this.onFrostMSWalletOptionsPressed, + required this.onRefreshHeightPressed, required this.walletId, }); @@ -288,6 +317,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget { final VoidCallback onFiroShowLelantusCoins; final VoidCallback onFiroShowSparkCoins; final VoidCallback onFrostMSWalletOptionsPressed; + final VoidCallback onRefreshHeightPressed; final String walletId; @override @@ -307,6 +337,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget { final bool canChangeRep = coin is NanoCurrency; final bool isFrost = coin is FrostCurrency; + final bool isMoneroWow = wallet is LibMoneroWallet; return Stack( children: [ @@ -509,6 +540,43 @@ class WalletOptionsPopupMenu extends ConsumerWidget { ), ), ), + if (isMoneroWow) + const SizedBox( + height: 8, + ), + if (isMoneroWow) + TransparentButton( + onPressed: onRefreshHeightPressed, + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SvgPicture.asset( + Assets.svg.addressBookDesktop, + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconLeft, + ), + const SizedBox(width: 14), + Expanded( + child: Text( + _WalletOptions.refreshFromHeight.prettyName, + style: STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ), + ), + ), + ], + ), + ), + ), if (xpubEnabled) const SizedBox( height: 8, diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 52454fb29..bcfffa8cd 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -135,6 +135,7 @@ import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_setting import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_view_only_wallet_keys_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_recovery_phrase_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/delete_wallet_warning_view.dart'; +import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/lelantus_settings_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/rbf_settings_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/rename_wallet_view.dart'; @@ -2140,6 +2141,20 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case EditRefreshHeightView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => EditRefreshHeightView( + walletId: args, + ), + settings: RouteSettings( + name: settings.name, + ), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + // == Desktop specific routes ============================================ case CreatePasswordView.routeName: if (args is bool) { From 2873595e40f34abe168ae940a954285f1c753868 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 11 Dec 2024 09:24:42 -0600 Subject: [PATCH 02/12] chore: use libsecret mirror due to instability of gitlab.gnome.org --- scripts/linux/build_secure_storage_deps.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/linux/build_secure_storage_deps.sh b/scripts/linux/build_secure_storage_deps.sh index 9956d286c..737508ab0 100755 --- a/scripts/linux/build_secure_storage_deps.sh +++ b/scripts/linux/build_secure_storage_deps.sh @@ -25,7 +25,7 @@ cd "$LINUX_DIRECTORY" || exit 1 #pip3 install --user meson markdown tomli --upgrade # pip3 install --user gi-docgen cd build || exit 1 -git -C libsecret pull origin $LIBSECRET_TAG || git clone https://gitlab.gnome.org/GNOME/libsecret.git libsecret +git -C libsecret pull origin $LIBSECRET_TAG || git clone https://git.cypherstack.com/Cypher_Stack/libsecret.git libsecret cd libsecret || exit 1 git checkout $LIBSECRET_TAG if ! [ -x "$(command -v meson)" ]; then From 4af7243265fcf2d116a207efd9a14b7cd1752c5c Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 11 Dec 2024 11:54:14 -0600 Subject: [PATCH 03/12] feat: xmrrpc (and wowrpc) uri parsing + tests --- lib/utilities/node_uri_util.dart | 132 +++++++++++++++++++++++++ test/utilities/node_uri_util_test.dart | 104 +++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 lib/utilities/node_uri_util.dart create mode 100644 test/utilities/node_uri_util_test.dart diff --git a/lib/utilities/node_uri_util.dart b/lib/utilities/node_uri_util.dart new file mode 100644 index 000000000..73876949e --- /dev/null +++ b/lib/utilities/node_uri_util.dart @@ -0,0 +1,132 @@ +abstract interface class NodeQrData { + final String host; + final int port; + final String? label; + + NodeQrData({required this.host, required this.port, this.label}); + + String encode(); + String get scheme; +} + +abstract class LibMoneroNodeQrData extends NodeQrData { + final String user; + final String password; + + LibMoneroNodeQrData({ + required super.host, + required super.port, + super.label, + required this.user, + required this.password, + }); + + @override + String encode() { + String? userInfo; + if (user.isNotEmpty) { + userInfo = user; + if (password.isNotEmpty) { + userInfo += ":$password"; + } + } + + final uri = Uri( + scheme: scheme, + userInfo: userInfo, + port: port, + host: host, + queryParameters: {"label": label}, + ); + + return uri.toString(); + } + + @override + String toString() { + return "$runtimeType {" + "scheme: $scheme, " + "host: $host, " + "port: $port, " + "user: $user, " + "password: $password, " + "label: $label" + "}"; + } +} + +class MoneroNodeQrData extends LibMoneroNodeQrData { + MoneroNodeQrData({ + required super.host, + required super.port, + required super.user, + required super.password, + super.label, + }); + + @override + String get scheme => "xmrrpc"; +} + +class WowneroNodeQrData extends LibMoneroNodeQrData { + WowneroNodeQrData({ + required super.host, + required super.port, + required super.user, + required super.password, + super.label, + }); + + @override + String get scheme => "wowrpc"; +} + +abstract final class NodeQrUtil { + static ({String? user, String? password}) _parseUserInfo(String? userInfo) { + if (userInfo == null || userInfo.isEmpty) { + return (user: null, password: null); + } + + final splitIndex = userInfo.indexOf(":"); + if (splitIndex == -1) { + return (user: userInfo, password: null); + } + + return ( + user: userInfo.substring(0, splitIndex), + password: userInfo.substring(splitIndex + 1), + ); + } + + static NodeQrData decodeUri(String uriString) { + final uri = Uri.tryParse(uriString); + if (uri == null) throw Exception("Invalid uri string."); + if (!uri.hasAuthority) throw Exception("Uri has no authority."); + + final userInfo = _parseUserInfo(uri.userInfo); + + final query = uri.queryParameters; + + switch (uri.scheme) { + case "xmrrpc": + return MoneroNodeQrData( + host: uri.host, + port: uri.port, + user: userInfo.user ?? "", + password: userInfo.password ?? "", + label: query["label"], + ); + case "wowrpc": + return WowneroNodeQrData( + host: uri.host, + port: uri.port, + user: userInfo.user ?? "", + password: userInfo.password ?? "", + label: query["label"], + ); + + default: + throw Exception("Unknown node uri scheme \"${uri.scheme}\" found."); + } + } +} diff --git a/test/utilities/node_uri_util_test.dart b/test/utilities/node_uri_util_test.dart new file mode 100644 index 000000000..42d8474a0 --- /dev/null +++ b/test/utilities/node_uri_util_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/utilities/node_uri_util.dart'; + +void main() { + test("Valid xmrrpc scheme node uri", () { + expect( + NodeQrUtil.decodeUri( + "xmrrpc://nodo:password@bob.onion:18083?label=Nodo Tor Node", + ), + isA(), + ); + }); + + test("Valid wowrpc scheme node uri", () { + expect( + NodeQrUtil.decodeUri( + "wowrpc://nodo:password@10.0.0.10:18083", + ), + isA(), + ); + }); + + test("Invalid authority node uri", () { + String? message; + try { + NodeQrUtil.decodeUri( + "nodo:password@bob.onion:18083?label=Nodo Tor Node", + ); + } catch (e) { + message = e.toString(); + } + expect(message, "Exception: Uri has no authority."); + }); + + test("Empty uri string", () { + String? message; + try { + NodeQrUtil.decodeUri(""); + } catch (e) { + message = e.toString(); + } + expect(message, "Exception: Uri has no authority."); + }); + + test("Invalid uri string", () { + String? message; + try { + NodeQrUtil.decodeUri("::invalid@@@.ok"); + } catch (e) { + message = e.toString(); + } + expect(message, "Exception: Invalid uri string."); + }); + + test("Unknown uri string", () { + String? message; + try { + NodeQrUtil.decodeUri("http://u:p@host.com:80/lol?hmm=42"); + } catch (e) { + message = e.toString(); + } + expect(message, "Exception: Unknown node uri scheme \"http\" found."); + }); + + test("decoding to model", () { + final data = NodeQrUtil.decodeUri( + "xmrrpc://nodo:password@bob.onion:18083?label=Nodo+Tor+Node", + ); + expect(data.scheme, "xmrrpc"); + expect(data.host, "bob.onion"); + expect(data.port, 18083); + expect(data.label, "Nodo Tor Node"); + expect((data as MoneroNodeQrData?)?.user, "nodo"); + expect((data as MoneroNodeQrData?)?.password, "password"); + }); + + test("encoding to string", () { + const validString = + "xmrrpc://nodo:password@bob.onion:18083?label=Nodo+Tor+Node"; + final data = NodeQrUtil.decodeUri( + validString, + ); + expect(data.encode(), validString); + }); + + test("normal to string", () { + const validString = + "xmrrpc://nodo:password@bob.onion:18083?label=Nodo+Tor+Node"; + final data = NodeQrUtil.decodeUri( + validString, + ); + expect( + data.toString(), + "MoneroNodeQrData {" + "scheme: xmrrpc, " + "host: bob.onion, " + "port: 18083, " + "user: nodo, " + "password: password, " + "label: Nodo Tor Node" + "}", + ); + }); +} From cdf2dd88199b33e998923fb322cb6c0554a1ab05 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 11 Dec 2024 15:26:12 -0600 Subject: [PATCH 04/12] feat: xmrrpc (and wowrpc) node uri scanning ui for adding new nodes --- .../add_edit_node_view.dart | 177 ++++++++++++++++-- 1 file changed, 159 insertions(+), 18 deletions(-) diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart index ccf4bf2ba..324a3d02a 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart @@ -10,6 +10,7 @@ import 'dart:async'; +import 'package:barcode_scan2/barcode_scan2.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -26,6 +27,8 @@ import '../../../../utilities/assets.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/enums/sync_type_enum.dart'; import '../../../../utilities/flutter_secure_storage_interface.dart'; +import '../../../../utilities/logger.dart'; +import '../../../../utilities/node_uri_util.dart'; import '../../../../utilities/test_node_connection.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../utilities/tor_plain_net_option_enum.dart'; @@ -38,7 +41,9 @@ import '../../../../widgets/conditional_parent.dart'; import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../../widgets/desktop/secondary_button.dart'; +import '../../../../widgets/icon_widgets/qrcode_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; import '../../../../widgets/stack_dialog.dart'; import '../../../../widgets/stack_text_field.dart'; @@ -73,6 +78,8 @@ class _AddEditNodeViewState extends ConsumerState { late final String? nodeId; late final bool isDesktop; + (NodeModel, String)? _scannedResult; + late bool saveEnabled; late bool testConnectionEnabled; @@ -330,6 +337,77 @@ class _AddEditNodeViewState extends ConsumerState { } } + void _scanQr() async { + if (Util.isDesktop) { + try { + await showDialog( + context: context, + builder: (context) { + return QrCodeScannerDialog( + onQrCodeDetected: (qrCodeData) { + try { + _processQrData(qrCodeData); + } catch (e, s) { + Logging.instance.log( + "Error processing QR code data: $e\n$s", + level: LogLevel.Error, + ); + } + }, + ); + }, + ); + } catch (e, s) { + Logging.instance.log( + "Error opening QR code scanner dialog: $e\n$s", + level: LogLevel.Error, + ); + } + } else { + try { + final result = await BarcodeScanner.scan(); + await _processQrData(result.rawContent); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Warning, + ); + } + } + } + + Future _processQrData(String data) async { + try { + final nodeQrData = NodeQrUtil.decodeUri(data); + if (mounted) { + setState(() { + _scannedResult = ( + NodeModel( + host: nodeQrData.host, + port: nodeQrData.port, + name: nodeQrData.label ?? "", + id: const Uuid().v1(), + useSSL: nodeQrData.scheme == "https", + enabled: true, + coinName: coin.identifier, + isFailover: true, + isDown: false, + torEnabled: true, + clearnetEnabled: !nodeQrData.host.endsWith(".onion"), + loginName: (nodeQrData as LibMoneroNodeQrData?)?.user, + ), + (nodeQrData as LibMoneroNodeQrData?)?.password ?? "" + ); + }); + } + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Warning, + ); + } + } + @override void initState() { isDesktop = Util.isDesktop; @@ -390,6 +468,35 @@ class _AddEditNodeViewState extends ConsumerState { style: STextStyles.navBarTitle(context), ), actions: [ + if (viewType == AddEditNodeViewType.add && + coin + is CryptonoteCurrency) // TODO: [prio=low] do something other than `coin is CryptonoteCurrency` in the future + Padding( + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 10, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("qrNodeAppBarButtonKey"), + size: 36, + shadows: const [], + color: Theme.of(context) + .extension()! + .background, + icon: QrCodeIcon( + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .accentColorDark, + ), + onPressed: _scanQr, + ), + ), + ), if (viewType == AddEditNodeViewType.edit && ref .watch( @@ -473,19 +580,47 @@ class _AddEditNodeViewState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ + const SizedBox( + height: 8, + ), Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const SizedBox( - width: 8, - ), - const AppBarBackButton( - iconSize: 24, - size: 40, - ), - Text( - "Add new node", - style: STextStyles.desktopH3(context), + Row( + children: [ + const SizedBox( + width: 8, + ), + const AppBarBackButton( + iconSize: 24, + size: 40, + ), + Text( + "Add new node", + style: STextStyles.desktopH3(context), + ), + ], ), + if (coin + is CryptonoteCurrency) // TODO: [prio=low] do something other than `coin is CryptonoteCurrency` in the future + Padding( + padding: const EdgeInsets.only(right: 32), + child: AppBarIconButton( + size: 40, + color: isDesktop + ? Theme.of(context) + .extension()! + .textFieldDefaultBG + : Theme.of(context) + .extension()! + .background, + icon: const QrCodeIcon( + width: 21, + height: 21, + ), + onPressed: _scanQr, + ), + ), ], ), Padding( @@ -504,7 +639,9 @@ class _AddEditNodeViewState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ NodeForm( - node: node, + key: Key((node ?? _scannedResult?.$1)?.id ?? "none"), + node: node ?? _scannedResult?.$1, + scannedPw: _scannedResult?.$2, secureStore: ref.read(secureStoreProvider), readOnly: false, coin: widget.coin, @@ -629,6 +766,7 @@ class NodeForm extends ConsumerStatefulWidget { const NodeForm({ super.key, this.node, + this.scannedPw, required this.secureStore, required this.readOnly, required this.coin, @@ -636,6 +774,7 @@ class NodeForm extends ConsumerStatefulWidget { }); final NodeModel? node; + final String? scannedPw; final SecureStorageInterface secureStore; final bool readOnly; final CryptoCurrency coin; @@ -738,13 +877,15 @@ class _NodeFormState extends ConsumerState { if (widget.node != null) { final node = widget.node!; if (enableAuthFields) { - node.getPassword(widget.secureStore).then((value) { - if (value is String) { - _passwordController.text = value; - } - }); - - _usernameController.text = node.loginName ?? ""; + if (widget.scannedPw == null) { + node.getPassword(widget.secureStore).then((value) { + if (value is String) { + _passwordController.text = value; + } + }); + } else { + _passwordController.text = widget.scannedPw!; + } } _nameController.text = node.name; From e749c62ccd7760d62541bc33f2f47afa259a9586 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 11 Dec 2024 16:41:00 -0600 Subject: [PATCH 05/12] chore: satisfy linter --- .../desktop/qr_code_scanner_dialog.dart | 149 ++++++++++-------- 1 file changed, 83 insertions(+), 66 deletions(-) diff --git a/lib/widgets/desktop/qr_code_scanner_dialog.dart b/lib/widgets/desktop/qr_code_scanner_dialog.dart index edb6a074a..260ce61c9 100644 --- a/lib/widgets/desktop/qr_code_scanner_dialog.dart +++ b/lib/widgets/desktop/qr_code_scanner_dialog.dart @@ -23,14 +23,15 @@ import 'primary_button.dart'; import 'secondary_button.dart'; class QrCodeScannerDialog extends StatefulWidget { - final Function(String) onQrCodeDetected; + final void Function(String) onQrCodeDetected; - QrCodeScannerDialog({ + const QrCodeScannerDialog({ + super.key, required this.onQrCodeDetected, }); @override - _QrCodeScannerDialogState createState() => _QrCodeScannerDialogState(); + State createState() => _QrCodeScannerDialogState(); } class _QrCodeScannerDialogState extends State { @@ -66,16 +67,16 @@ class _QrCodeScannerDialogState extends State { }); if (Platform.isLinux && _cameraLinuxPlugin != null) { - await _cameraLinuxPlugin!.initializeCamera(); + await _cameraLinuxPlugin.initializeCamera(); Logging.instance.log("Linux Camera initialized", level: LogLevel.Info); } else if (Platform.isWindows && _cameraWindowsPlugin != null) { final List cameras = - await _cameraWindowsPlugin!.availableCameras(); + await _cameraWindowsPlugin.availableCameras(); if (cameras.isEmpty) { throw CameraException('No cameras available', 'No cameras found.'); } final CameraDescription camera = cameras[0]; // Could be user-selected. - _cameraId = await _cameraWindowsPlugin!.createCameraWithSettings( + _cameraId = await _cameraWindowsPlugin.createCameraWithSettings( camera, const MediaSettings( resolutionPreset: ResolutionPreset.low, @@ -84,11 +85,13 @@ class _QrCodeScannerDialogState extends State { enableAudio: false, ), ); - await _cameraWindowsPlugin!.initializeCamera(_cameraId); + await _cameraWindowsPlugin.initializeCamera(_cameraId); // await _cameraWindowsPlugin!.onCameraInitialized(_cameraId).first; // TODO [prio=low]: Make this work. ^^^ - Logging.instance.log("Windows Camera initialized with ID: $_cameraId", - level: LogLevel.Info); + Logging.instance.log( + "Windows Camera initialized with ID: $_cameraId", + level: LogLevel.Info, + ); } else if (Platform.isMacOS) { final List videoDevices = await CameraMacOS.instance .listDevices(deviceType: CameraMacOSDeviceType.video); @@ -104,8 +107,9 @@ class _QrCodeScannerDialogState extends State { }); Logging.instance.log( - "macOS Camera initialized with ID: $_macOSDeviceId", - level: LogLevel.Info); + "macOS Camera initialized with ID: $_macOSDeviceId", + level: LogLevel.Info, + ); } if (mounted) { setState(() { @@ -129,13 +133,15 @@ class _QrCodeScannerDialogState extends State { Future _stopCamera() async { try { if (Platform.isLinux && _cameraLinuxPlugin != null) { - _cameraLinuxPlugin!.stopCamera(); + _cameraLinuxPlugin.stopCamera(); Logging.instance.log("Linux Camera stopped", level: LogLevel.Info); } else if (Platform.isWindows && _cameraWindowsPlugin != null) { // if (_cameraId >= 0) { - await _cameraWindowsPlugin!.dispose(_cameraId); - Logging.instance.log("Windows Camera stopped with ID: $_cameraId", - level: LogLevel.Info); + await _cameraWindowsPlugin.dispose(_cameraId); + Logging.instance.log( + "Windows Camera stopped with ID: $_cameraId", + level: LogLevel.Info, + ); // } else { // Logging.instance.log("Windows Camera ID is null. Cannot dispose.", // level: LogLevel.Error); @@ -143,8 +149,10 @@ class _QrCodeScannerDialogState extends State { } else if (Platform.isMacOS) { // if (_macOSDeviceId != null) { await CameraMacOS.instance.stopImageStream(); - Logging.instance.log("macOS Camera stopped with ID: $_macOSDeviceId", - level: LogLevel.Info); + Logging.instance.log( + "macOS Camera stopped with ID: $_macOSDeviceId", + level: LogLevel.Info, + ); // } else { // Logging.instance.log("macOS Camera ID is null. Cannot stop.", // level: LogLevel.Error); @@ -168,7 +176,7 @@ class _QrCodeScannerDialogState extends State { try { String? base64Image; if (Platform.isLinux && _cameraLinuxPlugin != null) { - base64Image = await _cameraLinuxPlugin!.captureImage(); + base64Image = await _cameraLinuxPlugin.captureImage(); } else if (Platform.isWindows) { final XFile xfile = await _cameraWindowsPlugin!.takePicture(_cameraId); @@ -180,14 +188,14 @@ class _QrCodeScannerDialogState extends State { if (macOSimg == null) { Logging.instance .log("Failed to capture image", level: LogLevel.Error); - await Future.delayed(Duration(milliseconds: _imageDelayInMs)); + await Future.delayed(Duration(milliseconds: _imageDelayInMs)); continue; } final img.Image? image = img.decodeImage(macOSimg.bytes!); if (image == null) { Logging.instance .log("Failed to capture image", level: LogLevel.Error); - await Future.delayed(Duration(milliseconds: _imageDelayInMs)); + await Future.delayed(Duration(milliseconds: _imageDelayInMs)); continue; } base64Image = base64Encode(img.encodePng(image)); @@ -196,7 +204,7 @@ class _QrCodeScannerDialogState extends State { // Logging.instance // .log("Failed to capture image", level: LogLevel.Error); // Spammy. - await Future.delayed(Duration(milliseconds: _imageDelayInMs)); + await Future.delayed(Duration(milliseconds: _imageDelayInMs)); continue; } final img.Image? image = img.decodeImage(base64Decode(base64Image)); @@ -205,7 +213,7 @@ class _QrCodeScannerDialogState extends State { // > decoders, it is much slower than using an explicit decoder if (image == null) { Logging.instance.log("Failed to decode image", level: LogLevel.Error); - await Future.delayed(Duration(milliseconds: _imageDelayInMs)); + await Future.delayed(Duration(milliseconds: _imageDelayInMs)); continue; } @@ -233,8 +241,8 @@ class _QrCodeScannerDialogState extends State { // Spammy. } - await Future.delayed(Duration(milliseconds: _imageDelayInMs)); - } catch (e, s) { + await Future.delayed(Duration(milliseconds: _imageDelayInMs)); + } catch (e) { // Logging.instance.log("Failed to capture and scan image: $e\n$s", level: LogLevel.Error); // Spammy. @@ -266,7 +274,7 @@ class _QrCodeScannerDialogState extends State { return null; } return qrDecode.text; - } catch (e, s) { + } catch (e) { // Logging.instance.log("Failed to decode QR code: $e\n$s", level: LogLevel.Error); // Spammy. return null; @@ -322,61 +330,70 @@ class _QrCodeScannerDialogState extends State { allowedExtensions: ["png", "jpg", "jpeg"], ); - if (result == null || result.files.single.path == null) { - await showFloatingFlushBar( - type: FlushBarType.info, - message: "No file selected", - iconAsset: Assets.svg.file, - context: context, - ); - return; - } - - final filePath = result?.files.single.path!; - if (filePath == null) { - await showFloatingFlushBar( - type: FlushBarType.info, - message: "Error selecting file.", - iconAsset: Assets.svg.file, - context: context, - ); - return; - } - try { - final img.Image? image = - img.decodeImage(File(filePath!).readAsBytesSync()); - if (image == null) { + if (context.mounted) { + if (result == null || result.files.single.path == null) { await showFloatingFlushBar( type: FlushBarType.info, - message: "Failed to decode image.", + message: "No file selected", iconAsset: Assets.svg.file, context: context, ); return; } - final String? scanResult = await _scanImage(image); - if (scanResult != null && scanResult.isNotEmpty) { - widget.onQrCodeDetected(scanResult); - Navigator.of(context).pop(); - } else { + final filePath = result.files.single.path; + if (filePath == null) { await showFloatingFlushBar( type: FlushBarType.info, - message: "No QR code found in the image.", + message: "Error selecting file.", iconAsset: Assets.svg.file, context: context, ); + return; + } + + try { + final img.Image? image = + img.decodeImage(File(filePath).readAsBytesSync()); + if (image == null) { + await showFloatingFlushBar( + type: FlushBarType.info, + message: "Failed to decode image.", + iconAsset: Assets.svg.file, + context: context, + ); + return; + } + + final String? scanResult = await _scanImage(image); + if (context.mounted) { + if (scanResult != null && scanResult.isNotEmpty) { + widget.onQrCodeDetected(scanResult); + Navigator.of(context).pop(); + } else { + await showFloatingFlushBar( + type: FlushBarType.info, + message: "No QR code found in the image.", + iconAsset: Assets.svg.file, + context: context, + ); + } + } + } catch (e, s) { + Logging.instance.log( + "Failed to decode image: $e\n$s", + level: LogLevel.Error, + ); + if (context.mounted) { + await showFloatingFlushBar( + type: FlushBarType.info, + message: + "Error processing the image. Please try again.", + iconAsset: Assets.svg.file, + context: context, + ); + } } - } catch (e, s) { - Logging.instance.log("Failed to decode image: $e\n$s", - level: LogLevel.Error); - await showFloatingFlushBar( - type: FlushBarType.info, - message: - "Error processing the image. Please try again.", - iconAsset: Assets.svg.file, - context: context, - ); } }, ), From 3adddc2368627226a2d2e6ccd3c654aca27489d2 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 11 Dec 2024 17:40:22 -0600 Subject: [PATCH 06/12] fix: possible race condition when processing desktop qr data by returning the result on pop rather than using a callback, clean up some linter stuff, and prevent calling setState in dispose --- .../restore/restore_frost_ms_wallet_view.dart | 34 +- .../add_edit_node_view.dart | 77 +++-- .../wallet_view/sub_widgets/desktop_send.dart | 32 +- .../desktop/qr_code_scanner_dialog.dart | 296 ++++++++---------- lib/widgets/textfields/frost_step_field.dart | 30 +- 5 files changed, 229 insertions(+), 240 deletions(-) diff --git a/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart b/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart index 75c5cbc7d..14e1d45e5 100644 --- a/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart +++ b/lib/pages/add_wallet_views/frost_ms/restore/restore_frost_ms_wallet_view.dart @@ -228,26 +228,24 @@ class _RestoreFrostMsWalletViewState }); } else { // Platform.isLinux, Platform.isWindows, or Platform.isMacOS. - await showDialog( + final qrResult = await showDialog( context: context, - builder: (context) { - return QrCodeScannerDialog( - onQrCodeDetected: (qrCodeData) { - try { - // TODO [prio=low]: Validate QR code data. - configFieldController.text = qrCodeData; - - setState(() { - _configEmpty = configFieldController.text.isEmpty; - }); - } catch (e, s) { - Logging.instance.log("Error processing QR code data: $e\n$s", - level: LogLevel.Error); - } - }, - ); - }, + builder: (context) => const QrCodeScannerDialog(), ); + + if (qrResult == null) { + Logging.instance.log( + "Qr scanning cancelled", + level: LogLevel.Info, + ); + } else { + // TODO [prio=low]: Validate QR code data. + configFieldController.text = qrResult; + + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + } } } on PlatformException catch (e, s) { Logging.instance.log( diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart index 324a3d02a..fb18a4716 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart @@ -337,42 +337,53 @@ class _AddEditNodeViewState extends ConsumerState { } } + bool _scanLock = false; + void _scanQr() async { - if (Util.isDesktop) { - try { - await showDialog( - context: context, - builder: (context) { - return QrCodeScannerDialog( - onQrCodeDetected: (qrCodeData) { - try { - _processQrData(qrCodeData); - } catch (e, s) { - Logging.instance.log( - "Error processing QR code data: $e\n$s", - level: LogLevel.Error, - ); - } - }, + if (_scanLock) return; + _scanLock = true; + try { + if (Util.isDesktop) { + try { + final qrResult = await showDialog( + context: context, + builder: (context) => const QrCodeScannerDialog(), + ); + + if (qrResult == null) { + Logging.instance.log( + "Qr scanning cancelled", + level: LogLevel.Info, ); - }, - ); - } catch (e, s) { - Logging.instance.log( - "Error opening QR code scanner dialog: $e\n$s", - level: LogLevel.Error, - ); - } - } else { - try { - final result = await BarcodeScanner.scan(); - await _processQrData(result.rawContent); - } catch (e, s) { - Logging.instance.log( - "$e\n$s", - level: LogLevel.Warning, - ); + } else { + try { + await _processQrData(qrResult); + } catch (e, s) { + Logging.instance.log( + "Error processing QR code data: $e\n$s", + level: LogLevel.Error, + ); + } + } + } catch (e, s) { + Logging.instance.log( + "Error opening QR code scanner dialog: $e\n$s", + level: LogLevel.Error, + ); + } + } else { + try { + final result = await BarcodeScanner.scan(); + await _processQrData(result.rawContent); + } catch (e, s) { + Logging.instance.log( + "$e\n$s", + level: LogLevel.Warning, + ); + } } + } finally { + _scanLock = false; } } diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index 7fb764cfa..0fb65c635 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -145,23 +145,25 @@ class _DesktopSendState extends ConsumerState { Future scanWebcam() async { try { - await showDialog( + final qrResult = await showDialog( context: context, - builder: (context) { - return QrCodeScannerDialog( - onQrCodeDetected: (qrCodeData) { - try { - _processQrCodeData(qrCodeData); - } catch (e, s) { - Logging.instance.log( - "Error processing QR code data: $e\n$s", - level: LogLevel.Error, - ); - } - }, - ); - }, + builder: (context) => const QrCodeScannerDialog(), ); + if (qrResult == null) { + Logging.instance.log( + "Qr scanning cancelled", + level: LogLevel.Info, + ); + } else { + try { + _processQrCodeData(qrResult); + } catch (e, s) { + Logging.instance.log( + "Error processing QR code data: $e\n$s", + level: LogLevel.Error, + ); + } + } } catch (e, s) { Logging.instance.log( "Error opening QR code scanner dialog: $e\n$s", diff --git a/lib/widgets/desktop/qr_code_scanner_dialog.dart b/lib/widgets/desktop/qr_code_scanner_dialog.dart index 260ce61c9..b9a583f46 100644 --- a/lib/widgets/desktop/qr_code_scanner_dialog.dart +++ b/lib/widgets/desktop/qr_code_scanner_dialog.dart @@ -23,12 +23,7 @@ import 'primary_button.dart'; import 'secondary_button.dart'; class QrCodeScannerDialog extends StatefulWidget { - final void Function(String) onQrCodeDetected; - - const QrCodeScannerDialog({ - super.key, - required this.onQrCodeDetected, - }); + const QrCodeScannerDialog({super.key}); @override State createState() => _QrCodeScannerDialogState(); @@ -44,28 +39,26 @@ class _QrCodeScannerDialogState extends State { bool _isScanning = false; int _cameraId = -1; String? _macOSDeviceId; - final int _imageDelayInMs = 250; + final int _imageDelayInMs = Platform.isLinux ? 500 : 250; @override void initState() { super.initState(); - _isCameraOpen = false; - _isScanning = false; - _initializeCamera(); - } - @override - void dispose() { - _stopCamera(); - super.dispose(); - } - - Future _initializeCamera() async { - try { - setState(() { - _isScanning = true; // Show the progress indicator + _initializeCamera().then((camOpen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && camOpen) { + setState(() { + _isCameraOpen = true; + }); + unawaited(_captureAndScanImage()); + } }); + }); + } + Future _initializeCamera() async { + try { if (Platform.isLinux && _cameraLinuxPlugin != null) { await _cameraLinuxPlugin.initializeCamera(); Logging.instance.log("Linux Camera initialized", level: LogLevel.Info); @@ -102,35 +95,23 @@ class _QrCodeScannerDialogState extends State { await CameraMacOS.instance .initialize(cameraMacOSMode: CameraMacOSMode.photo); - setState(() { - _isCameraOpen = true; - }); - Logging.instance.log( "macOS Camera initialized with ID: $_macOSDeviceId", level: LogLevel.Info, ); } - if (mounted) { - setState(() { - _isCameraOpen = true; - _isScanning = true; - }); - } - unawaited(_captureAndScanImage()); // Could be awaited. + + return true; } catch (e, s) { Logging.instance .log("Failed to initialize camera: $e\n$s", level: LogLevel.Error); - if (mounted) { - // widget.onSnackbar("Failed to initialize camera. Please try again."); - setState(() { - _isScanning = false; - }); - } + return false; } } Future _stopCamera() async { + _isScanning = false; + try { if (Platform.isLinux && _cameraLinuxPlugin != null) { _cameraLinuxPlugin.stopCamera(); @@ -161,18 +142,12 @@ class _QrCodeScannerDialogState extends State { } catch (e, s) { Logging.instance .log("Failed to stop camera: $e\n$s", level: LogLevel.Error); - } finally { - if (mounted) { - setState(() { - _isScanning = false; - _isCameraOpen = false; - }); - } } } Future _captureAndScanImage() async { - while (_isCameraOpen && _isScanning) { + _isScanning = true; + while (_isScanning) { try { String? base64Image; if (Platform.isLinux && _cameraLinuxPlugin != null) { @@ -228,9 +203,10 @@ class _QrCodeScannerDialogState extends State { final String? scanResult = await _scanImage(image); if (scanResult != null && scanResult.isNotEmpty) { - widget.onQrCodeDetected(scanResult); + await _stopCamera(); + if (mounted) { - Navigator.of(context).pop(); + Navigator.of(context).pop(scanResult); } break; } else { @@ -283,135 +259,139 @@ class _QrCodeScannerDialogState extends State { @override Widget build(BuildContext context) { - return DesktopDialog( - maxWidth: 696, - maxHeight: 600, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Scan QR code", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: _isCameraOpen - ? _image != null - ? _image! - : const Center( - child: CircularProgressIndicator(), - ) - : const Center( - child: - CircularProgressIndicator(), // Show progress indicator immediately - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Row( + return PopScope( + onPopInvokedWithResult: (_, __) { + _stopCamera(); + }, + child: DesktopDialog( + maxWidth: 696, + maxHeight: 600, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded(child: Container()), - // "Select file" button. - SecondaryButton( - buttonHeight: ButtonHeight.l, - label: "Select file", - width: 200, - onPressed: () async { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ["png", "jpg", "jpeg"], - ); + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Scan QR code", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: _isCameraOpen + ? _image != null + ? _image! + : const Center( + child: CircularProgressIndicator(), + ) + : const Center( + child: + CircularProgressIndicator(), // Show progress indicator immediately + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded(child: Container()), + // "Select file" button. + SecondaryButton( + buttonHeight: ButtonHeight.l, + label: "Select file", + width: 200, + onPressed: () async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ["png", "jpg", "jpeg"], + ); - if (context.mounted) { - if (result == null || result.files.single.path == null) { - await showFloatingFlushBar( - type: FlushBarType.info, - message: "No file selected", - iconAsset: Assets.svg.file, - context: context, - ); - return; - } - - final filePath = result.files.single.path; - if (filePath == null) { - await showFloatingFlushBar( - type: FlushBarType.info, - message: "Error selecting file.", - iconAsset: Assets.svg.file, - context: context, - ); - return; - } - - try { - final img.Image? image = - img.decodeImage(File(filePath).readAsBytesSync()); - if (image == null) { + if (context.mounted) { + if (result == null || + result.files.single.path == null) { await showFloatingFlushBar( type: FlushBarType.info, - message: "Failed to decode image.", + message: "No file selected", iconAsset: Assets.svg.file, context: context, ); return; } - final String? scanResult = await _scanImage(image); - if (context.mounted) { - if (scanResult != null && scanResult.isNotEmpty) { - widget.onQrCodeDetected(scanResult); - Navigator.of(context).pop(); - } else { + final filePath = result.files.single.path; + if (filePath == null) { + await showFloatingFlushBar( + type: FlushBarType.info, + message: "Error selecting file.", + iconAsset: Assets.svg.file, + context: context, + ); + return; + } + + try { + final img.Image? image = + img.decodeImage(File(filePath).readAsBytesSync()); + if (image == null) { await showFloatingFlushBar( type: FlushBarType.info, - message: "No QR code found in the image.", + message: "Failed to decode image.", + iconAsset: Assets.svg.file, + context: context, + ); + return; + } + + final String? scanResult = await _scanImage(image); + if (context.mounted) { + if (scanResult != null && scanResult.isNotEmpty) { + Navigator.of(context).pop(scanResult); + } else { + await showFloatingFlushBar( + type: FlushBarType.info, + message: "No QR code found in the image.", + iconAsset: Assets.svg.file, + context: context, + ); + } + } + } catch (e, s) { + Logging.instance.log( + "Failed to decode image: $e\n$s", + level: LogLevel.Error, + ); + if (context.mounted) { + await showFloatingFlushBar( + type: FlushBarType.info, + message: + "Error processing the image. Please try again.", iconAsset: Assets.svg.file, context: context, ); } } - } catch (e, s) { - Logging.instance.log( - "Failed to decode image: $e\n$s", - level: LogLevel.Error, - ); - if (context.mounted) { - await showFloatingFlushBar( - type: FlushBarType.info, - message: - "Error processing the image. Please try again.", - iconAsset: Assets.svg.file, - context: context, - ); - } } - } - }, - ), - const SizedBox(width: 16), - // Close button. - PrimaryButton( - buttonHeight: ButtonHeight.l, - label: "Close", - width: 272.5, - onPressed: () { - _stopCamera(); - Navigator.of(context).pop(); - }, - ), - ], + }, + ), + const SizedBox(width: 16), + // Close button. + PrimaryButton( + buttonHeight: ButtonHeight.l, + label: "Close", + width: 272.5, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/widgets/textfields/frost_step_field.dart b/lib/widgets/textfields/frost_step_field.dart index d6e6b0cb8..f7563080f 100644 --- a/lib/widgets/textfields/frost_step_field.dart +++ b/lib/widgets/textfields/frost_step_field.dart @@ -91,24 +91,22 @@ class _FrostStepFieldState extends State { _changed(widget.controller.text); } else { // Platform.isLinux, Platform.isWindows, or Platform.isMacOS. - await showDialog( + final qrResult = await showDialog( context: context, - builder: (context) { - return QrCodeScannerDialog( - onQrCodeDetected: (qrCodeData) { - try { - // TODO [prio=low]: Validate QR code data. - widget.controller.text = qrCodeData; - - _changed(widget.controller.text); - } catch (e, s) { - Logging.instance.log("Error processing QR code data: $e\n$s", - level: LogLevel.Error); - } - }, - ); - }, + builder: (context) => const QrCodeScannerDialog(), ); + + if (qrResult == null) { + Logging.instance.log( + "Qr scanning cancelled", + level: LogLevel.Info, + ); + } else { + // TODO [prio=low]: Validate QR code data. + widget.controller.text = qrResult; + + _changed(widget.controller.text); + } } } on PlatformException catch (e, s) { Logging.instance.log( From 120952156f76277f287ca7716bb8a18c25564bb3 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 11 Dec 2024 18:20:44 -0600 Subject: [PATCH 07/12] fix: Linux qr scan would previously load the last scanned data if it exists --- pubspec.lock | 9 +++++---- scripts/app_config/templates/pubspec.template | 6 +++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 9b55c770b..812d1c1d6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -254,10 +254,11 @@ packages: camera_linux: dependency: "direct main" description: - name: camera_linux - sha256: "6ea08c23f643364e650e8fad73653747c049cbd00803a7c317132379ee3653ac" - url: "https://pub.dev" - source: hosted + path: "." + ref: ecb412474c5d240347b04ac1eb9f019802ff7034 + resolved-ref: ecb412474c5d240347b04ac1eb9f019802ff7034 + url: "https://github.com/cypherstack/camera-linux" + source: git version: "0.0.8" camera_macos: dependency: "direct main" diff --git a/scripts/app_config/templates/pubspec.template b/scripts/app_config/templates/pubspec.template index 9b4541240..6e31b1405 100644 --- a/scripts/app_config/templates/pubspec.template +++ b/scripts/app_config/templates/pubspec.template @@ -184,7 +184,11 @@ dependencies: calendar_date_picker2: ^1.0.2 sqlite3: 2.4.3 sqlite3_flutter_libs: 0.5.22 - camera_linux: ^0.0.8 +# camera_linux: ^0.0.8 + camera_linux: + git: + url: https://github.com/cypherstack/camera-linux + ref: ecb412474c5d240347b04ac1eb9f019802ff7034 zxing2: ^0.2.3 camera_windows: git: # TODO [prio=low]: Revert to official after https://github.com/flutter/packages/pull/7067. From 9c64ed63163f9f5c39f78d187b3fc72a91a1a967 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 12 Dec 2024 16:53:21 -0600 Subject: [PATCH 08/12] chore: update deps --- pubspec.lock | 8 ++++---- scripts/app_config/templates/pubspec.template | 10 ++-------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 812d1c1d6..13ac9db0a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -684,8 +684,8 @@ packages: dependency: "direct main" description: path: "." - ref: "6bf385b2e1e18c8aa23783cb8afeabace299cf68" - resolved-ref: "6bf385b2e1e18c8aa23783cb8afeabace299cf68" + ref: f0b1300140d45c13e7722f8f8d20308efeba8449 + resolved-ref: f0b1300140d45c13e7722f8f8d20308efeba8449 url: "https://github.com/cypherstack/electrum_adapter.git" source: git version: "3.0.0" @@ -1980,8 +1980,8 @@ packages: dependency: "direct main" description: path: "." - ref: "534ec251b339199446b723c01a25d324ae7bb974" - resolved-ref: "534ec251b339199446b723c01a25d324ae7bb974" + ref: "752f054b65c500adb9cad578bf183a978e012502" + resolved-ref: "752f054b65c500adb9cad578bf183a978e012502" url: "https://github.com/cypherstack/tor.git" source: git version: "0.0.1" diff --git a/scripts/app_config/templates/pubspec.template b/scripts/app_config/templates/pubspec.template index 6e31b1405..7626f030b 100644 --- a/scripts/app_config/templates/pubspec.template +++ b/scripts/app_config/templates/pubspec.template @@ -63,7 +63,7 @@ dependencies: tor_ffi_plugin: git: url: https://github.com/cypherstack/tor.git - ref: 647cadc3c82c276dc07915b02d24538fd610f220 + ref: 752f054b65c500adb9cad578bf183a978e012502 fusiondart: git: @@ -174,7 +174,7 @@ dependencies: electrum_adapter: git: url: https://github.com/cypherstack/electrum_adapter.git - ref: 6bf385b2e1e18c8aa23783cb8afeabace299cf68 + ref: f0b1300140d45c13e7722f8f8d20308efeba8449 stream_channel: ^2.1.0 solana: git: # TODO [prio=low]: Revert to official package once Tor support is merged upstream. @@ -245,12 +245,6 @@ dependency_overrides: ref: 0acacfd17eacf72135c693a7b862bd9b7cc56739 path: coinlib_flutter - # adding here due to pure laziness - tor_ffi_plugin: - git: - url: https://github.com/cypherstack/tor.git - ref: 534ec251b339199446b723c01a25d324ae7bb974 - bip47: git: url: https://github.com/cypherstack/bip47.git From c56038cadfd794678fd4f6a1fb25a0a246bc5d8d Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 12 Dec 2024 16:54:55 -0600 Subject: [PATCH 09/12] fix: Prevent failed keys fetch from disabling display of mnemonic --- .../wallet_backup_views/cn_wallet_keys.dart | 12 +- .../unlock_wallet_keys_desktop.dart | 103 +----------------- .../intermediate/lib_monero_wallet.dart | 26 +++-- 3 files changed, 26 insertions(+), 115 deletions(-) diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/cn_wallet_keys.dart b/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/cn_wallet_keys.dart index 14cae2ee0..ffe541b21 100644 --- a/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/cn_wallet_keys.dart +++ b/lib/pages/settings_views/wallet_settings_view/wallet_backup_views/cn_wallet_keys.dart @@ -156,11 +156,13 @@ class _CNWalletKeysState extends State { SizedBox( height: Util.isDesktop ? 12 : 16, ), - QR( - data: _current(_currentDropDownValue), - size: - Util.isDesktop ? 256 : MediaQuery.of(context).size.width / 1.5, - ), + if (_current(_currentDropDownValue) != "ERROR") + QR( + data: _current(_currentDropDownValue), + size: Util.isDesktop + ? 256 + : MediaQuery.of(context).size.width / 1.5, + ), SizedBox( height: Util.isDesktop ? 12 : 16, ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart index f2357448c..938e9568a 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/unlock_wallet_keys_desktop.dart @@ -313,108 +313,7 @@ class _UnlockWalletKeysDesktopState child: PrimaryButton( label: "Continue", enabled: continueEnabled, - onPressed: continueEnabled - ? () async { - unawaited( - showDialog( - context: context, - builder: (context) => const Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - LoadingIndicator( - width: 200, - height: 200, - ), - ], - ), - ), - ); - - await Future.delayed( - const Duration(seconds: 1), - ); - - final verified = await ref - .read(storageCryptoHandlerProvider) - .verifyPassphrase(passwordController.text); - - if (verified) { - if (context.mounted) { - Navigator.of(context, rootNavigator: true) - .pop(); - } - - ({String keys, String config})? frostData; - List? words; - - final wallet = - ref.read(pWallets).getWallet(widget.walletId); - - // TODO: [prio=low] handle wallets that don't have a mnemonic - // All wallets currently are mnemonic based - if (wallet is! MnemonicInterface) { - if (wallet is BitcoinFrostWallet) { - frostData = ( - keys: (await wallet.getSerializedKeys())!, - config: (await wallet.getMultisigConfig())!, - ); - } else { - throw Exception("FIXME ~= see todo in code"); - } - } else { - if (wallet is ViewOnlyOptionInterface && - (wallet as ViewOnlyOptionInterface) - .isViewOnly) { - // TODO: is something needed here? - } else { - words = await wallet.getMnemonicAsWords(); - } - } - - KeyDataInterface? keyData; - if (wallet is ViewOnlyOptionInterface && - wallet.isViewOnly) { - keyData = await wallet.getViewOnlyWalletData(); - } else if (wallet is ExtendedKeysInterface) { - keyData = await wallet.getXPrivs(); - } else if (wallet is LibMoneroWallet) { - keyData = await wallet.getKeys(); - } - - if (context.mounted) { - await Navigator.of(context) - .pushReplacementNamed( - WalletKeysDesktopPopup.routeName, - arguments: ( - mnemonic: words ?? [], - walletId: widget.walletId, - frostData: frostData, - keyData: keyData, - ), - ); - } - } else { - if (context.mounted) { - Navigator.of(context, rootNavigator: true) - .pop(); - } - - await Future.delayed( - const Duration(milliseconds: 300), - ); - if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Invalid passphrase!", - context: context, - ), - ); - } - } - } - : null, + onPressed: continueEnabled ? enterPassphrase : null, ), ), ], diff --git a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart index ffcaa1560..3c3c34a6d 100644 --- a/lib/wallets/wallet/intermediate/lib_monero_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_monero_wallet.dart @@ -298,14 +298,24 @@ abstract class LibMoneroWallet if (base == null || (oldInfo != null && oldInfo.name != walletId)) { return null; } - - return CWKeyData( - walletId: walletId, - publicViewKey: base.getPublicViewKey(), - privateViewKey: base.getPrivateViewKey(), - publicSpendKey: base.getPublicSpendKey(), - privateSpendKey: base.getPrivateSpendKey(), - ); + try { + return CWKeyData( + walletId: walletId, + publicViewKey: base.getPublicViewKey(), + privateViewKey: base.getPrivateViewKey(), + publicSpendKey: base.getPublicSpendKey(), + privateSpendKey: base.getPrivateSpendKey(), + ); + } catch (e, s) { + Logging.instance.log("getKeys failed: $e\n$s", level: LogLevel.Fatal); + return CWKeyData( + walletId: walletId, + publicViewKey: "ERROR", + privateViewKey: "ERROR", + publicSpendKey: "ERROR", + privateSpendKey: "ERROR", + ); + } } Future<(String, String)> From 2e0ac0b2f517ef76a3260a3ba21eab994ce43482 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 12 Dec 2024 16:58:01 -0600 Subject: [PATCH 10/12] chore: extra logging --- lib/utilities/connection_check/electrum_connection_check.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/utilities/connection_check/electrum_connection_check.dart b/lib/utilities/connection_check/electrum_connection_check.dart index 85ff83511..ae8ba3f94 100644 --- a/lib/utilities/connection_check/electrum_connection_check.dart +++ b/lib/utilities/connection_check/electrum_connection_check.dart @@ -61,7 +61,8 @@ Future checkElectrumServer({ .timeout(Duration(seconds: (proxyInfo == null ? 5 : 30))); return true; - } catch (_) { + } catch (e, s) { + Logging.instance.log("$e\n$s", level: LogLevel.Debug); return false; } } From eeb595e0d9d6e893d9a4407db816745740db6421 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 12 Dec 2024 17:05:58 -0600 Subject: [PATCH 11/12] fix: Update device-locale dep version to handle linux crashes with certain locale configurations --- scripts/app_config/templates/pubspec.template | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/app_config/templates/pubspec.template b/scripts/app_config/templates/pubspec.template index 7626f030b..1f968ec1d 100644 --- a/scripts/app_config/templates/pubspec.template +++ b/scripts/app_config/templates/pubspec.template @@ -123,7 +123,10 @@ dependencies: barcode_scan2: ^4.3.3 wakelock_plus: ^1.2.8 intl: ^0.17.0 - devicelocale: ^0.7.1 + devicelocale: + git: + url: https://github.com/cypherstack/flutter-devicelocale + ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce device_info_plus: ^10.1.2 keyboard_dismisser: ^3.0.0 another_flushbar: ^1.10.28 From e9aa2d6a30d0037da40de80dd3b469ecc87d23d6 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 12 Dec 2024 17:26:55 -0600 Subject: [PATCH 12/12] fix: force override of a dep we don't even use so that sqlite doesn't complain --- pubspec.lock | 13 +++++++------ scripts/app_config/templates/pubspec.template | 2 ++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 13ac9db0a..3ccce4760 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -627,11 +627,12 @@ packages: devicelocale: dependency: "direct main" description: - name: devicelocale - sha256: "0812b66f9eac57bc55c6ed4c178e0779440aa4e4e7c7e32fe1db02a758501d0e" - url: "https://pub.dev" - source: hosted - version: "0.7.1" + path: "." + ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce + resolved-ref: ba7d7d87a3772e972adb1358a5ec9a111b514fce + url: "https://github.com/cypherstack/flutter-devicelocale" + source: git + version: "0.8.1" dio: dependency: transitive description: @@ -2187,7 +2188,7 @@ packages: source: hosted version: "1.1.0" web: - dependency: transitive + dependency: "direct overridden" description: name: web sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" diff --git a/scripts/app_config/templates/pubspec.template b/scripts/app_config/templates/pubspec.template index 1f968ec1d..e44cdcab4 100644 --- a/scripts/app_config/templates/pubspec.template +++ b/scripts/app_config/templates/pubspec.template @@ -231,6 +231,8 @@ flutter_native_splash: android_disable_fullscreen: true dependency_overrides: + # required to make devicelocale work + web: ^0.5.0 # needed for dart 3.5+ (at least for now) win32: ^5.5.4