diff --git a/.maestro/handbook/24-settings-delete-wallet.yaml b/.maestro/handbook/24-settings-delete-wallet.yaml index 18c695bc9..df26d14d0 100644 --- a/.maestro/handbook/24-settings-delete-wallet.yaml +++ b/.maestro/handbook/24-settings-delete-wallet.yaml @@ -3,13 +3,13 @@ # this flow's diagnostic capture lands in build/handbook-captures/24-settings-delete-wallet.png. # Continues from 23-settings-contact (no clearState). The contact page is # pushed on top of the Settings menu, so we tap back to the menu, then open -# "Geschäftsbeziehung beenden". That entry does not navigate to a new page — +# "Abmelden". That entry does not navigate to a new page — # it presents the SettingsConfirmLogoutWalletSheet modal, the confirmation -# step for terminating the business relationship and signing out of the -# wallet. This flow documents that sheet: it stops on the modal and does NOT +# step for logging out, which removes the wallet from the device and returns +# to onboarding. This flow documents that sheet: it stops on the modal and does NOT # tick the confirmation checkbox or tap "Abmelden". # -# Both taps (back to the Settings menu, then opening the terminate entry) are +# Both taps (back to the Settings menu, then opening the logout entry) are # gated on their target not yet showing and re-tapped if one was silently # dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, # mobile-dev-inc/maestro#3137). Each gate stops its loop the instant the @@ -36,7 +36,7 @@ appId: swiss.realunit.app text: '.*Aus REALU Wallet abmelden.*' commands: - tapOn: - text: '.*Geschäftsbeziehung beenden.*' + text: '.*Abmelden.*' optional: true - waitForAnimationToEnd - extendedWaitUntil: diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 6a596d22c..5f9c8675e 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -53,6 +53,8 @@ "connectBitboxContent": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone.", "connectBitboxContentIos": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone und aktivieren Sie zusätzlich Bluetooth.", "connectBitboxFailed": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "connectBitboxNotInitialized": "Auf dieser BitBox ist noch keine Wallet eingerichtet. Bitte richten Sie über die BitBox-App eine Wallet auf dem Gerät ein oder stellen Sie eine wieder her und versuchen Sie es erneut.", + "connectBitboxNotInitializedTitle": "BitBox noch nicht eingerichtet", "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.", "connectBitboxSignatureCapturing": "Bitte bestätigen Sie die Anmeldeanfrage auf Ihrem BitBox-Gerät. Diese Signatur wird einmalig erfasst, damit künftige Käufe Ihre BitBox nicht erneut benötigen.", "connectBitboxSignatureCapturingTitle": "Anmeldung bestätigen", @@ -186,6 +188,7 @@ "pinVerify": "Geben Sie Ihre PIN ein", "pinVerifyDescription": "Geben Sie Ihre PIN ein, um Ihre Wallet zu entsperren", "pinVerifyFailed": "Die PIN ist falsch. Versuchen Sie es erneut.", + "pinVerifying": "Anmeldung…", "pinVerifyLocked": "Zu viele Fehlversuche. Nutzen Sie 'PIN vergessen?', um zurückzusetzen.", "pinVerifyLockedTemporarily": "Zu viele Fehlversuche. Versuchen Sie es in ${remaining} erneut.", "pinVerifySeedDescription": "Geben Sie Ihre PIN ein, um Ihre Seed-Phrase anzuzeigen", @@ -272,7 +275,7 @@ "settingsCurrency": "Währung", "settingsCurrencyLoadFailed": "Währungsliste konnte nicht geladen werden", "settingsCurrencyLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", - "settingsDeleteWallet": "Geschäftsbeziehung beenden", + "settingsDeleteWallet": "Abmelden", "settingsLanguageLoadFailed": "Sprachliste konnte nicht geladen werden", "settingsLanguageLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", "settingsLanguages": "Sprachen", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 52406e865..fdce5a692 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -53,6 +53,8 @@ "connectBitboxContent": "Please connect your BitBox with your Smartphone.", "connectBitboxContentIos": "Please connect your BitBox with your Smartphone and activate Bluetooth.", "connectBitboxFailed": "Something went wrong. Please try to connect again.", + "connectBitboxNotInitialized": "This BitBox has no wallet set up yet. Set up or restore a wallet on the device using the BitBox app, then try again.", + "connectBitboxNotInitializedTitle": "BitBox not set up yet", "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.", "connectBitboxSignatureCapturing": "Please confirm the sign-in request on your BitBox device. This signature is captured once so future purchases won't need your BitBox again.", "connectBitboxSignatureCapturingTitle": "Confirm sign-in", @@ -186,6 +188,7 @@ "pinVerify": "Enter your pin", "pinVerifyDescription": "Enter your PIN to unlock your wallet", "pinVerifyFailed": "PIN is wrong. Try again.", + "pinVerifying": "Signing in…", "pinVerifyLocked": "Too many failed attempts. Use 'Forgot PIN?' to reset.", "pinVerifyLockedTemporarily": "Too many failed attempts. Try again in ${remaining}.", "pinVerifySeedDescription": "Enter your PIN to view your seed phrase", @@ -272,7 +275,7 @@ "settingsCurrency": "Currency", "settingsCurrencyLoadFailed": "Failed to load currencies", "settingsCurrencyLoadFailedDescription": "Please check your internet connection and try again.", - "settingsDeleteWallet": "Terminate business relationship", + "settingsDeleteWallet": "Logout", "settingsLanguageLoadFailed": "Failed to load languages", "settingsLanguageLoadFailedDescription": "Please check your internet connection and try again.", "settingsLanguages": "Languages", diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index 85887157f..2c190fe32 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -1337,7 +1337,7 @@

06Einstellungen

Vollständige Settings-Liste: Sprachen, Währung, Netzwerk, Steuerbericht, Nutzerdaten, Rechtsdokumente, Kontakt, Wallet-Adresse, Wallet-Sicherung, - Geschäftsbeziehung beenden. Aktive Auswahlen (Sprache, Währung, Netzwerk) + Abmelden. Aktive Auswahlen (Sprache, Währung, Netzwerk) werden inline als trailing Text rechts angezeigt. Die Kachel Wallet-Sicherung erscheint nur bei einer Software-Wallet (Re-Display der BIP-39-Recovery-Phrase) — bei einer BitBox ist sie @@ -1503,12 +1503,12 @@

06Einstellungen

Geschäftsbeziehung beenden — Bestätigung
Das Bestätigungs-Sheet „Aus REALU Wallet abmelden", ausgelöst über - den Einstellungs-Eintrag Geschäftsbeziehung beenden. Der User muss + den Einstellungs-Eintrag Abmelden. Der User muss per Checkbox bestätigen, dass er seine Wiederherstellungsphrase gesichert hat — erst dann wird der Abmelden-Button aktiv. Abmelden löscht die Wallet vom Gerät und führt zurück ins Onboarding. diff --git a/lib/packages/hardware_wallet/bitbox.dart b/lib/packages/hardware_wallet/bitbox.dart index 1e5caa6cd..0a6e9a6a9 100644 --- a/lib/packages/hardware_wallet/bitbox.dart +++ b/lib/packages/hardware_wallet/bitbox.dart @@ -129,6 +129,13 @@ class BitboxService { if (!didVerify) throw Exception('Failed to verify'); } + /// The paired device's firmware status (`uninitialized` / `seeded` / + /// `initialized`). Read after pairing to tell a device with no wallet set up + /// (`uninitialized` — cannot derive an address) apart from a ready device. + /// Delegates to the plugin's cached-status read, so there is no device + /// round-trip and it cannot block. + Future getDeviceStatus() => bitboxManager.getDeviceStatus(); + /// Derives the wallet's ETH address from the device, retrying transient empty /// reads before giving up. /// diff --git a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart index 96da7838a..0774d1120 100644 --- a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart +++ b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart @@ -24,6 +24,19 @@ class ConnectBitboxCubit extends Cubit { // walked away and the device-side ephemeral noise channel has died. static const Duration _defaultPairingPinTimeout = Duration(seconds: 120); + // The bitbox02 firmware status for a device that has no wallet set up — it + // cannot derive an address. Mirrors the SDK's `StatusUninitialized`. Only this + // value is treated as "not set up": other non-ready statuses (e.g. a firmware + // upgrade requirement) are intentionally left on the existing failure path + // rather than mislabelled as unseeded. + static const _statusUninitialized = 'uninitialized'; + + // The status read is a local cached lookup (no device round-trip), so it + // returns in milliseconds. This cap only exists so a hypothetical stall can + // never hang the pairing flow — on timeout the read is treated as "not + // uninitialized" (fail-open) and the normal acquire path proceeds. + static const Duration _deviceStatusTimeout = Duration(seconds: 5); + ConnectBitboxCubit( this._service, this._walletService, @@ -155,15 +168,18 @@ class ConnectBitboxCubit extends Cubit { 'Disconnect the device, restart the app, and re-pair.', ), ); - final wallet = await _acquireWalletOrDefault().timeout( - _createWalletTimeout, - onTimeout: () => throw TimeoutException( - 'BitBox did not return an ETH address within ' - '${_createWalletTimeout.inSeconds}s. Try disconnecting and re-pairing.', - ), - ); - _service.startConnectionStatusObserver(); - await _captureAuthSignature(wallet); + // A device paired without a wallet set up has no seed, so the address read + // below would come back empty and fail as a generic error — bouncing the + // user into the silent re-scan loop with no idea why. Detect it up front + // and surface a dedicated state. Returning here (instead of throwing) means + // no re-scan timer is armed, so the device isn't picked up and re-paired in + // an endless loop. + if (await _isDeviceUninitialized()) { + if (isClosed) return; + emit(BitboxNotInitialized(currentState.device)); + return; + } + await _acquireWalletAndConnect(); } catch (e) { developer.log(e.toString(), name: '$ConnectBitboxCubit'); _pendingInit = null; @@ -173,6 +189,63 @@ class ConnectBitboxCubit extends Cubit { } } + /// Reads the device status, treating ONLY a clean, explicit `uninitialized` + /// read as "no wallet set up". Any failure or unexpected value returns false + /// (fail-open), so a status read can never block a device that would otherwise + /// pair successfully — it only ever adds the dedicated unseeded path on top of + /// the existing behaviour, never removes the working one. + Future _isDeviceUninitialized() async { + try { + final status = await _service.getDeviceStatus().timeout(_deviceStatusTimeout); + return status == _statusUninitialized; + } catch (e) { + developer.log( + 'device status read failed/timed out, treating device as ready: $e', + name: '$ConnectBitboxCubit', + ); + return false; + } + } + + /// Acquires the wallet from the device and finishes the connection. Shared by + /// the initial pairing flow and the [recheckDeviceStatus] retry so both run + /// the same create/observe/sign sequence. + Future _acquireWalletAndConnect() async { + final wallet = await _acquireWalletOrDefault().timeout( + _createWalletTimeout, + onTimeout: () => throw TimeoutException( + 'BitBox did not return an ETH address within ' + '${_createWalletTimeout.inSeconds}s. Try disconnecting and re-pairing.', + ), + ); + _service.startConnectionStatusObserver(); + await _captureAuthSignature(wallet); + } + + /// Re-reads the device status after a [BitboxNotInitialized], for when the user + /// has set up / restored a wallet on the device and wants to continue without + /// re-pairing. If the device now reports a wallet, the connection proceeds; if + /// it is still unseeded, the state is re-emitted so the user can try again. + Future recheckDeviceStatus() async { + final currentState = state; + if (currentState is! BitboxNotInitialized) return; + try { + if (await _isDeviceUninitialized()) { + if (isClosed) return; + emit(BitboxNotInitialized(currentState.device)); + return; + } + if (isClosed) return; + emit(BitboxPairing(currentState.device)); + await _acquireWalletAndConnect(); + } catch (e) { + developer.log(e.toString(), name: '$ConnectBitboxCubit'); + if (isClosed) return; + emit(BitboxNotConnected()); + _checkForTimer = Timer.periodic(const Duration(milliseconds: 500), (_) => checkForBitbox()); + } + } + /// Captures and caches the auth signature as an awaited, user-guided step of /// the pairing flow. The BitBox is guaranteed connected here, so every later /// buy / KYC / user-data call can run off the cached signature without diff --git a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart index 8384e478d..7e679463b 100644 --- a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart +++ b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart @@ -24,6 +24,15 @@ class BitboxPairing extends BitboxFound { BitboxPairing(super.device); } +/// The paired BitBox has no wallet set up yet (firmware status `uninitialized`), +/// so no address can be derived. A dedicated state — not the generic failure — +/// so the UI can tell the user to set up / restore a wallet on the device first, +/// instead of bouncing through the silent re-scan loop. Carries the device so a +/// re-check can continue the connection without re-pairing. +class BitboxNotInitialized extends BitboxFound { + BitboxNotInitialized(super.device); +} + class BitboxCapturingSignature extends BitboxConnectionState { final BitboxWallet wallet; diff --git a/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart b/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart index ee72a39fb..7f6731330 100644 --- a/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart +++ b/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart @@ -121,6 +121,20 @@ class ConnectBitboxView extends StatelessWidget { ], ), ), + BitboxNotInitialized() => ConnectContent( + title: S.of(context).connectBitboxNotInitializedTitle, + imagePath: 'assets/images/illustrations/bitbox_connect.svg', + onConfirm: () => context.read().recheckDeviceStatus(), + onCancel: onCancel ?? context.pop, + confirmLabel: S.of(context).retry, + child: Text( + S.of(context).connectBitboxNotInitialized, + textAlign: .center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + ), BitboxCapturingSignature() => ConnectContent( title: S.of(context).connectBitboxSignatureCapturingTitle, imagePath: 'assets/images/illustrations/bitbox_connected.svg', diff --git a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart index 1019dc79c..a6cd9161b 100644 --- a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart +++ b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart @@ -15,10 +15,8 @@ part 'kyc_link_wallet_state.dart'; class KycLinkWalletCubit extends Cubit { final RealUnitRegistrationService _registrationService; - KycLinkWalletCubit( - RealUnitRegistrationService registrationService, - RealUnitUserDataDto userData, - ) : _registrationService = registrationService, + KycLinkWalletCubit(RealUnitRegistrationService registrationService, RealUnitUserDataDto userData) + : _registrationService = registrationService, super(KycLinkWalletReady(userData)); Future submit(RealUnitUserDataDto userData) async { @@ -26,10 +24,14 @@ class KycLinkWalletCubit extends Cubit { emit(KycLinkWalletSubmitting(userData)); await _registrationService.registerWallet(userData); emit(const KycLinkWalletSuccess()); - } on BitboxNotConnectedException catch (e) { - emit(KycLinkWalletFailure(e.toString(), cause: e)); + } on BitboxNotConnectedException { + emit(KycLinkWalletBitboxRequired(userData)); } catch (e) { emit(KycLinkWalletFailure(e.toString(), cause: e)); } } + + /// Re-runs registration after the BitBox connection was established via the + /// `ConnectBitboxPage` sheet. Mirror of `KycRegistrationSubmitCubit.retrySubmit`. + Future retrySubmit(RealUnitUserDataDto userData) => submit(userData); } diff --git a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart index 848237276..b68a822a4 100644 --- a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart +++ b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart @@ -38,3 +38,18 @@ class KycLinkWalletFailure extends KycLinkWalletState { @override List get props => [message, cause]; } + +/// Emitted when `registerWallet` aborts because the BitBox is not connected. +/// The page reacts by opening the `ConnectBitboxPage` sheet and, on a +/// successful connection, calls `retrySubmit`. Carries `userData` so the page +/// renders the idle confirm body underneath the sheet (and again if the user +/// dismisses it without connecting). Mirror of +/// `KycRegistrationSubmitBitboxRequired`. +class KycLinkWalletBitboxRequired extends KycLinkWalletState { + final RealUnitUserDataDto userData; + + const KycLinkWalletBitboxRequired(this.userData); + + @override + List get props => [userData]; +} diff --git a/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart b/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart index 79f82e729..1a854efa4 100644 --- a/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart +++ b/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart @@ -1,10 +1,13 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_page.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart'; import 'package:realunit_wallet/setup/di.dart'; @@ -27,10 +30,7 @@ class KycLinkWalletPage extends StatelessWidget { return const _LinkWalletMissingUserDataPage(); } return BlocProvider( - create: (_) => KycLinkWalletCubit( - getIt(), - data, - ), + create: (_) => KycLinkWalletCubit(getIt(), data), child: const KycLinkWalletView(), ); } @@ -45,8 +45,10 @@ class KycLinkWalletView extends StatelessWidget { appBar: AppBar(title: Text(S.of(context).kycLinkWalletTitle)), body: BlocConsumer( listenWhen: (_, current) => - current is KycLinkWalletSuccess || current is KycLinkWalletFailure, - listener: (context, state) { + current is KycLinkWalletSuccess || + current is KycLinkWalletFailure || + current is KycLinkWalletBitboxRequired, + listener: (context, state) async { if (state is KycLinkWalletSuccess) { // Re-fetch routing state from the API. The wallet is now in the // Aktionariat share register, so `getRegistrationInfo` will return @@ -62,6 +64,26 @@ class KycLinkWalletView extends StatelessWidget { ), ); } + if (state is KycLinkWalletBitboxRequired) { + // No BitBox connected when registering the wallet — open the + // shared connect sheet instead of dead-ending on an error, then + // retry registration once the device is linked. Mirror of + // `KycRegistrationPage`. + final userData = state.userData; + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => ConnectBitboxPage( + onFinish: (wallet) { + context.read().add(SyncWalletServicesEvent(wallet)); + context.pop(true); + }, + ), + ); + if (context.mounted && result == true) { + context.read().retrySubmit(userData); + } + } }, builder: (context, state) => switch (state) { KycLinkWalletReady(:final userData) => _LinkWalletBody( @@ -72,6 +94,13 @@ class KycLinkWalletView extends StatelessWidget { userData: userData, isSubmitting: true, ), + // BitBox required: the listener drives the connect sheet on top of + // this frame; render the idle confirm body so the user lands back on + // the submit button if they dismiss the sheet without connecting. + KycLinkWalletBitboxRequired(:final userData) => _LinkWalletBody( + userData: userData, + isSubmitting: false, + ), // Success: the Bloc listener kicks off `checkKyc` which transitions // the parent KycCubit, so this branch is a transient frame. Render // the spinner to avoid a flash of the form. @@ -104,14 +133,8 @@ class _LinkWalletBody extends StatelessWidget { style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), - _LinkWalletInfoRow( - label: S.of(context).name, - value: userData.name, - ), - _LinkWalletInfoRow( - label: S.of(context).walletAddress, - value: walletAddress, - ), + _LinkWalletInfoRow(label: S.of(context).name, value: userData.name), + _LinkWalletInfoRow(label: S.of(context).walletAddress, value: walletAddress), const Spacer(), AppFilledButton( state: isSubmitting ? FilledButtonState.loading : FilledButtonState.idle, @@ -142,14 +165,9 @@ class _LinkWalletInfoRow extends StatelessWidget { children: [ Text( label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: RealUnitColors.neutral500, - ), - ), - Text( - value, - style: Theme.of(context).textTheme.bodyLarge, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: RealUnitColors.neutral500), ), + Text(value, style: Theme.of(context).textTheme.bodyLarge), ], ); } diff --git a/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart b/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart index 8a43a36b5..f6efcbb8a 100644 --- a/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart +++ b/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart @@ -33,18 +33,27 @@ class VerifyPinCubit extends Cubit { } Future checkPin() async { - final isCorrect = await _secureStorage.verifyPin(state.pin); - if (isCorrect) { - if (enableLockout) await _secureStorage.resetPinLockout(); - emit(const VerifyPinSuccess()); - } else { - if (!enableLockout) { - emit(const VerifyPinFailure(failedAttempts: 0)); - return; + final pin = state.pin; + final previousAttempts = state.failedAttempts; + emit(VerifyPinVerifying(pin: pin, failedAttempts: previousAttempts)); + try { + final isCorrect = await _secureStorage.verifyPin(pin); + if (isCorrect) { + if (enableLockout) await _secureStorage.resetPinLockout(); + emit(const VerifyPinSuccess()); + } else { + if (!enableLockout) { + emit(const VerifyPinFailure(failedAttempts: 0)); + return; + } + final attempts = await _secureStorage.getPinFailedAttempts() + 1; + await _secureStorage.setPinFailedAttempts(attempts); + await _emitLockState(attempts); } - final attempts = await _secureStorage.getPinFailedAttempts() + 1; - await _secureStorage.setPinFailedAttempts(attempts); - await _emitLockState(attempts); + } catch (_) { + // A hash/storage failure must not strand the user on the spinner with no + // number pad. Restore the input so they can retry instead of dead-ending. + emit(VerifyPinState(failedAttempts: previousAttempts)); } } diff --git a/lib/screens/pin/bloc/verify_pin/verify_pin_state.dart b/lib/screens/pin/bloc/verify_pin/verify_pin_state.dart index b36f1c08e..4c4e5d9c2 100644 --- a/lib/screens/pin/bloc/verify_pin/verify_pin_state.dart +++ b/lib/screens/pin/bloc/verify_pin/verify_pin_state.dart @@ -18,6 +18,10 @@ class VerifyPinState extends Equatable { List get props => [pin, failedAttempts]; } +class VerifyPinVerifying extends VerifyPinState { + const VerifyPinVerifying({required super.pin, super.failedAttempts}); +} + class VerifyPinSuccess extends VerifyPinState { const VerifyPinSuccess() : super(pin: ''); } diff --git a/lib/screens/pin/verify_pin_page.dart b/lib/screens/pin/verify_pin_page.dart index 9b2d9384f..246655627 100644 --- a/lib/screens/pin/verify_pin_page.dart +++ b/lib/screens/pin/verify_pin_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/generated/i18n.dart'; @@ -98,6 +99,9 @@ class _VerifyPinViewState extends State { }, builder: (context, state) { final isLocked = state is VerifyPinTemporarilyLocked || state is VerifyPinLocked; + // Covers both the PIN-hash check (VerifyPinVerifying) and the subsequent + // wallet load that runs after success while this screen is still on top. + final isVerifying = state is VerifyPinVerifying || state is VerifyPinSuccess; return Scaffold( appBar: AppBar(), @@ -139,7 +143,7 @@ class _VerifyPinViewState extends State { spacing: 16.0, children: [ PinIndicator( - pinLength: state.pin.length, + pinLength: isVerifying ? pinLength : state.pin.length, expectedPinLength: pinLength, wrongPin: state is VerifyPinFailure || isLocked, ), @@ -174,13 +178,16 @@ class _VerifyPinViewState extends State { ), ), const Spacer(), - IgnorePointer( - ignoring: isLocked, - child: NumberPad( - onNumberPressed: context.read().addDigit, - onDeletePressed: context.read().deleteDigit, + if (isVerifying) + const _VerifyingIndicator() + else + IgnorePointer( + ignoring: isLocked, + child: NumberPad( + onNumberPressed: context.read().addDigit, + onDeletePressed: context.read().deleteDigit, + ), ), - ), if (widget.bottom != null) widget.bottom! else const SizedBox(height: 60.0), ], ), @@ -257,3 +264,27 @@ class _ForgotPinButton extends StatelessWidget { ), ); } + +class _VerifyingIndicator extends StatelessWidget { + const _VerifyingIndicator(); + + @override + Widget build(BuildContext context) => SizedBox( + // Matches the NumberPad footprint (4 rows of ~68px) so swapping it in for + // the spinner does not shift the PIN dots above it. + height: 272, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CupertinoActivityIndicator(radius: 16), + const SizedBox(height: 16), + Text( + S.of(context).pinVerifying, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + ], + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock index d8b9567bd..795c516be 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,8 +77,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v0.0.9" - resolved-ref: "6172a2e76ccb5f45a92a37e89f92ff224e0ca4d1" + ref: "v0.0.10" + resolved-ref: "cd99ce656410e8df6c6585076d6ee0205a67b34c" url: "https://github.com/DFXswiss/bitbox_flutter.git" source: git version: "0.0.1" diff --git a/pubspec.yaml b/pubspec.yaml index fce1f4d8a..6f5b347e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,7 +79,7 @@ dependencies: bitbox_flutter: git: url: https://github.com/DFXswiss/bitbox_flutter.git - ref: v0.0.9 + ref: v0.0.10 dev_dependencies: flutter_test: diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_bitbox_required.png b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_bitbox_required.png new file mode 100644 index 000000000..34d7ea649 Binary files /dev/null and b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_bitbox_required.png differ diff --git a/test/goldens/screens/kyc/kyc_link_wallet_golden_test.dart b/test/goldens/screens/kyc/kyc_link_wallet_golden_test.dart index a044ac53b..bde6d8507 100644 --- a/test/goldens/screens/kyc/kyc_link_wallet_golden_test.dart +++ b/test/goldens/screens/kyc/kyc_link_wallet_golden_test.dart @@ -13,8 +13,7 @@ import 'package:realunit_wallet/screens/kyc/steps/link_wallet/kyc_link_wallet_pa import '../../../helper/helper.dart'; -class _MockKycLinkWalletCubit extends MockCubit - implements KycLinkWalletCubit {} +class _MockKycLinkWalletCubit extends MockCubit implements KycLinkWalletCubit {} class _MockKycCubit extends MockCubit implements KycCubit {} @@ -83,5 +82,27 @@ void main() { ); }, ); + + // Locks the visual contract for the BitBox-required state: it must render + // the idle confirm body (so dismissing the connect sheet lands the user + // back on the submit button), never a spinner or error dead-end. The + // connect sheet itself is baselined separately under hardware_connect_bitbox. + goldenTest( + 'bitbox required renders the idle confirm body', + fileName: 'kyc_link_wallet_page_bitbox_required', + constraints: phoneConstraints, + builder: () { + when(() => linkCubit.state).thenReturn(const KycLinkWalletBitboxRequired(_userData)); + return wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: kycCubit), + BlocProvider.value(value: linkCubit), + ], + child: const KycLinkWalletView(), + ), + ); + }, + ); }); } diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_verifying.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_verifying.png new file mode 100644 index 000000000..3d4bc6d2a Binary files /dev/null and b/test/goldens/screens/pin/goldens/macos/verify_pin_page_verifying.png differ diff --git a/test/goldens/screens/pin/verify_pin_golden_test.dart b/test/goldens/screens/pin/verify_pin_golden_test.dart index 9d9f5329c..7fb1dece0 100644 --- a/test/goldens/screens/pin/verify_pin_golden_test.dart +++ b/test/goldens/screens/pin/verify_pin_golden_test.dart @@ -50,5 +50,25 @@ void main() { ), ), ); + + goldenTest( + 'verifying state replaces the number pad with a spinner', + fileName: 'verify_pin_page_verifying', + // CupertinoActivityIndicator never settles; pump once to capture the + // initial frame instead of letting pumpAndSettle hang. + pumpBeforeTest: pumpOnce, + constraints: const BoxConstraints.tightFor(width: 390, height: 844), + builder: () { + final cubit = _MockVerifyPinCubit(); + when(() => cubit.state).thenReturn(const VerifyPinVerifying(pin: '123456')); + when(() => cubit.checkBiometricAvailability()).thenAnswer((_) async {}); + return wrapForGolden( + BlocProvider.value( + value: cubit, + child: VerifyPinView(onAuthenticated: () {}), + ), + ); + }, + ); }); } diff --git a/test/goldens/screens/settings/goldens/macos/settings_page_bitbox.png b/test/goldens/screens/settings/goldens/macos/settings_page_bitbox.png index 11b41bae8..5063080f1 100644 Binary files a/test/goldens/screens/settings/goldens/macos/settings_page_bitbox.png and b/test/goldens/screens/settings/goldens/macos/settings_page_bitbox.png differ diff --git a/test/goldens/screens/settings/goldens/macos/settings_page_default.png b/test/goldens/screens/settings/goldens/macos/settings_page_default.png index 5ea315d3b..8a4dacff7 100644 Binary files a/test/goldens/screens/settings/goldens/macos/settings_page_default.png and b/test/goldens/screens/settings/goldens/macos/settings_page_default.png differ diff --git a/test/integration/link_wallet_connect_flow_test.dart b/test/integration/link_wallet_connect_flow_test.dart new file mode 100644 index 000000000..df16208d6 --- /dev/null +++ b/test/integration/link_wallet_connect_flow_test.dart @@ -0,0 +1,206 @@ +// Cross-layer integration tests for the BitBox-gated "add wallet" (link_wallet) +// KYC step. +// +// These stitch the real `KycLinkWalletCubit` together with a real +// `RealUnitRegistrationService`, the real `Eip712Signer`, a +// `FakeBitboxCredentials` standing in for the hardware-wallet sign boundary, +// and a `MockClient` HTTP boundary — only the device transport and the network +// are faked, the orchestration runs through production code. +// +// They pin the seam the unit/widget suites only cover in isolation: a +// disconnected BitBox must surface as `KycLinkWalletBitboxRequired` (never a +// dead-end) WITHOUT touching the API, and once the device connects, +// `retrySubmit` must drive `registerWallet` (sign + POST) to a completed +// registration. +// +// The pairing ceremony itself (scan / init / channel-hash / confirm) is the +// subject of `connect_bitbox_flow_test.dart`; here we flip +// `FakeBitboxCredentials.behavior` from `disconnect` to `success` to model +// "the user connected the BitBox via that ceremony". + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/config/network_mode.dart'; +import 'package:realunit_wallet/packages/repository/cache_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:realunit_wallet/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart'; + +import '../helper/fake_bitbox_credentials.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockAccount extends Mock implements AWalletAccount {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +class _MockWalletService extends Mock implements WalletService {} + +const _userData = RealUnitUserDataDto( + email: 'a@b.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress(street: 'Bahnhofstrasse', zip: '8000', city: 'Zurich', country: 41), + ), +); + +void main() { + late _MockAppStore appStore; + late _MockWallet wallet; + late _MockAccount account; + late _MockWalletService walletService; + late SessionCache session; + late FakeBitboxCredentials credentials; + + setUp(() { + appStore = _MockAppStore(); + wallet = _MockWallet(); + account = _MockAccount(); + walletService = _MockWalletService(); + session = SessionCache(_MockCacheRepository()); + session.setAuthToken('jwt-1'); + // signDelay zero keeps the ceremony synchronous-ish for tight assertions. + credentials = FakeBitboxCredentials(signDelay: Duration.zero); + + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.sessionCache).thenReturn(session); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.primaryAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(credentials); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + KycLinkWalletCubit buildCubit(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return KycLinkWalletCubit(RealUnitRegistrationService(appStore, walletService), _userData); + } + + group('$KycLinkWalletCubit × RealUnitRegistrationService × FakeBitboxCredentials', () { + test( + 'submit with a disconnected BitBox surfaces BitboxRequired and never reaches the API', + () async { + credentials.behavior = FakeBitboxBehavior.disconnect; + var posts = 0; + final client = MockClient((_) async { + posts++; + return http.Response(jsonEncode({'status': 'completed'}), 201); + }); + final cubit = buildCubit(client); + addTearDown(cubit.close); + + await cubit.submit(_userData); + + expect(cubit.state, isA()); + expect( + posts, + 0, + reason: 'the EIP-712 sign throws BitboxNotConnectedException before the POST', + ); + // The wallet is unlocked for the ceremony and re-locked in the finally + // even though signing throws. + verify(() => walletService.ensureCurrentWalletUnlocked()).called(1); + verify(() => walletService.lockCurrentWallet()).called(1); + }, + ); + + test( + 'retrySubmit after the BitBox connects signs and POSTs to /register/wallet → Success', + () async { + credentials.behavior = FakeBitboxBehavior.success; + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'status': 'completed'}), 201); + }); + final cubit = buildCubit(client); + addTearDown(cubit.close); + + await cubit.retrySubmit(_userData); + + expect(cubit.state, isA()); + expect(sentUri!.path, '/v1/realunit/register/wallet'); + expect(body!['walletAddress'], credentials.address.hexEip55); + // 65-byte ECDSA signature → 0x + 130 hex chars. + expect((body!['signature'] as String).length, 132); + expect((body!['registrationDate'] as String).length, 10); + }, + ); + + test( + 'end-to-end reconnect: disconnected submit → BitboxRequired → connect → retry → Success', + () async { + Uri? sentUri; + final client = MockClient((request) async { + sentUri = request.url; + return http.Response(jsonEncode({'status': 'completed'}), 201); + }); + final cubit = buildCubit(client); + addTearDown(cubit.close); + + final emitted = []; + final sub = cubit.stream.listen(emitted.add); + addTearDown(sub.cancel); + + // Phase 1 — no BitBox: the add-wallet attempt must park on the connect + // prompt, not fail. + credentials.behavior = FakeBitboxBehavior.disconnect; + await cubit.submit(_userData); + expect(cubit.state, isA()); + expect(sentUri, isNull, reason: 'no POST before the device can sign'); + + // Phase 2 — the user connects the BitBox (the pairing ceremony lives in + // connect_bitbox_flow_test); the same credentials instance now signs. + credentials.behavior = FakeBitboxBehavior.success; + await cubit.retrySubmit(_userData); + + expect(cubit.state, isA()); + expect(sentUri!.path, '/v1/realunit/register/wallet'); + // Flush the broadcast-stream queue so the final emit reaches the + // listener before we assert the full transition order. + await pumpEventQueue(); + expect( + emitted.map((s) => s.runtimeType).toList(), + [ + KycLinkWalletSubmitting, + KycLinkWalletBitboxRequired, + KycLinkWalletSubmitting, + KycLinkWalletSuccess, + ], + ); + // One throwing attempt + one successful attempt — the retry actually + // re-engaged the device rather than replaying a cached result. + expect(credentials.signCallCount, 2); + }, + ); + }); +} diff --git a/test/packages/hardware_wallet/bitbox_service_test.dart b/test/packages/hardware_wallet/bitbox_service_test.dart index 43ee21c83..b91074448 100644 --- a/test/packages/hardware_wallet/bitbox_service_test.dart +++ b/test/packages/hardware_wallet/bitbox_service_test.dart @@ -382,6 +382,26 @@ void main() { }); }); + test('getDeviceStatus returns the firmware status the device reports', () { + // Thin pass-through to the plugin's cached-status read. The cubit branches + // on this string after pairing to detect an unseeded device, so a + // method-name flip would silently break that gate on real hardware. + fakeAsync((async) { + final service = pairedServiceSync(async); + platform.when( + SimulatedBitboxMethod.getDeviceStatus, + (_) async => 'uninitialized', + ); + + String? status; + service.getDeviceStatus().then((value) => status = value); + async.flushMicrotasks(); + + expect(status, 'uninitialized'); + expect(platform.count(SimulatedBitboxMethod.getDeviceStatus), 1); + }); + }); + test('confirmPairing returns normally on a verified channel', () { // Happy path: user pressed the on-device button. fakeAsync((async) { diff --git a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart index 11b764c55..f419237d1 100644 --- a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart +++ b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart @@ -50,6 +50,10 @@ void main() { when(() => service.startScan()).thenAnswer((_) async => true); when(() => service.getAllUsbDevices()).thenAnswer((_) async => []); when(() => service.startConnectionStatusObserver()).thenReturn(null); + // Default to a device that has a wallet set up so the existing pairing + // tests reach the address-derivation path. The unseeded path is exercised + // explicitly below. + when(() => service.getDeviceStatus()).thenAnswer((_) async => 'initialized'); when(() => walletService.createBitboxWallet(any())).thenAnswer((_) async => wallet); when(() => wallet.currentAccount).thenReturn(_FakeBitboxWalletAccount()); when(() => authService.ensureSignatureFor(any())).thenAnswer((_) async {}); @@ -453,5 +457,151 @@ void main() { expect(cubit.state, isA()); verify(() => walletService.createBitboxWallet('Luke-Skywallet')).called(1); }); + + // A brand-new device with no wallet (firmware status `uninitialized`) cannot + // derive an address. It must surface BitboxNotInitialized — not the generic + // failure — and must NOT try to create a wallet or arm the re-scan timer that + // would re-pair the device in an endless loop. + test('emits BitboxNotInitialized when the device has no wallet set up', () async { + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenAnswer((_) async => 'uninitialized'); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + + expect(cubit.state, isA()); + verifyNever(() => walletService.createBitboxWallet(any())); + + // The state must be stable: no re-scan timer is armed, so the cubit does + // not bounce back through the connection flow on its own. + await Future.delayed(const Duration(milliseconds: 50)); + expect(cubit.state, isA()); + }); + + test('recheckDeviceStatus continues to BitboxConnected once a wallet is set up', () async { + var pollCount = 0; + var statusCalls = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenAnswer((_) async { + statusCalls++; + return statusCalls == 1 ? 'uninitialized' : 'initialized'; + }); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + expect(cubit.state, isA()); + + await cubit.recheckDeviceStatus(); + expect(cubit.state, isA()); + verify(() => walletService.createBitboxWallet(any())).called(1); + }); + + test( + 'recheckDeviceStatus stays in BitboxNotInitialized while the device is still unseeded', + () async { + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenAnswer((_) async => 'uninitialized'); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + expect(cubit.state, isA()); + + await cubit.recheckDeviceStatus(); + expect(cubit.state, isA()); + verifyNever(() => walletService.createBitboxWallet(any())); + }, + ); + + test('recheckDeviceStatus is a no-op when not in BitboxNotInitialized', () async { + final cubit = makeCubit(); + addTearDown(cubit.close); + + await cubit.recheckDeviceStatus(); + expect(cubit.state, isA()); + }); + + test('recheckDeviceStatus falls back to NotConnected when acquireWallet throws', () async { + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + // First call: device unseeded → BitboxNotInitialized. + // Second call (recheckDeviceStatus): device now seeded, but wallet + // acquisition fails → catch block must emit BitboxNotConnected. + var statusCalls = 0; + when(() => service.getDeviceStatus()).thenAnswer((_) async { + statusCalls++; + return statusCalls == 1 ? 'uninitialized' : 'initialized'; + }); + when(() => walletService.createBitboxWallet(any())).thenThrow(Exception('wallet boom')); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + expect(cubit.state, isA()); + + await cubit.recheckDeviceStatus(); + expect(cubit.state, isA()); + }); + + // Fail-open guarantee: a failing status read must NOT block a device that + // would otherwise pair. If getDeviceStatus throws, the flow falls through to + // the normal acquire path and still reaches BitboxConnected — the new gate + // can only ever ADD the unseeded path, never break the working one. + test('continues to BitboxConnected when the status read throws', () async { + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenThrow(Exception('status boom')); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + + expect(cubit.state, isA()); + verify(() => walletService.createBitboxWallet(any())).called(1); + }); }); } diff --git a/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart b/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart index 478ad0d0c..096c0ea0b 100644 --- a/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart +++ b/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart @@ -1,3 +1,4 @@ +import 'package:bitbox_flutter/bitbox_flutter.dart' as sdk; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -20,6 +21,8 @@ class _MockConnectBitboxCubit extends MockCubit class _MockBitboxWallet extends Mock implements BitboxWallet {} +class _FakeBitboxDevice extends Fake implements sdk.BitboxDevice {} + void main() { late _MockConnectBitboxCubit cubit; late _MockBitboxWallet wallet; @@ -105,6 +108,23 @@ void main() { verify(() => cubit.continueWithoutSignature()).called(1); }); + testWidgets('BitboxNotInitialized shows retry and cancel buttons', (tester) async { + final device = _FakeBitboxDevice(); + await pumpView(tester, BitboxNotInitialized(device)); + + expect(find.byType(AppFilledButton), findsNWidgets(2)); + }); + + testWidgets('BitboxNotInitialized retry button calls recheckDeviceStatus', (tester) async { + final device = _FakeBitboxDevice(); + when(() => cubit.recheckDeviceStatus()).thenAnswer((_) async {}); + await pumpView(tester, BitboxNotInitialized(device)); + + final buttons = tester.widgetList(find.byType(AppFilledButton)).toList(); + buttons[0].onPressed?.call(); + verify(() => cubit.recheckDeviceStatus()).called(1); + }); + testWidgets('ConnectContent honors confirmLabel and cancelLabel overrides', (tester) async { await tester.pumpApp( ConnectContent( diff --git a/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart b/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart index e67cf499b..5e1d1f50b 100644 --- a/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart +++ b/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart @@ -61,45 +61,74 @@ void main() { blocTest( 'registerWallet succeeds → Submitting → Success', setUp: () { - when(() => registrationService.registerWallet(any())) - .thenAnswer((_) async => RegistrationStatus.completed); + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => RegistrationStatus.completed); }, build: build, act: (c) => c.submit(_userData), - expect: () => [ - const KycLinkWalletSubmitting(_userData), - const KycLinkWalletSuccess(), - ], + expect: () => [const KycLinkWalletSubmitting(_userData), const KycLinkWalletSuccess()], verify: (_) { verify(() => registrationService.registerWallet(_userData)).called(1); }, ); blocTest( - 'registerWallet throws BitboxNotConnectedException → Failure', + 'registerWallet throws BitboxNotConnectedException → BitboxRequired', setUp: () { - when(() => registrationService.registerWallet(any())) - .thenThrow(const BitboxNotConnectedException()); + when( + () => registrationService.registerWallet(any()), + ).thenThrow(const BitboxNotConnectedException()); }, build: build, act: (c) => c.submit(_userData), expect: () => [ const KycLinkWalletSubmitting(_userData), - isA(), + const KycLinkWalletBitboxRequired(_userData), ], ); blocTest( 'registerWallet throws generic exception → Failure', setUp: () { - when(() => registrationService.registerWallet(any())) - .thenAnswer((_) async => throw Exception('network down')); + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => throw Exception('network down')); }, build: build, act: (c) => c.submit(_userData), + expect: () => [const KycLinkWalletSubmitting(_userData), isA()], + ); + }); + + group('retrySubmit', () { + blocTest( + 're-runs registration after the BitBox was connected → Submitting → Success', + setUp: () { + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => RegistrationStatus.completed); + }, + build: build, + act: (c) => c.retrySubmit(_userData), + expect: () => [const KycLinkWalletSubmitting(_userData), const KycLinkWalletSuccess()], + verify: (_) { + verify(() => registrationService.registerWallet(_userData)).called(1); + }, + ); + + blocTest( + 'still emits BitboxRequired on retry when the BitBox is still disconnected', + setUp: () { + when( + () => registrationService.registerWallet(any()), + ).thenThrow(const BitboxNotConnectedException()); + }, + build: build, + act: (c) => c.retrySubmit(_userData), expect: () => [ const KycLinkWalletSubmitting(_userData), - isA(), + const KycLinkWalletBitboxRequired(_userData), ], ); }); diff --git a/test/screens/kyc/steps/link_wallet/kyc_link_wallet_page_test.dart b/test/screens/kyc/steps/link_wallet/kyc_link_wallet_page_test.dart index 703ce9b33..543f0a8e1 100644 --- a/test/screens/kyc/steps/link_wallet/kyc_link_wallet_page_test.dart +++ b/test/screens/kyc/steps/link_wallet/kyc_link_wallet_page_test.dart @@ -1,23 +1,32 @@ +import 'package:bitbox_flutter/bitbox_flutter.dart' as sdk; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_page.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_view.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart'; import '../../../../helper/helper.dart'; -class _MockKycLinkWalletCubit extends MockCubit - implements KycLinkWalletCubit {} +class _MockKycLinkWalletCubit extends MockCubit implements KycLinkWalletCubit {} class _MockKycCubit extends MockCubit implements KycCubit {} @@ -25,6 +34,16 @@ class _MockAppStore extends Mock implements AppStore {} class _MockRealUnitRegistrationService extends Mock implements RealUnitRegistrationService {} +class _MockBitboxService extends Mock implements BitboxService {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockDfxKycService extends Mock implements DfxKycService {} + +class _MockHomeBloc extends MockBloc implements HomeBloc {} + +class _FakeBitboxWallet extends Fake implements BitboxWallet {} + const _kycData = KycPersonalData( accountType: KycAccountType.personal, firstName: 'Ada', @@ -54,9 +73,11 @@ const _debugAddress = '0xfaeefaeefaeefaeefaeefaeefaeefaeefaeeb6a0'; void main() { late _MockKycLinkWalletCubit linkCubit; late _MockKycCubit kycCubit; + late _MockHomeBloc homeBloc; setUpAll(() { registerFallbackValue(_userData); + registerFallbackValue(SyncWalletServicesEvent(_FakeBitboxWallet())); final getIt = GetIt.instance; final appStore = _MockAppStore(); @@ -65,6 +86,17 @@ void main() { getIt.registerSingleton( _MockRealUnitRegistrationService(), ); + + // ConnectBitboxPage (opened by the BitboxRequired listener) builds a real + // ConnectBitboxCubit off these getIt dependencies. An empty device list + // keeps the connect sheet parked in its idle scanning state, so the tests + // can assert the sheet wiring without driving the pairing ceremony. + final bitboxService = _MockBitboxService(); + when(() => bitboxService.getAllUsbDevices()).thenAnswer((_) async => []); + when(() => bitboxService.startScan()).thenAnswer((_) async => false); + getIt.registerSingleton(bitboxService); + getIt.registerSingleton(_MockWalletService()); + getIt.registerSingleton(_MockDfxKycService()); }); tearDownAll(() async => await GetIt.instance.reset()); @@ -72,6 +104,7 @@ void main() { setUp(() { linkCubit = _MockKycLinkWalletCubit(); kycCubit = _MockKycCubit(); + homeBloc = _MockHomeBloc(); when(() => linkCubit.state).thenReturn(const KycLinkWalletReady(_userData)); when(() => kycCubit.state).thenReturn(const KycInitial()); when(() => kycCubit.checkKyc()).thenAnswer((_) async {}); @@ -87,7 +120,9 @@ void main() { ); group('$KycLinkWalletView', () { - testWidgets('renders the userData name and the current wallet address in Ready', (tester) async { + testWidgets('renders the userData name and the current wallet address in Ready', ( + tester, + ) async { when(() => linkCubit.state).thenReturn(const KycLinkWalletReady(_userData)); await tester.pumpApp(buildSubject()); @@ -97,8 +132,9 @@ void main() { // 0x prefix is present and the address renders fully so a future change // that crops it (e.g. to "0xfaee…b6a0") would fail loud. expect( - find.byWidgetPredicate((w) => - w is Text && (w.data?.startsWith('0x') ?? false) && w.data!.length == 42), + find.byWidgetPredicate( + (w) => w is Text && (w.data?.startsWith('0x') ?? false) && w.data!.length == 42, + ), findsOne, ); }); @@ -150,6 +186,111 @@ void main() { }); }); + group('$KycLinkWalletView BitBox connect sheet', () { + // Drives the cubit from Ready to BitboxRequired so the page listener fires. + void emitBitboxRequired() => whenListen( + linkCubit, + Stream.fromIterable([const KycLinkWalletBitboxRequired(_userData)]), + initialState: const KycLinkWalletReady(_userData), + ); + + // Opens, then settles, the connect sheet. Popping the modal route disposes + // the real ConnectBitboxCubit, which cancels its periodic scan timer — so + // every test must close the sheet to avoid a pending-timer failure. + Future pumpUntilSheetOpen(WidgetTester tester) async { + await tester.pumpApp(buildSubject()); + await tester.pump(); // deliver BitboxRequired to the listener + await tester.pump(const Duration(milliseconds: 350)); // sheet open animation + } + + NavigatorState sheetNavigator(WidgetTester tester) => + Navigator.of(tester.element(find.byType(ConnectBitboxPage))); + + testWidgets('BitboxRequired opens the ConnectBitboxPage connect sheet', (tester) async { + emitBitboxRequired(); + + await pumpUntilSheetOpen(tester); + + expect(find.byType(ConnectBitboxPage), findsOneWidget); + + sheetNavigator(tester).pop(); + await tester.pumpAndSettle(); + }); + + testWidgets('a successful connect (sheet returns true) retries registration', (tester) async { + when(() => linkCubit.retrySubmit(any())).thenAnswer((_) async {}); + emitBitboxRequired(); + + await pumpUntilSheetOpen(tester); + expect(find.byType(ConnectBitboxPage), findsOneWidget); + + // ConnectBitboxView.onFinish pops the sheet with `true` once the device + // is linked; reproduce that result here. + sheetNavigator(tester).pop(true); + await tester.pumpAndSettle(); + + verify(() => linkCubit.retrySubmit(_userData)).called(1); + }); + + testWidgets('dismissing the sheet without connecting does not retry', (tester) async { + when(() => linkCubit.retrySubmit(any())).thenAnswer((_) async {}); + emitBitboxRequired(); + + await pumpUntilSheetOpen(tester); + expect(find.byType(ConnectBitboxPage), findsOneWidget); + + // Back button / scrim tap pops with a null result. + sheetNavigator(tester).pop(); + await tester.pumpAndSettle(); + + verifyNever(() => linkCubit.retrySubmit(any())); + }); + + testWidgets('onFinish syncs the wallet, pops true, and retries registration', (tester) async { + when(() => linkCubit.retrySubmit(any())).thenAnswer((_) async {}); + emitBitboxRequired(); + + // `onFinish` calls `context.pop(true)` (go_router), so host the view in a + // GoRouter stack; with the sheet route on top, canPop is true. + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: kycCubit), + BlocProvider.value(value: linkCubit), + BlocProvider.value(value: homeBloc), + ], + child: const KycLinkWalletView(), + ), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + localizationsDelegates: [S.delegate, GlobalMaterialLocalizations.delegate], + supportedLocales: S.delegate.supportedLocales, + ), + ); + await tester.pump(); // deliver BitboxRequired to the listener + await tester.pump(const Duration(milliseconds: 350)); // sheet open animation + expect(find.byType(ConnectBitboxView), findsOneWidget); + + // ConnectBitboxPage forwards the page's onFinish straight to the view; + // invoke it exactly as the connect flow does on BitboxFinishSetup. + final view = tester.widget(find.byType(ConnectBitboxView)); + view.onFinish(_FakeBitboxWallet()); + await tester.pumpAndSettle(); + + verify(() => homeBloc.add(any(that: isA()))).called(1); + verify(() => linkCubit.retrySubmit(_userData)).called(1); + }); + }); + group('$KycLinkWalletPage with missing userData', () { testWidgets( 'renders defensive page with a retry button when no userData is supplied', @@ -172,4 +313,20 @@ void main() { }, ); }); + + group('$KycLinkWalletPage with userData', () { + testWidgets('wires its own cubit from getIt and renders the confirm body', (tester) async { + await tester.pumpApp( + BlocProvider.value( + value: kycCubit, + child: const KycLinkWalletPage(userData: _userData), + ), + ); + + // The page builds a real KycLinkWalletCubit (seeded to Ready) via getIt + // and renders the confirm body — exercising the userData branch of build. + expect(find.text(_userData.name), findsOneWidget); + expect(find.byType(CupertinoActivityIndicator), findsNothing); + }); + }); } diff --git a/test/screens/pin/bloc/verify_pin/verify_pin_state_test.dart b/test/screens/pin/bloc/verify_pin/verify_pin_state_test.dart index afa872989..02821d165 100644 --- a/test/screens/pin/bloc/verify_pin/verify_pin_state_test.dart +++ b/test/screens/pin/bloc/verify_pin/verify_pin_state_test.dart @@ -60,6 +60,28 @@ void main() { }); }); + group('VerifyPinVerifying', () { + test('carries the entered pin and is equal for same pin + attempts', () { + final a = VerifyPinVerifying(pin: '123456'); + final b = VerifyPinVerifying(pin: '123456'); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a.pin, '123456'); + expect(a.failedAttempts, 0); + }); + + test('different pin is unequal', () { + final a = VerifyPinVerifying(pin: '123456'); + final b = VerifyPinVerifying(pin: '654321'); + expect(a, isNot(equals(b))); + }); + + test('preserves failedAttempts', () { + final a = VerifyPinVerifying(pin: '123456', failedAttempts: 3); + expect(a.failedAttempts, 3); + }); + }); + group('VerifyPinFailure', () { test('same failedAttempts is equal', () { final a = VerifyPinFailure(failedAttempts: 2); diff --git a/test/screens/pin/verify_pin_cubit_test.dart b/test/screens/pin/verify_pin_cubit_test.dart index 3bf861832..5c4399910 100644 --- a/test/screens/pin/verify_pin_cubit_test.dart +++ b/test/screens/pin/verify_pin_cubit_test.dart @@ -110,6 +110,24 @@ void main() { verify(() => secureStorage.resetPinLockout()).called(1); }); + blocTest( + 'emits VerifyPinVerifying (carrying the full pin) before VerifyPinSuccess', + build: build, + setUp: () => + when(() => secureStorage.verifyPin(any())).thenAnswer((_) async => true), + act: (cubit) => addPin(cubit, '123456'), + expect: () => [ + const VerifyPinState(pin: '1'), + const VerifyPinState(pin: '12'), + const VerifyPinState(pin: '123'), + const VerifyPinState(pin: '1234'), + const VerifyPinState(pin: '12345'), + const VerifyPinState(pin: '123456'), + const VerifyPinVerifying(pin: '123456'), + const VerifyPinSuccess(), + ], + ); + test('wrong pin (1st attempt) with lockout on emits VerifyPinFailure', () async { when(() => secureStorage.verifyPin(any())).thenAnswer((_) async => false); when(() => secureStorage.getPinFailedAttempts()).thenAnswer((_) async => 0); @@ -171,6 +189,21 @@ void main() { // Permanent lockout does NOT write a temporary lockedUntil. verifyNever(() => secureStorage.setPinLockedUntil(any())); }); + + test('a verifyPin failure recovers to a usable state instead of a stuck spinner', () async { + when(() => secureStorage.verifyPin(any())).thenThrow(Exception('hash failure')); + final cubit = build(); + // After the spinner, recovery emits a plain VerifyPinState (input reset) + // so the number pad returns — never a permanent VerifyPinVerifying. + final recovered = + cubit.stream.firstWhere((s) => s.runtimeType == VerifyPinState && s.pin.isEmpty); + + addPin(cubit, '123456'); + await recovered.timeout(const Duration(seconds: 30)); + + expect(cubit.state.runtimeType, VerifyPinState); + expect(cubit.state.pin, isEmpty); + }); }); group('onLockExpired', () { diff --git a/test/screens/pin/verify_pin_page_test.dart b/test/screens/pin/verify_pin_page_test.dart index 37fc6ad06..38978f976 100644 --- a/test/screens/pin/verify_pin_page_test.dart +++ b/test/screens/pin/verify_pin_page_test.dart @@ -1,5 +1,5 @@ import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; @@ -9,6 +9,7 @@ import 'package:realunit_wallet/packages/service/biometric_service.dart'; import 'package:realunit_wallet/packages/storage/secure_storage.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/pin/bloc/verify_pin/verify_pin_cubit.dart'; +import 'package:realunit_wallet/screens/pin/constants/pin_constants.dart'; import 'package:realunit_wallet/screens/pin/verify_pin_page.dart'; import 'package:realunit_wallet/screens/pin/widgets/pin_indicator.dart'; import 'package:realunit_wallet/setup/di.dart'; @@ -158,6 +159,38 @@ void main() { ); }); + testWidgets('shows a loading indicator and hides the number pad while verifying', ( + tester, + ) async { + when(() => verifyPinCubit.state).thenReturn(const VerifyPinVerifying(pin: '123456')); + + await tester.pumpApp( + buildSubject(VerifyPinView(onAuthenticated: onAuthenticated)), + ); + + expect(find.byType(CupertinoActivityIndicator), findsOne); + expect(find.text(S.current.pinVerifying), findsOne); + expect(find.byType(NumberPad), findsNothing); + // Dots stay filled during the wait so the screen does not look reset. + expect( + (tester.widget(find.byType(PinIndicator)) as PinIndicator).pinLength, + pinLength, + ); + }); + + testWidgets('keeps the loading indicator after success while the wallet loads', ( + tester, + ) async { + when(() => verifyPinCubit.state).thenReturn(const VerifyPinSuccess()); + + await tester.pumpApp( + buildSubject(VerifyPinView(onAuthenticated: onAuthenticated)), + ); + + expect(find.byType(CupertinoActivityIndicator), findsOne); + expect(find.byType(NumberPad), findsNothing); + }); + group('$BlocListener', () { testWidgets('triggers onPinVerified if verification is successful', (tester) async { whenListen(