diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 6a596d22..3b0f9899 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -33,8 +33,8 @@ "buyPaymentConfirmFailedAktionariat": "Es gibt ein technisches Problem. Bitte überprüfen Sie Ihr E-Mail-Postfach, möglicherweise fehlt noch eine Bestätigung Ihrer Blockchain-Adresse. Andernfalls versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", "buyPaymentInformation": "Zahlungsinformationen", "buyPaymentInformationDescription": "Bitte überweisen Sie den Kaufbetrag mit diesen Angaben über Ihre Bankanwendung. Der Verwendungszweck ist wichtig!", - "buyRealUnit": "RealUnit kaufen", "buyRealu": "RealUnit Token kaufen", + "buyRealUnit": "RealUnit kaufen", "cancel": "Abbrechen", "changeAddress": "Adresse ändern", "changeInReview": "Änderung in Prüfung", @@ -53,11 +53,13 @@ "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.", - "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.", + "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", "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", "connectBitboxSignatureFailed": "Ihre Anmeldesignatur konnte nicht erfasst werden. Sie können es erneut versuchen oder trotzdem fortfahren – Ihre BitBox wird dann möglicherweise für Ihren ersten Kauf erneut benötigt.", "connectBitboxSignatureFailedTitle": "Anmeldung nicht abgeschlossen", + "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.", "connectBitboxTitle": "BitBox verbinden", "connected": "Verbunden", "connectedBitboxContent": "Bitte bestätigen Sie und folgen nun den letzten Anweisungen auf Ihrer BitBox.", @@ -198,8 +200,8 @@ "proofDocument": "Nachweis-Dokument", "purposeOfPayment": "Verwendungszweck", "qrCode": "QR-Code", - "realunitStockToken": "RealUnit Aktientoken", "realunitStockprice": "RealUnit Aktienkurs", + "realunitStockToken": "RealUnit Aktientoken", "realunitWallet": "RealUnit Wallet", "realunitWalletLogout": "Aus REALU Wallet abmelden", "realunitWalletLogoutCheck": "Ich habe meine Wiederherstellungsphrase gesichert.", @@ -246,18 +248,18 @@ "sellBitboxCheckingEth": "Wallet-Guthaben wird geprüft", "sellBitboxDepositDescription": "Bestätigen Sie auf der BitBox, um ZCHF an die DFX-Einzahlungsadresse zu überweisen.", "sellBitboxDepositFrom": "Sie senden", + "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox", "sellBitboxDepositRetryDescription": "Der Tausch wurde abgeschlossen, aber die ZCHF-Einzahlung konnte nicht gesendet werden. Ihre Mittel sind sicher. Tippen Sie auf Wiederholen.", "sellBitboxDepositRetryTitle": "Einzahlung fehlgeschlagen", "sellBitboxDepositTitle": "ZCHF an DFX senden", "sellBitboxDepositTo": "DFX-Einzahlung", - "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox", "sellBitboxEthReady": "Wallet bereit", "sellBitboxEthReadyDescription": "Ihr Wallet hat genug ETH, um mit dem Verkauf fortzufahren.", "sellBitboxSwapDescription": "Bestätigen Sie auf Ihrem BitBox, um REALU über den BrokerBot in ZCHF zu tauschen.", "sellBitboxSwapFrom": "Sie senden", + "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.", "sellBitboxSwapTitle": "REALU → ZCHF tauschen", "sellBitboxSwapTo": "Sie erhalten", - "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.", "sellBitboxWaitingForEth": "Gasgebühren werden angefordert", "sellBitboxWaitingForEthDescription": "Ein kleiner ETH-Betrag wird an Ihr Wallet gesendet, um die Transaktionsgebühren zu decken. Dies kann einige Minuten dauern.", "sellMinAmount": "Mindestbetrag: ${amount} ${currency}", @@ -282,10 +284,10 @@ "settingsWalletBackupSubtitle1": "Bitte notieren Sie Ihre 12 Wiederherstellungs-Wörter in der exakten Reihenfolge auf einem Blatt Papier und bewahren Sie sie absolut sicher auf.", "settingsWalletBackupSubtitle2": "Dies ist die einzige Möglichkeit, Ihre Wallet wiederherzustellen.", "showSeed": "Seed anzeigen", - "signMessage": "Signierte Nachricht", - "signMessageGet": "Signierte Nachricht abrufen", "signature": "Signatur", "signingCancelled": "Signatur abgebrochen — bitte BitBox erneut bestätigen", + "signMessage": "Signierte Nachricht", + "signMessageGet": "Signierte Nachricht abrufen", "skip": "Überspringen", "softwareTermsText": "Mit der Nutzung dieser App akzeptieren Sie die Nutzungsbedingungen dieser Software.", "softwareTermsTextHighlighted": "Nutzungsbedingungen", @@ -329,9 +331,9 @@ "transactionBuy": "Kauf", "transactionHistory": "Transaktionshistorie", "transactionPending": "In Bearbeitung", + "transactions": "Transaktionen", "transactionSell": "Verkauf", "transactionWaitingForPayment": "Warte auf Zahlung", - "transactions": "Transaktionen", "twoFa": "2-Faktor Authentifizierung", "twoFaCodeRequired": "Code ist erforderlich", "twoFaCodeTooShort": "Der Code sollte 6 Ziffern lang sein", @@ -356,4 +358,4 @@ "youPay": "Sie bezahlen", "youReceive": "Sie erhalten", "youSell": "Sie verkaufen" -} +} \ No newline at end of file diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 52406e86..cdb7a32c 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -33,8 +33,8 @@ "buyPaymentConfirmFailedAktionariat": "There is a technical problem. Please check your email inbox — you may still need to confirm your blockchain address. Otherwise, please try again later. If the error persists, contact our support team.", "buyPaymentInformation": "Payment information", "buyPaymentInformationDescription": "Please transfer the purchase amount using your banking app with these details. The purpose of payment is important!", - "buyRealUnit": "Buy RealUnit", "buyRealu": "Buy RealUnit Token", + "buyRealUnit": "Buy RealUnit", "cancel": "Cancel", "changeAddress": "Change address", "changeInReview": "Change in review", @@ -53,11 +53,13 @@ "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.", - "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.", + "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", "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", "connectBitboxSignatureFailed": "We couldn't capture your sign-in signature. You can retry, or continue anyway – your BitBox may then be needed again for your first purchase.", "connectBitboxSignatureFailedTitle": "Sign-in not completed", + "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.", "connectBitboxTitle": "Connect BitBox", "connected": "Connected", "connectedBitboxContent": "Please confirm and follow the last steps on your BitBox.", @@ -198,8 +200,8 @@ "proofDocument": "Proof document", "purposeOfPayment": "Purpose of payment", "qrCode": "QR code", - "realunitStockToken": "RealUnit Stock Token", "realunitStockprice": "RealUnit Stockprice", + "realunitStockToken": "RealUnit Stock Token", "realunitWallet": "RealUnit Wallet", "realunitWalletLogout": "Log out of REALU Wallet", "realunitWalletLogoutCheck": "I have backed up my recovery phrase.", @@ -246,18 +248,18 @@ "sellBitboxCheckingEth": "Checking your wallet balance", "sellBitboxDepositDescription": "Confirm on your BitBox to transfer ZCHF to the DFX deposit address.", "sellBitboxDepositFrom": "You send", + "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.", "sellBitboxDepositRetryDescription": "The swap was completed but the ZCHF deposit could not be sent. Your funds are safe. Tap retry to try again.", "sellBitboxDepositRetryTitle": "Deposit failed", "sellBitboxDepositTitle": "Send ZCHF to DFX", "sellBitboxDepositTo": "DFX deposit", - "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.", "sellBitboxEthReady": "Wallet ready", "sellBitboxEthReadyDescription": "Your wallet has enough ETH to proceed with the sale.", "sellBitboxSwapDescription": "Confirm on your BitBox to swap REALU for ZCHF via the BrokerBot.", "sellBitboxSwapFrom": "You send", + "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.", "sellBitboxSwapTitle": "Swap REALU → ZCHF", "sellBitboxSwapTo": "You receive", - "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.", "sellBitboxWaitingForEth": "Requesting gas funds", "sellBitboxWaitingForEthDescription": "A small amount of ETH is being sent to your wallet to cover transaction fees. This may take a few minutes.", "sellMinAmount": "Minimum amount: ${amount} ${currency}", @@ -282,10 +284,10 @@ "settingsWalletBackupSubtitle1": "Please write down your 12 recovery words in the exact order on a piece of paper and keep them in a completely safe place.", "settingsWalletBackupSubtitle2": "This is the only way to recover your wallet.", "showSeed": "Show Seed", - "signMessage": "Sign Message", - "signMessageGet": "Get Sign Message", "signature": "Signature", "signingCancelled": "Signature cancelled — please confirm on the BitBox again", + "signMessage": "Sign Message", + "signMessageGet": "Get Sign Message", "skip": "Skip", "softwareTermsText": "By using this app, you accept the terms of use of this software.", "softwareTermsTextHighlighted": "terms of use", @@ -329,9 +331,9 @@ "transactionBuy": "Buy", "transactionHistory": "Transaction history", "transactionPending": "Processing", + "transactions": "Transactions", "transactionSell": "Sell", "transactionWaitingForPayment": "Waiting for payment", - "transactions": "Transactions", "twoFa": "Two-factor authentication", "twoFaCodeRequired": "Code is required", "twoFaCodeTooShort": "Code should be 6 digits", @@ -356,4 +358,4 @@ "youPay": "You pay", "youReceive": "You receive", "youSell": "You sell" -} +} \ No newline at end of file diff --git a/lib/packages/hardware_wallet/bitbox.dart b/lib/packages/hardware_wallet/bitbox.dart index 1e5caa6c..0a6e9a6a 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 96da7838..12d307f7 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,13 @@ 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'; + ConnectBitboxCubit( this._service, this._walletService, @@ -155,15 +162,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 _service.getDeviceStatus() == _statusUninitialized) { + if (isClosed) return; + emit(BitboxNotInitialized(currentState.device)); + return; + } + await _acquireWalletAndConnect(); } catch (e) { developer.log(e.toString(), name: '$ConnectBitboxCubit'); _pendingInit = null; @@ -173,6 +183,45 @@ class ConnectBitboxCubit extends Cubit { } } + /// 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 _service.getDeviceStatus() == _statusUninitialized) { + 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 8384e478..7e679463 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 ee72a39f..7f673133 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/pubspec.lock b/pubspec.lock index cf5905a0..32073ca0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,8 +77,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v0.0.8" - resolved-ref: "7786f6e70b716287f08f4b7082762e6d7d0546bf" + ref: "fix/bitbox-uninitialized-device-status" + resolved-ref: "237ceee851cd654ccfd4bbcdee094fd796032453" url: "https://github.com/DFXswiss/bitbox_flutter.git" source: git version: "0.0.1" @@ -174,10 +174,10 @@ packages: dependency: "direct main" description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -923,18 +923,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1416,26 +1416,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.12" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a277d4e3..bb35a262 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.8 + ref: fix/bitbox-uninitialized-device-status dev_dependencies: flutter_test: diff --git a/test/packages/hardware_wallet/bitbox_service_test.dart b/test/packages/hardware_wallet/bitbox_service_test.dart index 43ee21c8..b9107444 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 11b764c5..ea8c13a3 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,96 @@ 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()); + }); }); } 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 478ad0d0..096c0ea0 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(