diff --git a/crypto_plugins/flutter_libmonero b/crypto_plugins/flutter_libmonero index 982f5ab19..db7585d8c 160000 --- a/crypto_plugins/flutter_libmonero +++ b/crypto_plugins/flutter_libmonero @@ -1 +1 @@ -Subproject commit 982f5ab19fe0dd3dd3f6be2c46f8dff13d49027c +Subproject commit db7585d8cd493b143e0a0652c618904d1f636d1d 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 59a798a41..75c5cbc7d 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 @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:barcode_scan2/barcode_scan2.dart'; import 'package:flutter/material.dart'; @@ -32,6 +33,7 @@ import '../../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../../widgets/desktop/desktop_app_bar.dart'; import '../../../../widgets/desktop/desktop_scaffold.dart'; import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../../widgets/frost_mascot.dart'; import '../../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../../widgets/icon_widgets/qrcode_icon.dart'; @@ -207,6 +209,54 @@ class _RestoreFrostMsWalletViewState super.dispose(); } + Future scanQr() async { + try { + if (Platform.isAndroid || Platform.isIOS) { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75), + ); + } + + final qrResult = await BarcodeScanner.scan(); + + configFieldController.text = qrResult.rawContent; + + setState(() { + _configEmpty = configFieldController.text.isEmpty; + }); + } else { + // Platform.isLinux, Platform.isWindows, or Platform.isMacOS. + 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); + } + }, + ); + }, + ); + } + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + } + @override Widget build(BuildContext context) { return ConditionalParent( @@ -351,31 +401,7 @@ class _RestoreFrostMsWalletViewState semanticsLabel: "Scan QR Button. Opens Camera For Scanning QR Code.", key: const Key("frConfigScanQrButtonKey"), - onTap: () async { - try { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } - - final qrResult = await BarcodeScanner.scan(); - - configFieldController.text = - qrResult.rawContent; - - setState(() { - _configEmpty = - configFieldController.text.isEmpty; - }); - } on PlatformException catch (e, s) { - Logging.instance.log( - "Failed to get camera permissions while trying to scan qr code: $e\n$s", - level: LogLevel.Warning, - ); - } - }, + onTap: scanQr, child: const QrCodeIcon(), ), ], diff --git a/lib/pages/receive_view/addresses/address_card.dart b/lib/pages/receive_view/addresses/address_card.dart index db14fd979..59074d979 100644 --- a/lib/pages/receive_view/addresses/address_card.dart +++ b/lib/pages/receive_view/addresses/address_card.dart @@ -304,7 +304,7 @@ class _AddressCardState extends ConsumerState { key: _qrKey, child: QR( data: AddressUtils.buildUriString( - widget.coin, + widget.coin.uriScheme, address.value, {}, ), diff --git a/lib/pages/receive_view/addresses/address_details_view.dart b/lib/pages/receive_view/addresses/address_details_view.dart index 2b0e09187..2006e900b 100644 --- a/lib/pages/receive_view/addresses/address_details_view.dart +++ b/lib/pages/receive_view/addresses/address_details_view.dart @@ -97,7 +97,7 @@ class _AddressDetailsViewState extends ConsumerState { key: _qrKey, child: QR( data: AddressUtils.buildUriString( - ref.watch(pWalletCoin(widget.walletId)), + ref.watch(pWalletCoin(widget.walletId)).uriScheme, address.value, {}, ), @@ -289,7 +289,7 @@ class _AddressDetailsViewState extends ConsumerState { key: _qrKey, child: QR( data: AddressUtils.buildUriString( - coin, + coin.uriScheme, address.value, {}, ), diff --git a/lib/pages/receive_view/addresses/address_qr_popup.dart b/lib/pages/receive_view/addresses/address_qr_popup.dart index 5a8bc1592..b4446f543 100644 --- a/lib/pages/receive_view/addresses/address_qr_popup.dart +++ b/lib/pages/receive_view/addresses/address_qr_popup.dart @@ -142,7 +142,7 @@ class _AddressQrPopupState extends State { key: _qrKey, child: QR( data: AddressUtils.buildUriString( - widget.coin, + widget.coin.uriScheme, widget.addressString, {}, ), diff --git a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart index 7a7497d0e..f9049a538 100644 --- a/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart +++ b/lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart @@ -170,7 +170,7 @@ class _GenerateUriQrCodeViewState extends State { } final uriString = AddressUtils.buildUriString( - widget.coin, + widget.coin.uriScheme, receivingAddress, queryParams, ); @@ -263,7 +263,7 @@ class _GenerateUriQrCodeViewState extends State { } _uriString = AddressUtils.buildUriString( - widget.coin, + widget.coin.uriScheme, receivingAddress, {}, ); diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart index 2beaab5f2..a40262cdc 100644 --- a/lib/pages/receive_view/receive_view.dart +++ b/lib/pages/receive_view/receive_view.dart @@ -577,7 +577,7 @@ class _ReceiveViewState extends ConsumerState { children: [ QR( data: AddressUtils.buildUriString( - coin, + coin.uriScheme, address, {}, ), diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index 09237e080..7b4e99c34 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -16,11 +16,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_libepiccash/lib.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; + import '../../models/isar/models/transaction_note.dart'; import '../../notifications/show_flush_bar.dart'; -import '../pinpad_views/lock_screen_view.dart'; -import 'sub_widgets/sending_transaction_dialog.dart'; -import '../wallet_view/wallet_view.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; import '../../providers/db/main_db_provider.dart'; @@ -53,6 +51,9 @@ import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; +import '../pinpad_views/lock_screen_view.dart'; +import '../wallet_view/wallet_view.dart'; +import 'sub_widgets/sending_transaction_dialog.dart'; class ConfirmTransactionView extends ConsumerStatefulWidget { const ConfirmTransactionView({ diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 0eb6f8881..004b02af1 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -163,25 +163,23 @@ class _SendViewState extends ConsumerState { level: LogLevel.Info, ); - final results = AddressUtils.parseUri(qrResult.rawContent); + final paymentData = AddressUtils.parsePaymentUri(qrResult.rawContent); - Logging.instance.log("qrResult parsed: $results", level: LogLevel.Info); - - if (results.isNotEmpty && results["scheme"] == coin.uriScheme) { + if (paymentData.coin.uriScheme == coin.uriScheme) { // auto fill address - _address = (results["address"] ?? "").trim(); + _address = paymentData.address.trim(); sendToController.text = _address!; // autofill notes field - if (results["message"] != null) { - noteController.text = results["message"]!; - } else if (results["label"] != null) { - noteController.text = results["label"]!; + if (paymentData.message != null) { + noteController.text = paymentData.message!; + } else if (paymentData.label != null) { + noteController.text = paymentData.label!; } // autofill amount field - if (results["amount"] != null) { - final Amount amount = Decimal.parse(results["amount"]!).toAmount( + if (paymentData.amount != null) { + final Amount amount = Decimal.parse(paymentData.amount!).toAmount( fractionDigits: coin.fractionDigits, ); cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format( @@ -1071,7 +1069,7 @@ class _SendViewState extends ConsumerState { _address = _address!.substring(0, _address!.indexOf("\n")); } - sendToController.text = formatAddress(_address!); + sendToController.text = AddressUtils().formatAddress(_address!); } }); } @@ -1402,7 +1400,8 @@ class _SendViewState extends ConsumerState { if (coin is Epiccash) { // strip http:// and https:// if content contains @ - content = formatAddress( + content = AddressUtils() + .formatAddress( content, ); } @@ -2421,22 +2420,3 @@ class _SendViewState extends ConsumerState { ); } } - -String formatAddress(String epicAddress) { - // strip http:// or https:// prefixes if the address contains an @ symbol (and is thus an epicbox address) - if ((epicAddress.startsWith("http://") || - epicAddress.startsWith("https://")) && - epicAddress.contains("@")) { - epicAddress = epicAddress.replaceAll("http://", ""); - epicAddress = epicAddress.replaceAll("https://", ""); - } - // strip mailto: prefix - if (epicAddress.startsWith("mailto:")) { - epicAddress = epicAddress.replaceAll("mailto:", ""); - } - // strip / suffix if the address contains an @ symbol (and is thus an epicbox address) - if (epicAddress.endsWith("/") && epicAddress.contains("@")) { - epicAddress = epicAddress.substring(0, epicAddress.length - 1); - } - return epicAddress; -} diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart index d197a21fd..7f82cc0e5 100644 --- a/lib/pages/send_view/token_send_view.dart +++ b/lib/pages/send_view/token_send_view.dart @@ -53,6 +53,7 @@ import '../../widgets/stack_dialog.dart'; import '../../widgets/stack_text_field.dart'; import '../../widgets/textfield_icon_button.dart'; import '../address_book_views/address_book_view.dart'; +import '../token_view/token_view.dart'; import 'confirm_transaction_view.dart'; import 'sub_widgets/building_transaction_dialog.dart'; import 'sub_widgets/transaction_fee_selection_sheet.dart'; @@ -522,6 +523,7 @@ class _TokenSendViewState extends ConsumerState { walletId: walletId, isTokenTx: true, onSuccess: clearSendForm, + routeOnSuccessName: TokenView.routeName, ), settings: const RouteSettings( name: ConfirmTransactionView.routeName, diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart index 3dcdd45d5..d0f964116 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart @@ -52,6 +52,8 @@ import '../../../../../wallets/isar/models/frost_wallet_info.dart'; import '../../../../../wallets/isar/models/wallet_info.dart'; import '../../../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; import '../../../../../wallets/wallet/impl/epiccash_wallet.dart'; +import '../../../../../wallets/wallet/impl/monero_wallet.dart'; +import '../../../../../wallets/wallet/impl/wownero_wallet.dart'; import '../../../../../wallets/wallet/wallet.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/cw_based_interface.dart'; import '../../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart'; @@ -486,7 +488,13 @@ abstract class SWB { privateKey: privateKey, ); - await wallet.init(); + if (wallet is MoneroWallet /*|| wallet is WowneroWallet doesn't work.*/) { + await wallet.init(isRestore: true); + } else if (wallet is WowneroWallet) { + await wallet.init(isRestore: true); + } else { + await wallet.init(); + } int restoreHeight = walletbackup['restoreHeight'] as int? ?? 0; if (restoreHeight <= 0) { diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart index a792641ba..a96a01b8f 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart @@ -448,7 +448,7 @@ class _DesktopReceiveState extends ConsumerState { Center( child: QR( data: AddressUtils.buildUriString( - coin, + coin.uriScheme, address, {}, ), 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 8edf13e7b..b09b57ef0 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 @@ -9,26 +9,14 @@ */ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:camera_linux/camera_linux.dart'; -import 'package:camera_macos/camera_macos_arguments.dart'; -import 'package:camera_macos/camera_macos_controller.dart'; -import 'package:camera_macos/camera_macos_device.dart'; -import 'package:camera_macos/camera_macos_platform_interface.dart'; -import 'package:camera_platform_interface/camera_platform_interface.dart'; -import 'package:camera_windows/camera_windows.dart'; import 'package:cw_core/monero_transaction_priority.dart'; import 'package:decimal/decimal.dart'; import 'package:dropdown_button2/dropdown_button2.dart'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:image/image.dart' as img; -import 'package:zxing2/qrcode.dart'; import '../../../../models/isar/models/contact_entry.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; @@ -71,6 +59,7 @@ import '../../../../widgets/desktop/desktop_dialog.dart'; import '../../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../../widgets/desktop/desktop_fee_dialog.dart'; import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/desktop/qr_code_scanner_dialog.dart'; import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/dialogs/firo_exchange_address_dialog.dart'; import '../../../../widgets/fee_slider.dart'; @@ -79,7 +68,6 @@ import '../../../../widgets/icon_widgets/clipboard_icon.dart'; import '../../../../widgets/icon_widgets/qrcode_icon.dart'; import '../../../../widgets/icon_widgets/x_icon.dart'; import '../../../../widgets/rounded_container.dart'; -import '../../../../widgets/stack_dialog.dart'; import '../../../../widgets/stack_text_field.dart'; import '../../../../widgets/textfield_icon_button.dart'; import '../../../coin_control/desktop_coin_control_use_dialog.dart'; @@ -160,7 +148,6 @@ class _DesktopSendState extends ConsumerState { context: context, builder: (context) { return QrCodeScannerDialog( - walletId: widget.walletId, onQrCodeDetected: (qrCodeData) { try { _processQrCodeData(qrCodeData); @@ -743,13 +730,15 @@ class _DesktopSendState extends ConsumerState { void _processQrCodeData(String qrCodeData) { try { - var results = AddressUtils.parseUri(qrCodeData); - if (results.isNotEmpty && results["scheme"] == coin.uriScheme) { - _address = (results["address"] ?? "").trim(); + final paymentData = AddressUtils.parsePaymentUri(qrCodeData); + if (paymentData.coin.uriScheme == coin.uriScheme) { + // Auto fill address. + _address = paymentData.address.trim(); sendToController.text = _address!; - if (results["amount"] != null) { - final Amount amount = Decimal.parse(results["amount"]!).toAmount( + // Amount. + if (paymentData.amount != null) { + final Amount amount = Decimal.parse(paymentData.amount!).toAmount( fractionDigits: coin.fractionDigits, ); cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format( @@ -759,6 +748,13 @@ class _DesktopSendState extends ConsumerState { ref.read(pSendAmount.notifier).state = amount; } + // Note/message. + if (paymentData.message != null) { + _note = paymentData.message; + } else if (paymentData.label != null) { + _note = paymentData.label; + } + _setValidAddressProviders(_address); setState(() { _addressToggleFlag = sendToController.text.isNotEmpty; @@ -809,18 +805,65 @@ class _DesktopSendState extends ConsumerState { content = content.substring(0, content.indexOf("\n")); } - if (coin is Epiccash) { - // strip http:// and https:// if content contains @ - content = formatAddress(content); + try { + final paymentData = AddressUtils.parsePaymentUri(content); + if (paymentData.coin.uriScheme == coin.uriScheme) { + // auto fill address + _address = paymentData.address; + sendToController.text = _address!; + + // autofill notes field. + if (paymentData.message != null) { + _note = paymentData.message; + } else if (paymentData.label != null) { + _note = paymentData.label; + } + + // autofill amoutn field + if (paymentData.amount != null) { + final amount = Decimal.parse(paymentData.amount!).toAmount( + fractionDigits: coin.fractionDigits, + ); + cryptoAmountController.text = ref + .read(pAmountFormatter(coin)) + .format(amount, withUnitName: false); + ref.read(pSendAmount.notifier).state = amount; + } + + // Trigger validation after pasting. + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } else { + if (coin is Epiccash) { + content = AddressUtils().formatAddress(content); + } + + sendToController.text = content; + _address = content; + + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); + } + } catch (e) { + // If parsing fails, treat it as a plain address. + if (coin is Epiccash) { + // strip http:// and https:// if content contains @ + content = AddressUtils().formatAddress(content); + } + + sendToController.text = content; + _address = content; + + // Trigger validation after pasting. + _setValidAddressProviders(_address); + setState(() { + _addressToggleFlag = sendToController.text.isNotEmpty; + }); } - - sendToController.text = content; - _address = content; - - _setValidAddressProviders(_address); - setState(() { - _addressToggleFlag = sendToController.text.isNotEmpty; - }); } } @@ -1968,389 +2011,3 @@ String formatAddress(String epicAddress) { } return epicAddress; } - -class QrCodeScannerDialog extends StatefulWidget { - final String walletId; - final Function(String) onQrCodeDetected; - - QrCodeScannerDialog({ - required this.walletId, - required this.onQrCodeDetected, - }); - - @override - _QrCodeScannerDialogState createState() => _QrCodeScannerDialogState(); -} - -class _QrCodeScannerDialogState extends State { - final CameraLinux? _cameraLinuxPlugin = - Platform.isLinux ? CameraLinux() : null; - final CameraWindows? _cameraWindowsPlugin = - Platform.isWindows ? CameraWindows() : null; - CameraMacOSController? _macOSController; - bool _isCameraOpen = false; - Image? _image; - bool _isScanning = false; - int _cameraId = -1; - String? _macOSDeviceId; - int _imageDelayInMs = 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 - }); - - if (Platform.isLinux && _cameraLinuxPlugin != null) { - await _cameraLinuxPlugin!.initializeCamera(); - Logging.instance.log("Linux Camera initialized", level: LogLevel.Info); - } else if (Platform.isWindows && _cameraWindowsPlugin != null) { - final List cameras = - 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( - camera, - const MediaSettings( - resolutionPreset: ResolutionPreset.low, - fps: 4, - videoBitrate: 200000, - enableAudio: false, - ), - ); - 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); - } else if (Platform.isMacOS) { - final List videoDevices = await CameraMacOS.instance - .listDevices(deviceType: CameraMacOSDeviceType.video); - if (videoDevices.isEmpty) { - throw Exception('No cameras available'); - } - _macOSDeviceId = videoDevices.first.deviceId; - 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. - } 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; - }); - } - } - } - - Future _stopCamera() async { - try { - if (Platform.isLinux && _cameraLinuxPlugin != null) { - _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); - // } else { - // Logging.instance.log("Windows Camera ID is null. Cannot dispose.", - // level: LogLevel.Error); - // } - } else if (Platform.isMacOS) { - // if (_macOSDeviceId != null) { - await CameraMacOS.instance.stopImageStream(); - 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); - // } - } - } 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) { - try { - String? base64Image; - if (Platform.isLinux && _cameraLinuxPlugin != null) { - base64Image = await _cameraLinuxPlugin!.captureImage(); - } else if (Platform.isWindows) { - final XFile xfile = - await _cameraWindowsPlugin!.takePicture(_cameraId); - final bytes = await xfile.readAsBytes(); - base64Image = base64Encode(bytes); - // We could use a Uint8List to optimize for Windows and macOS. - } else if (Platform.isMacOS) { - final macOSimg = await CameraMacOS.instance.takePicture(); - if (macOSimg == null) { - Logging.instance - .log("Failed to capture image", level: LogLevel.Error); - 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)); - continue; - } - base64Image = base64Encode(Uint8List.fromList(img.encodePng(image))); - } - if (base64Image == null || base64Image.isEmpty) { - // Logging.instance - // .log("Failed to capture image", level: LogLevel.Error); - // Spammy. - await Future.delayed(Duration(milliseconds: _imageDelayInMs)); - continue; - } - final img.Image? image = img.decodeImage(base64Decode(base64Image)); - // TODO [prio=low]: Optimize this process. Docs say: - // > WARNING Since this will check the image data against all known - // > 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)); - continue; - } - - if (mounted) { - setState(() { - _image = Image.memory( - base64Decode(base64Image!), - fit: BoxFit.cover, - ); - }); - } - - final String? scanResult = await _scanImage(image); - if (scanResult != null && scanResult.isNotEmpty) { - widget.onQrCodeDetected(scanResult); - if (mounted) { - Navigator.of(context).pop(); - } - break; - } else { - // Logging.instance.log("No QR code found in the image", level: LogLevel.Info); - // if (mounted) { - // widget.onSnackbar("No QR code found in the image."); - // } - // Spammy. - } - - await Future.delayed(Duration(milliseconds: _imageDelayInMs)); - } catch (e, s) { - // Logging.instance.log("Failed to capture and scan image: $e\n$s", level: LogLevel.Error); - // Spammy. - - // if (mounted) { - // widget.onSnackbar( - // "Error capturing or scanning the image. Please try again."); - // } - } - } - } - - Future _scanImage(img.Image image) async { - try { - final LuminanceSource source = RGBLuminanceSource( - image.width, - image.height, - image - .convert(numChannels: 4) - .getBytes(order: img.ChannelOrder.abgr) - .buffer - .asInt32List(), - ); - final BinaryBitmap bitmap = - BinaryBitmap(GlobalHistogramBinarizer(source)); - - final QRCodeReader reader = QRCodeReader(); - final qrDecode = reader.decode(bitmap); - if (qrDecode.text.isEmpty) { - return null; - } - return qrDecode.text; - } catch (e, s) { - // Logging.instance.log("Failed to decode QR code: $e\n$s", level: LogLevel.Error); - // Spammy. - return null; - } - } - - @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( - 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 (result == null || result.files.single.path == null) { - await showDialog( - context: context, - builder: (_) => const StackOkDialog( - title: "Error scanning QR code", - message: "No file selected.", - ), - ); - return; - } - - final filePath = result?.files.single.path!; - if (filePath == null) { - await showDialog( - context: context, - builder: (_) => const StackOkDialog( - title: "Error scanning QR code", - message: "Error selecting file.", - ), - ); - return; - } - try { - final img.Image? image = - img.decodeImage(File(filePath!).readAsBytesSync()); - if (image == null) { - await showDialog( - context: context, - builder: (_) => const StackOkDialog( - title: "Error scanning QR code", - message: "Failed to decode image.", - ), - ); - return; - } - - final String? scanResult = await _scanImage(image); - if (scanResult != null && scanResult.isNotEmpty) { - widget.onQrCodeDetected(scanResult); - Navigator.of(context).pop(); - } else { - await showDialog( - context: context, - builder: (_) => const StackOkDialog( - title: "Error scanning QR code", - message: "No QR code found in the image.", - ), - ); - } - } catch (e, s) { - Logging.instance.log("Failed to decode image: $e\n$s", - level: LogLevel.Error); - await showDialog( - context: context, - builder: (_) => const StackOkDialog( - title: "Error scanning QR code", - message: - "Error processing the image. Please try again.", - ), - ); - } - }, - ), - const SizedBox(width: 16), - // Close button. - PrimaryButton( - buttonHeight: ButtonHeight.l, - label: "Close", - width: 272.5, - onPressed: () { - _stopCamera(); - Navigator.of(context).pop(); - }, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 97f2ec965..869aa5895 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -10,10 +10,21 @@ import 'dart:convert'; -import 'logger.dart'; +import '../app_config.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; class AddressUtils { + static final Set recognizedParams = { + 'amount', + 'label', + 'message', + 'tx_amount', // For Monero/Wownero. + 'tx_payment_id', + 'recipient_name', + 'tx_description', + // TODO [prio=med]: Add more recognized params for other coins. + }; + static String condenseAddress(String address) { return '${address.substring(0, 5)}...${address.substring(address.length - 5)}'; } @@ -170,42 +181,150 @@ class AddressUtils { // // } // } - /// parse an address uri - /// returns an empty map if the input string does not begin with "firo:" + /// Return only recognized parameters. + static Map filterParams(Map params) { + return Map.fromEntries(params.entries + .where((entry) => recognizedParams.contains(entry.key.toLowerCase()))); + } + + /// Parses a URI string and returns a map with parsed components. static Map parseUri(String uri) { final Map result = {}; try { final u = Uri.parse(uri); if (u.hasScheme) { result["scheme"] = u.scheme.toLowerCase(); - result["address"] = u.path; - result.addAll(u.queryParameters); + + // Handle different URI formats. + if (result["scheme"] == "bitcoin" || + result["scheme"] == "bitcoincash") { + result["address"] = u.path; + } else if (result["scheme"] == "monero") { + // Monero addresses can contain '?' which Uri.parse interprets as query start. + final addressEnd = + uri.indexOf('?', 7); // 7 is the length of "monero:". + if (addressEnd != -1) { + result["address"] = uri.substring(7, addressEnd); + } else { + result["address"] = uri.substring(7); + } + } else { + // Default case, treat path as address. + result["address"] = u.path; + } + + // Parse query parameters. + result.addAll(_parseQueryParameters(u.queryParameters)); + + // Handle Monero-specific fragment (tx_description). + if (u.fragment.isNotEmpty && result["scheme"] == "monero") { + result["tx_description"] = Uri.decodeComponent(u.fragment); + } } } catch (e) { - Logging.instance - .log("Exception caught in parseUri($uri): $e", level: LogLevel.Error); + print("Exception caught in parseUri($uri): $e"); } return result; } - /// builds a uri string with the given address and query parameters if any + /// Helper method to parse and normalize query parameters. + static Map _parseQueryParameters(Map params) { + final Map result = {}; + params.forEach((key, value) { + final lowerKey = key.toLowerCase(); + if (recognizedParams.contains(lowerKey)) { + switch (lowerKey) { + case 'amount': + case 'tx_amount': + result['amount'] = _normalizeAmount(value); + break; + case 'label': + case 'recipient_name': + result['label'] = Uri.decodeComponent(value); + break; + case 'message': + case 'tx_description': + result['message'] = Uri.decodeComponent(value); + break; + case 'tx_payment_id': + result['tx_payment_id'] = Uri.decodeComponent(value); + break; + default: + result[lowerKey] = Uri.decodeComponent(value); + } + } else { + // Include unrecognized parameters as-is. + result[key] = Uri.decodeComponent(value); + } + }); + return result; + } + + /// Normalizes amount value to a standard format. + static String _normalizeAmount(String amount) { + // Remove any non-numeric characters except for '.' + final sanitized = amount.replaceAll(RegExp(r'[^\d.]'), ''); + // Ensure only one decimal point + final parts = sanitized.split('.'); + if (parts.length > 2) { + return '${parts[0]}.${parts.sublist(1).join()}'; + } + return sanitized; + } + + /// Centralized method to handle various cryptocurrency URIs and return a common object. + static PaymentUriData parsePaymentUri(String uri) { + final Map parsedData = parseUri(uri); + + // Normalize the URI scheme. + final String scheme = parsedData['scheme'] ?? ''; + parsedData.remove('scheme'); + + // Determine the coin type based on the URI scheme. + final CryptoCurrency coin = _getCryptoCurrencyByScheme(scheme); + + // Filter out unrecognized parameters. + final filteredParams = filterParams(parsedData); + + return PaymentUriData( + coin: coin, + address: parsedData['address'] ?? '', + amount: filteredParams['amount'] ?? filteredParams['tx_amount'], + label: filteredParams['label'] ?? filteredParams['recipient_name'], + message: filteredParams['message'] ?? filteredParams['tx_description'], + paymentId: filteredParams['tx_payment_id'], // Specific to Monero + additionalParams: filteredParams, + ); + } + + /// Builds a uri string with the given address and query parameters (if any) static String buildUriString( - CryptoCurrency coin, + String scheme, String address, Map params, ) { - // TODO: other sanitation as well ? - String sanitizedAddress = address; - if (coin is Bitcoincash || coin is Ecash) { - final prefix = "${coin.uriScheme}:"; - if (address.startsWith(prefix)) { - sanitizedAddress = address.replaceFirst(prefix, ""); + // Filter unrecognized parameters. + final filteredParams = filterParams(params); + String uriString = "$scheme:$address"; + + if (scheme.toLowerCase() == "monero") { + // Handle Monero-specific formatting. + if (filteredParams.containsKey("tx_description")) { + final description = filteredParams.remove("tx_description")!; + if (filteredParams.isNotEmpty) { + uriString += Uri(queryParameters: filteredParams).toString(); + } + uriString += "#${Uri.encodeComponent(description)}"; + } else if (filteredParams.isNotEmpty) { + uriString += Uri(queryParameters: filteredParams).toString(); + } + } else { + // General case for other cryptocurrencies. + if (filteredParams.isNotEmpty) { + uriString += Uri(queryParameters: filteredParams).toString(); } } - String uriString = "${coin.uriScheme}:$sanitizedAddress"; - if (params.isNotEmpty) { - uriString += Uri(queryParameters: params).toString(); - } + return uriString; } @@ -215,10 +334,7 @@ class AddressUtils { try { result = Map.from(jsonDecode(data) as Map); } catch (e) { - Logging.instance.log( - "Exception caught in parseQRSeedData($data): $e", - level: LogLevel.Error, - ); + print("Exception caught in parseQRSeedData($data): $e"); } return result; } @@ -227,4 +343,53 @@ class AddressUtils { static String encodeQRSeedData(List words) { return jsonEncode({"mnemonic": words}); } + + /// Method to get CryptoCurrency based on URI scheme. + static CryptoCurrency _getCryptoCurrencyByScheme(String scheme) { + if (AppConfig.coins.map((e) => e.uriScheme).toSet().contains(scheme)) { + return AppConfig.coins.firstWhere((e) => e.uriScheme == scheme); + } else { + throw UnsupportedError('Unsupported URI scheme: $scheme'); + } + } + + /// Formats an address string to remove any unnecessary prefixes or suffixes. + String formatAddress(String epicAddress) { + // strip http:// or https:// prefixes if the address contains an @ symbol (and is thus an epicbox address) + if ((epicAddress.startsWith("http://") || + epicAddress.startsWith("https://")) && + epicAddress.contains("@")) { + epicAddress = epicAddress.replaceAll("http://", ""); + epicAddress = epicAddress.replaceAll("https://", ""); + } + // strip mailto: prefix + if (epicAddress.startsWith("mailto:")) { + epicAddress = epicAddress.replaceAll("mailto:", ""); + } + // strip / suffix if the address contains an @ symbol (and is thus an epicbox address) + if (epicAddress.endsWith("/") && epicAddress.contains("@")) { + epicAddress = epicAddress.substring(0, epicAddress.length - 1); + } + return epicAddress; + } +} + +class PaymentUriData { + final CryptoCurrency coin; + final String address; + final String? amount; + final String? label; + final String? message; + final String? paymentId; // Specific to Monero. + final Map additionalParams; + + PaymentUriData({ + required this.coin, + required this.address, + this.amount, + this.label, + this.message, + this.paymentId, + required this.additionalParams, + }); } diff --git a/lib/utilities/test_monero_node_connection.dart b/lib/utilities/test_monero_node_connection.dart index b1f590e41..aef26ba9a 100644 --- a/lib/utilities/test_monero_node_connection.dart +++ b/lib/utilities/test_monero_node_connection.dart @@ -150,7 +150,7 @@ Future testMoneroNodeConnection( final response = await request.close(); final result = await response.transform(utf8.decoder).join(); - print("HTTP Response: $result"); + // print("HTTP Response: $result"); final success = result.contains('"result":') && !result.contains('"error"'); diff --git a/lib/utilities/test_node_connection.dart b/lib/utilities/test_node_connection.dart index 3bcc61f1d..0155710f0 100644 --- a/lib/utilities/test_node_connection.dart +++ b/lib/utilities/test_node_connection.dart @@ -119,7 +119,7 @@ Future testNodeConnection({ final url = formData.host!; final uri = Uri.tryParse(url); if (uri != null) { - if (!uri.hasScheme) { + if (!uri.hasScheme && !uri.host.endsWith(".onion")) { // try https first testPassed = await _xmrHelper( formData @@ -141,16 +141,6 @@ Future testNodeConnection({ proxyInfo, ); } - } else if (!uri.hasScheme && uri.host.endsWith(".onion")) { - // We can just test http for onion addresses. - testPassed = await _xmrHelper( - formData - ..host = url - ..useSSL = false, - context, - onSuccess, - proxyInfo, - ); } else { testPassed = await _xmrHelper( formData diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index 736f0fb05..beb377229 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -84,6 +84,66 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { _credentials = web3.EthPrivateKey.fromHex(privateKey); } + TxData _prepareTempTx(TxData txData, String myAddress) { + // hack eth tx data into inputs and outputs + final List outputs = []; + final List inputs = []; + + final amount = txData.recipients!.first.amount; + final addressTo = txData.recipients!.first.address; + + final OutputV2 output = OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: amount.raw.toString(), + addresses: [ + addressTo, + ], + walletOwns: addressTo == myAddress, + ); + final InputV2 input = InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [myAddress], + valueStringSats: amount.raw.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ); + + outputs.add(output); + inputs.add(input); + + final otherData = { + "nonce": txData.nonce, + "isCancelled": false, + "overrideFee": txData.fee!.toJsonString(), + }; + + final txn = TransactionV2( + walletId: walletId, + blockHash: null, + hash: txData.txHash!, + txid: txData.txid!, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: null, + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + version: -1, + type: addressTo == myAddress + ? TransactionType.sentToSelf + : TransactionType.outgoing, + subType: TransactionSubType.none, + otherData: jsonEncode(otherData), + ); + + return txData.copyWith( + tempTx: txn, + ); + } + // ==================== Overrides ============================================ @override @@ -447,7 +507,10 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { } @override - Future confirmSend({required TxData txData}) async { + Future confirmSend({ + required TxData txData, + TxData Function(TxData txData, String myAddress)? prepareTempTx, + }) async { final client = getEthClient(); if (_credentials == null) { await _initCredentials(); @@ -459,10 +522,15 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { chainId: txData.chainId!.toInt(), ); - return txData.copyWith( - txid: txid, - txHash: txid, + final data = (prepareTempTx ?? _prepareTempTx)( + txData.copyWith( + txid: txid, + txHash: txid, + ), + (await getCurrentReceivingAddress())!.value, ); + + return await updateSentCachedTxData(txData: data); } @override diff --git a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart index 3be7b8edb..ad6e31d79 100644 --- a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart @@ -39,7 +39,7 @@ class EthTokenWallet extends Wallet { late web3dart.DeployedContract _deployedContract; late web3dart.ContractFunction _sendFunction; - static const _gasLimit = 200000; + static const _gasLimit = 65000; // =========================================================================== @@ -67,6 +67,67 @@ class EthTokenWallet extends Wallet { String _addressFromTopic(String topic) => checksumEthereumAddress("0x${topic.substring(topic.length - 40)}"); + TxData _prepareTempTx(TxData txData, String myAddress) { + final otherData = { + "nonce": txData.nonce!, + "isCancelled": false, + "overrideFee": txData.fee!.toJsonString(), + "contractAddress": tokenContract.address, + }; + + final amount = txData.recipients!.first.amount; + final addressTo = txData.recipients!.first.address; + + // hack eth tx data into inputs and outputs + final List outputs = []; + final List inputs = []; + + final output = OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: "00", + valueStringSats: amount.raw.toString(), + addresses: [ + addressTo, + ], + walletOwns: addressTo == myAddress, + ); + final input = InputV2.isarCantDoRequiredInDefaultConstructor( + scriptSigHex: null, + scriptSigAsm: null, + sequence: null, + outpoint: null, + addresses: [myAddress], + valueStringSats: amount.raw.toString(), + witness: null, + innerRedeemScriptAsm: null, + coinbase: null, + walletOwns: true, + ); + + outputs.add(output); + inputs.add(input); + + final tempTx = TransactionV2( + walletId: walletId, + blockHash: null, + hash: txData.txHash!, + txid: txData.txid!, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + height: null, + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + version: -1, + type: addressTo == myAddress + ? TransactionType.sentToSelf + : TransactionType.outgoing, + subType: TransactionSubType.ethToken, + otherData: jsonEncode(otherData), + ); + + return txData.copyWith( + tempTx: tempTx, + ); + } + // =========================================================================== @override @@ -205,7 +266,10 @@ class EthTokenWallet extends Wallet { @override Future confirmSend({required TxData txData}) async { try { - return await ethWallet.confirmSend(txData: txData); + return await ethWallet.confirmSend( + txData: txData, + prepareTempTx: _prepareTempTx, + ); } catch (e) { // rethrow to pass error in alert rethrow; diff --git a/lib/widgets/desktop/qr_code_scanner_dialog.dart b/lib/widgets/desktop/qr_code_scanner_dialog.dart new file mode 100644 index 000000000..0df458b48 --- /dev/null +++ b/lib/widgets/desktop/qr_code_scanner_dialog.dart @@ -0,0 +1,401 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:camera_linux/camera_linux.dart'; +import 'package:camera_macos/camera_macos_arguments.dart'; +import 'package:camera_macos/camera_macos_device.dart'; +import 'package:camera_macos/camera_macos_platform_interface.dart'; +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_windows/camera_windows.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/desktop/secondary_button.dart'; +import 'package:zxing2/qrcode.dart'; + +import '../../notifications/show_flush_bar.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import 'desktop_dialog.dart'; +import 'desktop_dialog_close_button.dart'; + +class QrCodeScannerDialog extends StatefulWidget { + final Function(String) onQrCodeDetected; + + QrCodeScannerDialog({ + required this.onQrCodeDetected, + }); + + @override + _QrCodeScannerDialogState createState() => _QrCodeScannerDialogState(); +} + +class _QrCodeScannerDialogState extends State { + final CameraLinux? _cameraLinuxPlugin = + Platform.isLinux ? CameraLinux() : null; + final CameraWindows? _cameraWindowsPlugin = + Platform.isWindows ? CameraWindows() : null; + bool _isCameraOpen = false; + Image? _image; + bool _isScanning = false; + int _cameraId = -1; + String? _macOSDeviceId; + final int _imageDelayInMs = 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 + }); + + if (Platform.isLinux && _cameraLinuxPlugin != null) { + await _cameraLinuxPlugin!.initializeCamera(); + Logging.instance.log("Linux Camera initialized", level: LogLevel.Info); + } else if (Platform.isWindows && _cameraWindowsPlugin != null) { + final List cameras = + 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( + camera, + const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 4, + videoBitrate: 200000, + enableAudio: false, + ), + ); + 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); + } else if (Platform.isMacOS) { + final List videoDevices = await CameraMacOS.instance + .listDevices(deviceType: CameraMacOSDeviceType.video); + if (videoDevices.isEmpty) { + throw Exception('No cameras available'); + } + _macOSDeviceId = videoDevices.first.deviceId; + 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. + } 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; + }); + } + } + } + + Future _stopCamera() async { + try { + if (Platform.isLinux && _cameraLinuxPlugin != null) { + _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); + // } else { + // Logging.instance.log("Windows Camera ID is null. Cannot dispose.", + // level: LogLevel.Error); + // } + } else if (Platform.isMacOS) { + // if (_macOSDeviceId != null) { + await CameraMacOS.instance.stopImageStream(); + 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); + // } + } + } 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) { + try { + String? base64Image; + if (Platform.isLinux && _cameraLinuxPlugin != null) { + base64Image = await _cameraLinuxPlugin!.captureImage(); + } else if (Platform.isWindows) { + final XFile xfile = + await _cameraWindowsPlugin!.takePicture(_cameraId); + final bytes = await xfile.readAsBytes(); + base64Image = base64Encode(bytes); + // We could use a Uint8List to optimize for Windows and macOS. + } else if (Platform.isMacOS) { + final macOSimg = await CameraMacOS.instance.takePicture(); + if (macOSimg == null) { + Logging.instance + .log("Failed to capture image", level: LogLevel.Error); + 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)); + continue; + } + base64Image = base64Encode(img.encodePng(image)); + } + if (base64Image == null || base64Image.isEmpty) { + // Logging.instance + // .log("Failed to capture image", level: LogLevel.Error); + // Spammy. + await Future.delayed(Duration(milliseconds: _imageDelayInMs)); + continue; + } + final img.Image? image = img.decodeImage(base64Decode(base64Image)); + // TODO [prio=low]: Optimize this process. Docs say: + // > WARNING Since this will check the image data against all known + // > 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)); + continue; + } + + if (mounted) { + setState(() { + _image = Image.memory( + base64Decode(base64Image!), + fit: BoxFit.cover, + ); + }); + } + + final String? scanResult = await _scanImage(image); + if (scanResult != null && scanResult.isNotEmpty) { + widget.onQrCodeDetected(scanResult); + if (mounted) { + Navigator.of(context).pop(); + } + break; + } else { + // Logging.instance.log("No QR code found in the image", level: LogLevel.Info); + // if (mounted) { + // widget.onSnackbar("No QR code found in the image."); + // } + // Spammy. + } + + await Future.delayed(Duration(milliseconds: _imageDelayInMs)); + } catch (e, s) { + // Logging.instance.log("Failed to capture and scan image: $e\n$s", level: LogLevel.Error); + // Spammy. + + // if (mounted) { + // widget.onSnackbar( + // "Error capturing or scanning the image. Please try again."); + // } + } + } + } + + Future _scanImage(img.Image image) async { + try { + final LuminanceSource source = RGBLuminanceSource( + image.width, + image.height, + image + .convert(numChannels: 4) + .getBytes(order: img.ChannelOrder.abgr) + .buffer + .asInt32List(), + ); + final BinaryBitmap bitmap = + BinaryBitmap(GlobalHistogramBinarizer(source)); + + final QRCodeReader reader = QRCodeReader(); + final qrDecode = reader.decode(bitmap); + if (qrDecode.text.isEmpty) { + return null; + } + return qrDecode.text; + } catch (e, s) { + // Logging.instance.log("Failed to decode QR code: $e\n$s", level: LogLevel.Error); + // Spammy. + return null; + } + } + + @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( + 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 (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) { + await showFloatingFlushBar( + type: FlushBarType.info, + message: "Failed to decode image.", + 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 { + 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); + 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(); + }, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/textfields/frost_step_field.dart b/lib/widgets/textfields/frost_step_field.dart index f805dc64a..d6e6b0cb8 100644 --- a/lib/widgets/textfields/frost_step_field.dart +++ b/lib/widgets/textfields/frost_step_field.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:barcode_scan2/barcode_scan2.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,6 +10,7 @@ import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../conditional_parent.dart'; +import '../desktop/qr_code_scanner_dialog.dart'; import '../icon_widgets/clipboard_icon.dart'; import '../icon_widgets/qrcode_icon.dart'; import '../icon_widgets/x_icon.dart'; @@ -71,6 +74,50 @@ class _FrostStepFieldState extends State { super.initState(); } + Future scanQr() async { + try { + if (Platform.isAndroid || Platform.isIOS) { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed( + const Duration(milliseconds: 75), + ); + } + + final qrResult = await BarcodeScanner.scan(); + + widget.controller.text = qrResult.rawContent; + + _changed(widget.controller.text); + } else { + // Platform.isLinux, Platform.isWindows, or Platform.isMacOS. + 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); + } + }, + ); + }, + ); + } + } on PlatformException catch (e, s) { + Logging.instance.log( + "Failed to get camera permissions while trying to scan qr code: $e\n$s", + level: LogLevel.Warning, + ); + } + } + @override Widget build(BuildContext context) { return ConditionalParent( @@ -150,27 +197,7 @@ class _FrostStepFieldState extends State { semanticsLabel: "Scan QR Button. Opens Camera For Scanning QR Code.", key: _qrKey, - onTap: () async { - try { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 75), - ); - } - - final qrResult = await BarcodeScanner.scan(); - - widget.controller.text = qrResult.rawContent; - - _changed(widget.controller.text); - } on PlatformException catch (e, s) { - Logging.instance.log( - "Failed to get camera permissions while trying to scan qr code: $e\n$s", - level: LogLevel.Warning, - ); - } - }, + onTap: scanQr, child: const QrCodeIcon(), ), ], diff --git a/scripts/app_config/templates/windows/CMakeLists.txt b/scripts/app_config/templates/windows/CMakeLists.txt index 52af5ebd2..795e592f2 100644 --- a/scripts/app_config/templates/windows/CMakeLists.txt +++ b/scripts/app_config/templates/windows/CMakeLists.txt @@ -92,8 +92,8 @@ install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmonero/s install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmonero/scripts/monero_c/release/wownero/x86_64-w64-mingw32_libwallet2_api_c.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" RENAME "wownero_libwallet2_api_c.dll" COMPONENT Runtime) -install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmonero/scripts/monero_c/release/wownero/x86_64-w64-mingw32_libgcc_s_seh-1.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" RENAME "libgcc_s_seh-1.dll" - COMPONENT Runtime) +#install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmonero/scripts/monero_c/release/wownero/x86_64-w64-mingw32_libgcc_s_seh-1.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" RENAME "libgcc_s_seh-1.dll" +# COMPONENT Runtime) install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmonero/scripts/monero_c/release/wownero/x86_64-w64-mingw32_libpolyseed.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" RENAME "libpolyseed.dll" COMPONENT Runtime) @@ -101,8 +101,8 @@ install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmonero/s install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmonero/scripts/monero_c/release/wownero/x86_64-w64-mingw32_libssp-0.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" RENAME "libssp-0.dll" COMPONENT Runtime) -install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmonero/scripts/monero_c/release/wownero/x86_64-w64-mingw32_libstdc++-6.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" RENAME "libstdc++-6.dll" - COMPONENT Runtime) +#install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmonero/scripts/monero_c/release/wownero/x86_64-w64-mingw32_libstdc++-6.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" RENAME "libstdc++-6.dll" +# COMPONENT Runtime) install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../crypto_plugins/flutter_libmonero/scripts/monero_c/release/wownero/x86_64-w64-mingw32_libwinpthread-1.dll" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" RENAME "libwinpthread-1.dll" COMPONENT Runtime)