CW-1045-Ledger-Bugs-Enhancements (#2278)

* feat: prepare ledger.dart to use callbacks

* feat: set ledger callback using monero_c

* fix(cw_monero): async ledger

* build: Bump monero_c dependencies

* feat: Add "How to connect" to HW Device selection screen

* refactor: use monero_c free to clean pointer

* fix: use new monero_c deps

* fix: merge conflicts regarding new theming

* feat: add status bottomsheet indicating an ongoing signing process

* fix: getLastLedgerCommand monero.dart generation

* reformat send_view_model.dart [skip ci]

---------

Co-authored-by: Czarek Nakamoto <cyjan@mrcyjanek.net>
Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
Konstantin Ullrich 2025-05-26 17:38:51 +02:00 committed by GitHub
parent e03dcc7fe9
commit 90aee053cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 660 additions and 315 deletions

View file

@ -421,6 +421,7 @@ class CWMonero extends Monero {
moneroWallet.setLedgerConnection(connection);
}
@override
void resetLedgerConnection() {
disableLedgerExchange();
}
@ -428,9 +429,11 @@ class CWMonero extends Monero {
@override
void setGlobalLedgerConnection(ledger.LedgerConnection connection) {
gLedger = connection;
keepAlive(connection);
}
@override
String? getLastLedgerCommand() => latestLedgerCommand;
bool isViewOnly() {
return isViewOnlyBySpendKey(null);
}
@ -439,5 +442,4 @@ class CWMonero extends Monero {
Map<String, List<int>> debugCallLength() {
return monero_wallet_api.debugCallLength();
}
}

View file

@ -5,7 +5,9 @@ import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/connect_device/widgets/device_tile.dart';
import 'package:cake_wallet/src/widgets/bottom_sheet/info_steps_bottom_sheet_widget.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/themes/core/material_base_theme.dart';
import 'package:cake_wallet/utils/responsive_layout_util.dart';
import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart';
import 'package:cw_core/utils/print_verbose.dart';
@ -60,6 +62,7 @@ class ConnectDevicePage extends BasePage {
onConnectDevice,
allowChangeWallet,
ledgerVM,
currentTheme,
));
}
@ -68,12 +71,14 @@ class ConnectDevicePageBody extends StatefulWidget {
final OnConnectDevice onConnectDevice;
final bool allowChangeWallet;
final LedgerViewModel ledgerVM;
final MaterialThemeBase currentTheme;
const ConnectDevicePageBody(
this.walletType,
this.onConnectDevice,
this.allowChangeWallet,
this.ledgerVM,
this.currentTheme,
);
@override
@ -179,109 +184,126 @@ class ConnectDevicePageBodyState extends State<ConnectDevicePageBody> {
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint,
height: double.infinity,
padding: EdgeInsets.symmetric(vertical: 24, horizontal: 24),
child: SingleChildScrollView(
child: Column(
children: [
Padding(
padding: EdgeInsets.only(left: 20, right: 20, bottom: 20),
child: Text(
Platform.isIOS
? S.of(context).connect_your_hardware_wallet_ios
: S.of(context).connect_your_hardware_wallet,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
Offstage(
offstage: !longWait,
child: Padding(
padding: EdgeInsets.only(left: 20, right: 20, bottom: 20),
child: Text(
S.of(context).if_you_dont_see_your_device,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
Observer(
builder: (_) => Offstage(
offstage: widget.ledgerVM.bleIsEnabled,
child: Padding(
return Container(
width: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint,
padding: EdgeInsets.symmetric(vertical: 24, horizontal: 24),
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
Padding(
padding: EdgeInsets.only(left: 20, right: 20, bottom: 20),
child: Text(
S.of(context).ledger_please_enable_bluetooth,
style: Theme.of(context).textTheme.titleMedium,
Platform.isIOS
? S.of(context).connect_your_hardware_wallet_ios
: S.of(context).connect_your_hardware_wallet,
style: Theme.of(context)
.textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
Offstage(
offstage: !longWait,
child: Padding(
padding: EdgeInsets.only(left: 20, right: 20, bottom: 20),
child: Text(
S.of(context).if_you_dont_see_your_device,
style: Theme.of(context)
.textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
Observer(
builder: (_) => Offstage(
offstage: widget.ledgerVM.bleIsEnabled,
child: Padding(
padding:
EdgeInsets.only(left: 20, right: 20, bottom: 20),
child: Text(
S.of(context).ledger_please_enable_bluetooth,
style: Theme.of(context)
.textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
),
if (bleDevices.length > 0) ...[
Padding(
padding: EdgeInsets.only(left: 20, right: 20, bottom: 20),
child: Container(
width: double.infinity,
child: Text(
S.of(context).bluetooth,
style: Theme.of(context)
.textTheme.bodyMedium,
),
),
),
...bleDevices
.map(
(device) => Padding(
padding: EdgeInsets.only(bottom: 20),
child: DeviceTile(
onPressed: () => _connectToDevice(device),
title: device.name,
leading: _getDeviceTileLeading(device.deviceInfo),
connectionType: device.connectionType,
),
),
)
.toList()
],
if (usbDevices.length > 0) ...[
Padding(
padding: EdgeInsets.only(left: 20, right: 20, bottom: 20),
child: Container(
width: double.infinity,
child: Text(
S.of(context).usb,
style: Theme.of(context)
.textTheme.bodyMedium,
),
),
),
...usbDevices
.map(
(device) => Padding(
padding: EdgeInsets.only(bottom: 20),
child: DeviceTile(
onPressed: () => _connectToDevice(device),
title: device.name,
leading: _getDeviceTileLeading(device.deviceInfo),
connectionType: device.connectionType,
),
),
)
.toList(),
],
if (widget.allowChangeWallet) ...[
PrimaryButton(
text: S.of(context).wallets,
color: Theme.of(context)
.colorScheme.primary,
textColor: Theme.of(context)
.colorScheme.onPrimary,
onPressed: _onChangeWallet,
)
],
],
),
if (bleDevices.length > 0) ...[
Padding(
padding: EdgeInsets.only(left: 20, right: 20, bottom: 20),
child: Container(
width: double.infinity,
child: Text(
S.of(context).bluetooth,
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
...bleDevices
.map(
(device) => Padding(
padding: EdgeInsets.only(bottom: 20),
child: DeviceTile(
onPressed: () => _connectToDevice(device),
title: device.name,
leading: _getDeviceTileLeading(device.deviceInfo),
connectionType: device.connectionType,
),
),
)
.toList()
],
if (usbDevices.length > 0) ...[
Padding(
padding: EdgeInsets.only(left: 20, right: 20, bottom: 20),
child: Container(
width: double.infinity,
child: Text(
S.of(context).usb,
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
...usbDevices
.map(
(device) => Padding(
padding: EdgeInsets.only(bottom: 20),
child: DeviceTile(
onPressed: () => _connectToDevice(device),
title: device.name,
leading: _getDeviceTileLeading(device.deviceInfo),
connectionType: device.connectionType,
),
),
)
.toList(),
],
if (widget.allowChangeWallet) ...[
PrimaryButton(
text: S.of(context).wallets,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
onPressed: _onChangeWallet,
)
],
],
),
),
),
PrimaryButton(
text: S.of(context).how_to_connect,
color: Theme.of(context).colorScheme.surfaceContainer,
textColor: Theme.of(context).colorScheme.onSecondaryContainer,
onPressed: () => _onHowToConnect(context),
)
],
),
);
}
@ -293,4 +315,21 @@ class ConnectDevicePageBodyState extends State<ConnectDevicePageBody> {
.pushNamedAndRemoveUntil(Routes.dashboard, (route) => false),
);
}
void _onHowToConnect(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (BuildContext bottomSheetContext) => InfoStepsBottomSheet(
titleText: S.of(context).how_to_connect,
currentTheme: widget.currentTheme,
steps: [
InfoStep('${S.of(context).step} 1', S.of(context).connect_hw_info_step_1),
InfoStep('${S.of(context).step} 2', S.of(context).connect_hw_info_step_2),
InfoStep('${S.of(context).step} 3', S.of(context).connect_hw_info_step_3),
InfoStep('${S.of(context).step} 4', S.of(context).connect_hw_info_step_4),
],
),
);
}
}

View file

@ -684,29 +684,47 @@ class SendPage extends BasePage {
});
}
if (state is IsAwaitingDeviceResponseState) {
if (state is IsDeviceSigningResponseState) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
showModalBottomSheet<void>(
context: context,
isDismissible: false,
builder: (BuildContext bottomSheetContext) => InfoBottomSheet(
currentTheme: currentTheme,
titleText: S.of(bottomSheetContext).proceed_on_device,
contentImage: 'assets/images/hardware_wallet/ledger_nano_x.png',
contentImageColor: Theme.of(context).colorScheme.onSurface,
content: S.of(bottomSheetContext).proceed_on_device_description,
isTwoAction: false,
actionButtonText: S.of(context).cancel,
actionButton: () {
sendViewModel.state = InitialExecutionState();
Navigator.of(bottomSheetContext).pop();
},
),
builder: (context) {
dialogContext = context;
return LoadingBottomSheet(titleText: S.of(context).device_is_signing);
},
);
});
}
if (state is IsAwaitingDeviceResponseState) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
showModalBottomSheet<void>(
context: context,
isDismissible: false,
builder: (context) {
dialogContext = context;
return InfoBottomSheet(
currentTheme: currentTheme,
titleText: S.of(context).proceed_on_device,
contentImage:
'assets/images/hardware_wallet/ledger_nano_x.png',
contentImageColor: Theme.of(context).colorScheme.onSurface,
content: S.of(context).proceed_on_device_description,
isTwoAction: false,
actionButtonText: S.of(context).cancel,
actionButton: () {
sendViewModel.state = InitialExecutionState();
Navigator.of(context).pop();
},
);
});
});
}
});
_effectsInstalled = true;

View file

@ -0,0 +1,114 @@
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/themes/core/material_base_theme.dart';
import 'package:flutter/material.dart';
import 'base_bottom_sheet_widget.dart';
class InfoStep {
final String title;
final String description;
const InfoStep(this.title, this.description);
}
class InfoStepsBottomSheet extends BaseBottomSheet {
final MaterialThemeBase currentTheme;
final List<InfoStep> steps;
InfoStepsBottomSheet({
required String titleText,
required this.steps,
String? titleIconPath,
required this.currentTheme,
}) : super(titleText: titleText, titleIconPath: titleIconPath);
@override
Widget contentWidget(BuildContext context) => SizedBox(
height: 500,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
children: steps
.map((step) => Container(
margin: EdgeInsets.only(
bottom: 15, left: 20, right: 20),
padding: EdgeInsets.all(10),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Theme.of(context).cardColor,
),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Padding(
padding: EdgeInsets.only(left: 16),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment:
MainAxisAlignment.start,
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
step.title,
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(
fontSize: 20,
fontWeight: FontWeight.w500,
color: Theme.of(context)
.colorScheme
.onSurface,
),
),
Padding(
padding: EdgeInsets.only(top: 5),
child: Text(
step.description,
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
)
],
),
),
)
],
),
))
.toList(),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: PrimaryButton(
text: S.of(context).close,
color: Theme.of(context).colorScheme.primary,
textColor: currentTheme.isDark
? Theme.of(context).colorScheme.onSurfaceVariant
: Theme.of(context).colorScheme.onPrimary,
onPressed: () => Navigator.of(context).pop(),
),
)
],
),
);
@override
Widget footerWidget(BuildContext context) => SizedBox.shrink();
}

View file

@ -15,8 +15,8 @@ import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/evm_transaction_error_fees_handler.dart';
import 'package:cake_wallet/entities/fiat_currency.dart';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/entities/template.dart';
import 'package:cake_wallet/entities/preferences_key.dart';
import 'package:cake_wallet/entities/template.dart';
import 'package:cake_wallet/entities/transaction_description.dart';
import 'package:cake_wallet/entities/wallet_contact.dart';
import 'package:cake_wallet/ethereum/ethereum.dart';
@ -293,19 +293,18 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
.toList();
@computed
bool get hasCoinControl =>
wallet.type == WalletType.bitcoin ||
wallet.type == WalletType.litecoin ||
wallet.type == WalletType.monero ||
wallet.type == WalletType.wownero ||
wallet.type == WalletType.decred ||
wallet.type == WalletType.bitcoinCash;
bool get hasCoinControl => [
WalletType.bitcoin,
WalletType.litecoin,
WalletType.monero,
WalletType.wownero,
WalletType.decred,
WalletType.bitcoinCash
].contains(wallet.type);
@computed
bool get isElectrumWallet =>
wallet.type == WalletType.bitcoin ||
wallet.type == WalletType.litecoin ||
wallet.type == WalletType.bitcoinCash;
[WalletType.bitcoin, WalletType.litecoin, WalletType.bitcoinCash].contains(wallet.type);
@observable
CryptoCurrency selectedCryptoCurrency;
@ -444,12 +443,23 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
}
}
Timer? _ledgerTxStateTimer;
@action
Future<PendingTransaction?> createTransaction({ExchangeProvider? provider}) async {
try {
if (!(state is IsExecutingState)) state = IsExecutingState();
if (wallet.isHardwareWallet) state = IsAwaitingDeviceResponseState();
if (wallet.isHardwareWallet) {
state = IsAwaitingDeviceResponseState();
if (walletType == WalletType.monero)
_ledgerTxStateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
if (monero!.getLastLedgerCommand() == "INS_CLSAG") {
timer.cancel();
state = IsDeviceSigningResponseState();
}
});
}
pendingTransaction = await wallet.createTransaction(_credentials(provider));
@ -475,6 +485,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
state = ExecutedSuccessfullyState();
return pendingTransaction;
} catch (e) {
_ledgerTxStateTimer?.cancel();
// if (e is LedgerException) {
// final errorCode = e.errorCode.toRadixString(16);
// final fallbackMsg =
@ -592,7 +603,8 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
));
}
final sharedPreferences = await SharedPreferences.getInstance();
await sharedPreferences.setString(PreferencesKey.backgroundSyncLastTrigger(wallet.name), DateTime.now().add(Duration(minutes: 1)).toIso8601String());
await sharedPreferences.setString(PreferencesKey.backgroundSyncLastTrigger(wallet.name),
DateTime.now().add(Duration(minutes: 1)).toIso8601String());
state = TransactionCommitted();
} catch (e) {
state = FailureState(translateErrorMessage(e, wallet.type, wallet.currency));

View file

@ -1,5 +1,6 @@
import 'package:cake_wallet/core/execution_state.dart';
class IsDeviceSigningResponseState extends IsExecutingState {}
class IsAwaitingDeviceResponseState extends IsExecutingState {}
class TransactionCommitting extends ExecutionState {}
class TransactionCommitted extends ExecutionState {}