diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index b967576a4..a1a39b9ac 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1201,7 +1201,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.3.6; + version = 0.3.9; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index af46a1381..a418ff0d1 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "89632fe36d73c33ac5c783ee9560e211da3685cd", - "version" : "0.3.6" + "revision" : "5e08294bc5701d8b48337745990a417841d6e026", + "version" : "0.3.9" } }, { diff --git a/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/Contents.json new file mode 100644 index 000000000..311bf5850 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "coin-stack-3.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/coin-stack-3.png b/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/coin-stack-3.png new file mode 100644 index 000000000..98c058f10 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/coin-stack-3.imageset/coin-stack-3.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/Contents.json new file mode 100644 index 000000000..a40a89637 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "hw-searching-arrows.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/hw-searching-arrows.png b/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/hw-searching-arrows.png new file mode 100644 index 000000000..eced4a766 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/hw-searching-arrows.imageset/hw-searching-arrows.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/Contents.json new file mode 100644 index 000000000..0bf65fca2 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "hw-searching-ring-inner.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/hw-searching-ring-inner.png b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/hw-searching-ring-inner.png new file mode 100644 index 000000000..8d94f0249 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring-inner.imageset/hw-searching-ring-inner.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/Contents.json new file mode 100644 index 000000000..c1f6478dd --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "hw-searching-ring.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/hw-searching-ring.png b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/hw-searching-ring.png new file mode 100644 index 000000000..8e30fa63d Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/hw-searching-ring.imageset/hw-searching-ring.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/Contents.json similarity index 76% rename from Bitkit/Assets.xcassets/Illustrations/trezor.imageset/Contents.json rename to Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/Contents.json index 012c311e7..fdd8d4517 100644 --- a/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/Contents.json +++ b/Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/Contents.json @@ -1,12 +1,12 @@ { "images" : [ { - "filename" : "trezor.png", + "filename" : "trezor-cropped.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "trezor@2x.png", + "filename" : "trezor-cropped@2x.png", "idiom" : "universal", "scale" : "2x" }, diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor.png b/Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/trezor-cropped.png similarity index 100% rename from Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor.png rename to Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/trezor-cropped.png diff --git a/Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor@2x.png b/Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/trezor-cropped@2x.png similarity index 100% rename from Bitkit/Assets.xcassets/Illustrations/trezor.imageset/trezor@2x.png rename to Bitkit/Assets.xcassets/Illustrations/trezor-cropped.imageset/trezor-cropped@2x.png diff --git a/Bitkit/Components/Trezor/HwDeviceIllustrations.swift b/Bitkit/Components/Trezor/HwDeviceIllustrations.swift index 513fc381d..03be9993b 100644 --- a/Bitkit/Components/Trezor/HwDeviceIllustrations.swift +++ b/Bitkit/Components/Trezor/HwDeviceIllustrations.swift @@ -24,7 +24,7 @@ struct HwDeviceIllustrations: View { let staggerY = width * Layout.proportionalStagger ZStack { - Image("trezor") + Image("trezor-cropped") .resizable() .scaledToFit() .frame(width: width * Layout.trezorProportionalWidth, height: imageHeight) diff --git a/Bitkit/Components/Widgets/Suggestions.swift b/Bitkit/Components/Widgets/Suggestions.swift index 779f0bb13..592710083 100644 --- a/Bitkit/Components/Widgets/Suggestions.swift +++ b/Bitkit/Components/Widgets/Suggestions.swift @@ -358,7 +358,7 @@ struct Suggestions: View { case .support: route = .support case .hardware: - sheets.showSheet(.hardwareIntro) + sheets.showSheet(.hardwareConnect) case .transferToSpending: route = app.hasSeenTransferIntro ? .fundingOptions : .transferIntro } diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index c737e481f..e41a788f2 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -189,12 +189,12 @@ struct MainNavView: View { config in WidgetsSheet(config: config) } .sheet( - item: $sheets.hardwareIntroSheetItem, + item: $sheets.hardwareConnectSheetItem, onDismiss: { sheets.hideSheet() } ) { - config in HardwareIntroSheet(config: config) + config in HardwareConnectSheet(config: config) } .sheet( item: $sheets.hardwarePairingSheetItem, @@ -216,6 +216,7 @@ struct MainNavView: View { // A hardware device asked for its one-time pairing code (e.g. during reconnect); // surface the app-wide Pair Device sheet. Hidden again once submitted/cancelled. if needsCode { + guard !sheets.hardwareConnectHandlesPairing else { return } sheets.showSheet(.hardwarePairing) } else { sheets.hideSheetIfActive(.hardwarePairing, reason: "Pairing code resolved") diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift index 7805a33be..845d40975 100644 --- a/Bitkit/Managers/TrezorManager.swift +++ b/Bitkit/Managers/TrezorManager.swift @@ -52,6 +52,8 @@ final class TrezorManager { var showPairingCode: Bool = false + private(set) var pairingCodeRequestID: Int = 0 + var showConfirmOnDevice: Bool = false var confirmMessage: String = "" @@ -115,6 +117,7 @@ final class TrezorManager { .receive(on: DispatchQueue.main) .sink { [weak self] in self?.showPairingCode = true + self?.pairingCodeRequestID &+= 1 } .store(in: &cancellables) @@ -170,6 +173,7 @@ final class TrezorManager { showPinEntry = false showPassphraseEntry = false showConfirmOnDevice = false + showPairingCode = false showWalletModeChooser = false uiHandler.setWalletMode(.standard) walletMode = .standard @@ -263,6 +267,7 @@ final class TrezorManager { func connect(device: TrezorDeviceInfo) async { error = nil suppressNextAutoReconnect = false + showPairingCode = false // Explicit user-initiated connect always opens the standard wallet — a // passphrase/on-device selection left over from a previously connected device @@ -274,6 +279,13 @@ final class TrezorManager { do { let features = try await trezorService.connect(deviceId: device.path, selection: uiHandler.currentSelection()) + + if Task.isCancelled { + try? await trezorService.disconnect() + trezorLog("Connect cancelled before pairing; disconnected \(device.path)") + return + } + connectedDevice = device deviceFeatures = features showConfirmOnDevice = false @@ -710,6 +722,12 @@ final class TrezorManager { if message.contains("Pairing required") { return "Bluetooth pairing required. Please put your Trezor in pairing mode." } + if message.contains("Code verification failed") || message.contains("verification failed") { + return t("hardware__pairing_code_invalid") + } + if message.contains("DeviceBusy") || message.contains("Device is busy") || message.contains("DeviceLocked") { + return t("hardware__device_busy") + } if message.contains("Pairing failed") || message.contains("Invalid credentials") { return "Pairing failed. Please try putting your Trezor back in pairing mode." } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9433138f7..4eaefe66c 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -36,6 +36,31 @@ "hardware__intro_title" = "Hardware Wallet"; "hardware__intro_header" = "Add your hardware wallet"; "hardware__intro_text" = "Connect your hardware device to watch or manage your long-term funds."; +"hardware__connect_title" = "Connect Device"; +"hardware__connect_header" = "Searching for devices"; +"hardware__connect_text" = "Please connect your hardware wallet now via Bluetooth."; +"hardware__connect_error" = "Could not connect to your Trezor. Check that it is unlocked and try again."; +"hardware__search_error" = "Could not search for hardware wallets. Check your connection and try again."; +"hardware__pairing_code_invalid" = "Incorrect pairing code. Put your Trezor back in pairing mode and try again."; +"hardware__device_busy" = "Your Trezor is busy. Unlock it on the device, then try again."; +"hardware__device_model_trezor" = "Trezor"; +"hardware__bluetooth_open_settings" = "Open Settings"; +"hardware__bluetooth_off_title" = "Bluetooth is Off"; +"hardware__bluetooth_off_text" = "Please enable Bluetooth in Settings to connect to your Trezor."; +"hardware__bluetooth_unauthorized_title" = "Bluetooth Unauthorized"; +"hardware__bluetooth_unauthorized_text" = "Bitkit needs Bluetooth permission to connect to your Trezor. Please enable it in Settings."; +"hardware__bluetooth_unsupported_title" = "Bluetooth Unsupported"; +"hardware__bluetooth_unsupported_text" = "This device does not support Bluetooth Low Energy."; +"hardware__bluetooth_unavailable_title" = "Bluetooth Unavailable"; +"hardware__bluetooth_unavailable_text" = "Bluetooth is not available. Please check your device settings."; +"hardware__found_title" = "Found Device"; +"hardware__found_header" = "Found\nTrezor"; +"hardware__found_text" = "Would you like to securely pair this {model} with Bitkit?"; +"hardware__paired_title" = "Device Connected"; +"hardware__paired_header" = "Paired\nTrezor"; +"hardware__paired_text" = "Bitkit found funds on your device and added these to your balance."; +"hardware__paired_label" = "Label Funds"; +"hardware__paired_finish" = "Finish"; "hardware__pairing_title" = "Pair Device"; "hardware__pairing_text" = "Enter the 6-digit code shown on your hardware device."; "hardware__remove_button" = "Remove {name}"; diff --git a/Bitkit/Services/Trezor/TrezorTransport.swift b/Bitkit/Services/Trezor/TrezorTransport.swift index b143c488a..342d5a6ca 100644 --- a/Bitkit/Services/Trezor/TrezorTransport.swift +++ b/Bitkit/Services/Trezor/TrezorTransport.swift @@ -198,6 +198,10 @@ final class TrezorTransport: TrezorTransportCallback { submittedPairingCode = "" pairingCodeLock.unlock() + // Drain any leftover signal from a prior (cancelled/submitted) pairing so this request + // doesn't return an empty code instantly and abort the handshake mid-pairing. + while pairingCodeSemaphore.wait(timeout: DispatchTime.now()) == .success {} + // Notify UI to show pairing code dialog DispatchQueue.main.async { self.needsPairingCodePublisher.send() diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index e3228cbf1..64ebe3d90 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -24,7 +24,7 @@ enum SheetID: String, CaseIterable { case tagFilter case dateRangeSelector case widgets - case hardwareIntro + case hardwareConnect case hardwarePairing case renameHardwareWallet } @@ -36,6 +36,7 @@ struct SheetConfiguration { class SheetViewModel: ObservableObject { @Published var activeSheetConfiguration: SheetConfiguration? = nil + @Published var hardwareConnectHandlesPairing = false func showSheet(_ id: SheetID, data: Any? = nil) { if isAnySheetOpen { @@ -304,10 +305,10 @@ class SheetViewModel: ObservableObject { } } - var hardwareIntroSheetItem: HardwareIntroSheetItem? { + var hardwareConnectSheetItem: HardwareConnectSheetItem? { get { - guard let config = activeSheetConfiguration, config.id == .hardwareIntro else { return nil } - return HardwareIntroSheetItem() + guard let config = activeSheetConfiguration, config.id == .hardwareConnect else { return nil } + return HardwareConnectSheetItem() } set { if newValue == nil { diff --git a/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift new file mode 100644 index 000000000..038da78cd --- /dev/null +++ b/Bitkit/ViewModels/Trezor/HwConnectViewModel.swift @@ -0,0 +1,237 @@ +import BitkitCore +import Foundation + +/// Result of a successful hardware-wallet connect: the persisted known-device id and its resolved +/// display name (from the device's own label/model). +struct HwConnectResult: Equatable { + let deviceId: String + let name: String +} + +/// Device discovery/connection seam the Connect Hardware flow drives. `TrezorHwConnectService` is +/// the production adapter over `TrezorManager`; tests inject a fake so the flow can be exercised +/// without the BLE stack. +@MainActor +protocol HwConnectServicing { + func scanForUnpairedDevices() async throws -> [TrezorDeviceInfo] + func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult + func setDeviceLabel(id: String, label: String) + func cancelPairingCode() +} + +/// Backs the Connect Hardware bottom-sheet flow (Intro → Searching → Found → Paired). Drives device +/// discovery, connection and the Bitkit-side funds label through an `HwConnectServicing`, exposing a +/// single `phase` the sheet renders. The one-time pairing code, when the device requests it during +/// connect, is surfaced inline by moving to `.pairCode`. Reactivity to `showPairingCode`/`wallets` +/// lives in the sheet (idiomatic `.onChange`), which forwards changes via `onPairingCodeRequested()` +/// / `onWalletsUpdated(_:)`. +@Observable +@MainActor +final class HwConnectViewModel { + enum Phase: Hashable { + case intro + case searching + case found + case paired + case pairCode + } + + static let deviceLabelMaxLength = 50 + private static let scanInterval: Duration = .seconds(2) + + // MARK: - Published state + + private(set) var phase: Phase = .intro + private(set) var isConnecting = false + private(set) var foundDevice: TrezorDeviceInfo? + private(set) var foundDeviceModel = "" + private(set) var pairedDeviceId: String? + private(set) var deviceName = "" + private(set) var balanceSats: UInt64 = 0 + private(set) var labelInput = "" + private(set) var errorMessage: String? + + /// Invoked when the user taps Finish after the label is persisted, so the host can dismiss the + /// sheet and return to Home. Set by the sheet. + var onFinished: (() -> Void)? + + // MARK: - Dependencies & internal state + + private let service: HwConnectServicing + private var labelInitialized = false + private var searchTask: Task? + private var connectTask: Task? + + init(service: HwConnectServicing) { + self.service = service + } + + // MARK: - Intro → Searching + + func onIntroContinue() { + errorMessage = nil + phase = .searching + startSearching() + } + + private func startSearching() { + guard searchTask == nil else { return } + errorMessage = nil + searchTask = Task { [weak self] in + while let self, !Task.isCancelled { + do { + let devices = try await service.scanForUnpairedDevices() + if Task.isCancelled { return } + errorMessage = nil + if let device = devices.first { + onDeviceFound(device) + return + } + } catch { + if Task.isCancelled { return } + errorMessage = t("hardware__search_error") + } + do { + try await Task.sleep(for: Self.scanInterval) + } catch { + return + } + } + } + } + + private func onDeviceFound(_ device: TrezorDeviceInfo) { + searchTask?.cancel() + searchTask = nil + foundDevice = device + foundDeviceModel = resolveHwWalletName(label: nil, model: device.model) + errorMessage = nil + phase = .found + } + + // MARK: - Found → Connect → Paired + + func onConnect() { + guard let device = foundDevice, connectTask == nil else { return } + searchTask?.cancel() + searchTask = nil + isConnecting = true + errorMessage = nil + connectTask = Task { [weak self] in + guard let self else { return } + do { + let result = try await service.connect(to: device) + if Task.isCancelled { return } + onConnected(result) + } catch { + if Task.isCancelled { return } + onConnectFailed(error) + } + connectTask = nil + } + } + + private func onConnected(_ result: HwConnectResult) { + isConnecting = false + pairedDeviceId = result.deviceId + deviceName = result.name + if !labelInitialized { + labelInput = result.name + } + labelInitialized = true + errorMessage = nil + phase = .paired + } + + private func onConnectFailed(_ error: Error) { + isConnecting = false + errorMessage = (error as? AppError)?.message ?? t("hardware__connect_error") + phase = .found + } + + /// The device asked for its one-time pairing code mid-connect; surface the inline step. Only + /// while a connect is in flight, so a stray flag can't hijack the flow. + func onPairingCodeRequested() { + guard isConnecting else { return } + phase = .pairCode + } + + // MARK: - Paired + + /// The connected wallet's aggregated balance/name landed; reflect it on the Paired step. + func onWalletsUpdated(_ wallets: [HwWallet]) { + guard let deviceId = pairedDeviceId else { return } + guard let wallet = wallets.first(where: { $0.id == deviceId || $0.deviceIds.contains(deviceId) }) else { return } + deviceName = wallet.name + balanceSats = wallet.balanceSats + if !labelInitialized { + labelInput = wallet.name + } + labelInitialized = true + } + + func onLabelChange(_ value: String) { + labelInput = String(value.prefix(Self.deviceLabelMaxLength)) + } + + func onFinish() { + if let deviceId = pairedDeviceId { + service.setDeviceLabel(id: deviceId, label: labelInput) + } + onFinished?() + } + + // MARK: - Teardown + + /// Cancels a pending connect/pairing-code request when the user backs out mid-connect. + func cancelConnect() { + connectTask?.cancel() + connectTask = nil + service.cancelPairingCode() + isConnecting = false + } + + /// Called when the sheet is dismissed: stop scanning/connecting and drop any pending pairing. + func reset() { + searchTask?.cancel() + searchTask = nil + cancelConnect() + } +} + +/// Production `HwConnectServicing` over `TrezorManager`. iOS is BLE-only, so discovery is a single +/// BLE scan filtered to unpaired devices; `connect(to:)` reports success by inspecting the manager's +/// `connectedDevice`/`deviceFeatures` (its own `connect` returns void and stores state) and surfaces +/// the manager's error otherwise. +@MainActor +struct TrezorHwConnectService: HwConnectServicing { + let trezorManager: TrezorManager + + func scanForUnpairedDevices() async throws -> [TrezorDeviceInfo] { + await trezorManager.startScan() + if let error = trezorManager.error { + throw AppError(message: error, debugMessage: nil) + } + return trezorManager.devices.filter { !TrezorKnownDeviceStorage.isKnown(id: $0.id) } + } + + func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult { + await trezorManager.connect(device: device) + guard let connected = trezorManager.connectedDevice, connected.id == device.id else { + throw AppError(message: trezorManager.error ?? t("hardware__connect_error"), debugMessage: nil) + } + let name = resolveHwWalletName( + label: connected.label ?? trezorManager.deviceFeatures?.label, + model: connected.model ?? trezorManager.deviceFeatures?.model + ) + return HwConnectResult(deviceId: connected.id, name: name) + } + + func setDeviceLabel(id: String, label: String) { + trezorManager.renameDevice(id: id, newName: label) + } + + func cancelPairingCode() { + trezorManager.cancelPairingCode() + } +} diff --git a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift index 6e07cc2be..58e9b0719 100644 --- a/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift +++ b/Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift @@ -30,7 +30,7 @@ struct HardwareWalletsSettingsScreen: View { title: t("settings__hardware_wallets__add_button"), shouldExpand: true ) { - sheets.showSheet(.hardwareIntro) + sheets.showSheet(.hardwareConnect) } .accessibilityIdentifier("AddHardwareWallet") .padding(.horizontal, 16) diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift new file mode 100644 index 000000000..12fa50e9a --- /dev/null +++ b/Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// Found step: a discovered device with a Connect confirmation. Connect shows a spinner and +/// surfaces an inline error on failure. +struct HwFoundView: View { + let deviceModel: String + let isConnecting: Bool + let errorMessage: String? + let onConnect: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("hardware__found_title")) + .padding(.horizontal, 16) + + VStack(alignment: .leading, spacing: 8) { + DisplayText(t("hardware__found_header"), accentColor: .blueAccent) + + BodyMText(t("hardware__found_text", variables: ["model": deviceModel])) + + if let errorMessage { + BodyMText(errorMessage, textColor: .redAccent) + .padding(.top, 8) + .accessibilityIdentifier("HwFoundError") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 32) + + Image("trezor-device") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 24) + + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary, shouldExpand: true) { + onCancel() + } + .accessibilityIdentifier("HwFoundCancel") + + CustomButton( + title: t("common__connect"), + isDisabled: isConnecting, + isLoading: isConnecting, + shouldExpand: true + ) { + onConnect() + } + .accessibilityIdentifier("HwFoundConnect") + } + .padding(.horizontal, 32) + .padding(.bottom, 16) + } + .accessibilityIdentifier("HardwareWalletFoundScreen") + } +} + +#Preview { + HwFoundView(deviceModel: "Trezor Safe 3", isConnecting: false, errorMessage: nil, onConnect: {}, onCancel: {}) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwPairCodeView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwPairCodeView.swift new file mode 100644 index 000000000..6b3c3062b --- /dev/null +++ b/Bitkit/Views/Sheets/HardwareConnect/HwPairCodeView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +/// Inline one-time THP pairing-code step shown when a device asks for its 6-digit code mid-connect. +/// On the sixth digit the code row collapses into a spinner while pairing completes. +struct HwPairCodeView: View { + let onSubmit: (String) -> Void + + private let codeLength = 6 + private let cellWidth: CGFloat = 32 + private let cellSpacing: CGFloat = 8 + + @State private var code = "" + @State private var submitted = false + + private var progress: CGFloat { + submitted ? 1 : 0 + } + + private var cellStep: CGFloat { + cellWidth + cellSpacing + } + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("hardware__pairing_title")) + .padding(.horizontal, 16) + + VStack(spacing: 0) { + BodyMText(t("hardware__pairing_text")) + .multilineTextAlignment(.center) + + Spacer() + + codeRow + .overlay { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .brandAccent)) + .frame(width: 32, height: 32) + .opacity(progress) + .scaleEffect(0.8 + 0.2 * progress) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 32) + + NumberPad(type: .simple, isDisabled: submitted, onPress: handleKey) + .frame(height: NumberPad.contentHeight) + } + .accessibilityIdentifier("HardwareWalletPairCodeScreen") + } + + /// Fixed-width cells so digits replace dots without the row shifting; on submit each cell + /// collapses toward the center and fades so the spinner can take its place. + private var codeRow: some View { + HStack(spacing: cellSpacing) { + ForEach(0 ..< codeLength, id: \.self) { index in + let digit = digit(at: index) + let centerOffset = CGFloat(codeLength - 1) / 2 - CGFloat(index) + DisplayText(digit ?? "•", textColor: digit != nil ? .textPrimary : .white32) + .frame(width: cellWidth) + .opacity(1 - progress) + .scaleEffect(x: 1 - 0.85 * progress, y: 1 - 0.15 * progress) + .offset(x: centerOffset * cellStep * progress) + } + } + .animation(.easeInOut(duration: 0.35), value: submitted) + } + + private func digit(at index: Int) -> String? { + let characters = Array(code) + return index < characters.count ? String(characters[index]) : nil + } + + private func handleKey(_ key: String) { + guard !submitted else { return } + if key == "delete" { + if !code.isEmpty { code.removeLast() } + } else if code.count < codeLength { + code += key + if code.count == codeLength { + submitted = true + onSubmit(code) + } + } + } +} + +#Preview { + HwPairCodeView(onSubmit: { _ in }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift new file mode 100644 index 000000000..9141897e5 --- /dev/null +++ b/Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +/// Paired step: the device's watch-only balance plus an editable "Label Funds" field, over the coin +/// illustration. +struct HwPairedView: View { + let deviceName: String + let balanceSats: UInt64 + @Binding var labelText: String + let onFinish: () -> Void + + /// Coins illustration width as a fraction of the sheet — the 256-wide Visual in the 375-wide Figma frame. + private let coinsWidthRatio: CGFloat = 256.0 / 375.0 + + var body: some View { + ZStack(alignment: .bottom) { + GeometryReader { geo in + Image("coin-stack-3") + .resizable() + .scaledToFit() + .frame(width: geo.size.width * coinsWidthRatio) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + .ignoresSafeArea(edges: .bottom) + .allowsHitTesting(false) + + VStack(spacing: 0) { + SheetHeader(title: t("hardware__paired_title")) + .padding(.horizontal, 16) + + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("hardware__paired_header"), accentColor: .blueAccent) + + BodyMText(t("hardware__paired_text")) + .padding(.top, 8) + + HwPairedBalanceView(name: deviceName, sats: balanceSats) + .padding(.top, 32) + + CaptionMText(t("hardware__paired_label")) + .padding(.top, 32) + .padding(.bottom, 8) + + TextField( + deviceName, + text: $labelText, + testIdentifier: "HardwareWalletLabelInput" + ) + } + .padding(.horizontal, 32) + + Spacer(minLength: 0) + + CustomButton(title: t("hardware__paired_finish"), shouldExpand: true) { + onFinish() + } + .accessibilityIdentifier("HardwareWalletPairedFinish") + .padding(.horizontal, 32) + .padding(.bottom, 16) + } + } + .accessibilityIdentifier("HardwareWalletPairedScreen") + } +} + +/// Device name over its watch-only balance, styled like the app's wallet balance rows. +private struct HwPairedBalanceView: View { + let name: String + let sats: UInt64 + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + CaptionMText(name) + + HStack(spacing: 8) { + Image("btc-circle-blue") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + + MoneyText(sats: Int(clamping: sats), size: .subtitle, symbol: true, symbolColor: .textPrimary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#Preview { + HwPairedView( + deviceName: "Trezor Safe 3", + balanceSats: 10_562_411, + labelText: .constant("Trezor Safe 3"), + onFinish: {} + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .environmentObject(CurrencyViewModel()) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwSearchingAnimation.swift b/Bitkit/Views/Sheets/HardwareConnect/HwSearchingAnimation.swift new file mode 100644 index 000000000..c0213188c --- /dev/null +++ b/Bitkit/Views/Sheets/HardwareConnect/HwSearchingAnimation.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// The Figma "Loading Animation" hardware ring: a dashed outer ring and inner ring counter-rotating +/// around a set of arrows. +struct HwSearchingAnimation: View { + /// Diameter of the loading visual (outer dashed ring). + private let canvasSize: CGFloat = 280 + + /// Arrows width as a fraction of the loader — 256 in the 311-wide Figma HW ring. + private let arrowsRatio: CGFloat = 256.0 / 311.0 + + /// Inner dashed-ring width as a fraction of the loader — 207 in the 311-wide Figma HW ring. + private let innerRingRatio: CGFloat = 207.0 / 311.0 + + /// Seconds per full ring turn (both rings counter-rotate one turn every 2s). + private let ringSpin: Double = 2 + + /// Seconds per full arrows turn. + private let arrowsSpin: Double = 4 + + @State private var animate = false + + var body: some View { + ZStack { + Image("hw-searching-ring") + .resizable() + .scaledToFit() + .frame(width: canvasSize, height: canvasSize) + .rotationEffect(.degrees(animate ? -360 : 0)) + .animation(.linear(duration: ringSpin).repeatForever(autoreverses: false), value: animate) + + Image("hw-searching-ring-inner") + .resizable() + .scaledToFit() + .frame(width: canvasSize * innerRingRatio, height: canvasSize * innerRingRatio) + .rotationEffect(.degrees(animate ? 360 : 0)) + .animation(.linear(duration: ringSpin).repeatForever(autoreverses: false), value: animate) + + Image("hw-searching-arrows") + .resizable() + .scaledToFit() + .frame(width: canvasSize * arrowsRatio, height: canvasSize * arrowsRatio) + .rotationEffect(.degrees(animate ? -360 : 0)) + .animation(.linear(duration: arrowsSpin).repeatForever(autoreverses: false), value: animate) + } + .frame(width: canvasSize, height: canvasSize) + .onAppear { animate = true } + } +} + +#Preview { + HwSearchingAnimation() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Sheets/HardwareConnect/HwSearchingView.swift b/Bitkit/Views/Sheets/HardwareConnect/HwSearchingView.swift new file mode 100644 index 000000000..512614d4e --- /dev/null +++ b/Bitkit/Views/Sheets/HardwareConnect/HwSearchingView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +/// Searching step of the Connect Hardware flow: the dashed-ring loading animation while scanning for +/// a nearby device, with an inline error message when a scan pass fails. +struct HwSearchingView: View { + let errorMessage: String? + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("hardware__connect_title")) + .padding(.horizontal, 16) + + VStack(alignment: .leading, spacing: 8) { + DisplayText(t("hardware__connect_header"), accentColor: .blueAccent) + + BodyMText( + errorMessage ?? t("hardware__connect_text"), + textColor: errorMessage != nil ? .redAccent : .textSecondary + ) + .frame(minHeight: 40, alignment: .top) + .accessibilityIdentifier(errorMessage != nil ? "HwSearchingError" : "HwSearchingText") + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 32) + + HwSearchingAnimation() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + CustomButton(title: t("common__cancel"), variant: .secondary, shouldExpand: true) { + onCancel() + } + .accessibilityIdentifier("HwSearchingCancel") + .padding(.horizontal, 32) + .padding(.bottom, 16) + } + .accessibilityIdentifier("HardwareWalletSearchingScreen") + } +} + +#Preview { + HwSearchingView(errorMessage: nil, onCancel: {}) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .preferredColorScheme(.dark) +} + +#Preview("Error") { + HwSearchingView(errorMessage: t("hardware__search_error"), onCancel: {}) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Sheets/HardwareConnectSheet.swift b/Bitkit/Views/Sheets/HardwareConnectSheet.swift new file mode 100644 index 000000000..2b4ac44b2 --- /dev/null +++ b/Bitkit/Views/Sheets/HardwareConnectSheet.swift @@ -0,0 +1,210 @@ +import CoreBluetooth +import SwiftUI + +struct HardwareConnectSheetItem: SheetItem { + let id: SheetID = .hardwareConnect + let size: SheetSize = .large +} + +/// Entry point for the Connect Hardware flow. +struct HardwareConnectSheet: View { + @Environment(TrezorManager.self) private var trezorManager + let config: HardwareConnectSheetItem + + var body: some View { + HardwareConnectFlow( + service: TrezorHwConnectService(trezorManager: trezorManager), + config: config + ) + } +} + +private struct HardwareConnectFlow: View { + @Environment(TrezorManager.self) private var trezorManager + @Environment(HwWalletManager.self) private var hwWalletManager + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var navigation: NavigationViewModel + + let config: HardwareConnectSheetItem + + @State private var viewModel: HwConnectViewModel + @State private var showBluetoothAlert = false + + init(service: HwConnectServicing, config: HardwareConnectSheetItem) { + self.config = config + _viewModel = State(initialValue: HwConnectViewModel(service: service)) + } + + var body: some View { + Sheet(id: .hardwareConnect, data: config) { + ZStack { + currentPhaseView + .id(viewModel.phase) + .transition(pushTransition) + } + .animation(.easeInOut(duration: 0.3), value: viewModel.phase) + } + .onChange(of: trezorManager.pairingCodeRequestID) { _, _ in + if trezorManager.showPairingCode { viewModel.onPairingCodeRequested() } + } + .onChange(of: viewModel.isConnecting) { _, connecting in + sheets.hardwareConnectHandlesPairing = connecting + } + .onChange(of: connectedWalletKey) { _, _ in + viewModel.onWalletsUpdated(hwWalletManager.wallets) + } + .task { + viewModel.onFinished = { + sheets.hideSheet() + navigation.reset() + app.requestedHomePage = 0 + } + } + .onDisappear { + viewModel.reset() + sheets.hardwareConnectHandlesPairing = false + } + .alert(bluetoothAlertTitle, isPresented: $showBluetoothAlert) { + Button(t("common__cancel"), role: .cancel) {} + Button(t("hardware__bluetooth_open_settings")) { openSettings() } + } message: { + Text(bluetoothAlertMessage) + } + } + + @ViewBuilder + private var currentPhaseView: some View { + switch viewModel.phase { + case .intro: + introStep + case .searching: + HwSearchingView(errorMessage: viewModel.errorMessage, onCancel: { sheets.hideSheet() }) + case .found: + HwFoundView( + deviceModel: foundDeviceModel, + isConnecting: viewModel.isConnecting, + errorMessage: viewModel.errorMessage, + onConnect: viewModel.onConnect, + onCancel: { + viewModel.cancelConnect() + sheets.hideSheet() + } + ) + case .paired: + HwPairedView( + deviceName: viewModel.deviceName, + balanceSats: viewModel.balanceSats, + labelText: labelBinding, + onFinish: viewModel.onFinish + ) + case .pairCode: + HwPairCodeView(onSubmit: { trezorManager.submitPairingCode($0) }) + .id(trezorManager.pairingCodeRequestID) + } + } + + /// Steps slide in from the trailing edge as the flow advances, mirroring the push transitions of + /// the app's other multi-step sheets, without a `NavigationStack` (which would draw its own + /// background over the sheet gradient). Cancel and back still dismiss the sheet. + private var pushTransition: AnyTransition { + .asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + ) + } + + // MARK: - Intro step + + private var introStep: some View { + VStack(spacing: 0) { + SheetHeader(title: t("hardware__intro_title")) + .padding(.horizontal, 16) + + HwDeviceIllustrations() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + VStack(alignment: .leading, spacing: 0) { + DisplayText(t("hardware__intro_header"), accentColor: .blueAccent) + + BodyMText(t("hardware__intro_text")) + .padding(.top, 8) + + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary, shouldExpand: true) { + sheets.hideSheet() + } + .accessibilityIdentifier("HwIntroCancel") + + CustomButton(title: t("common__continue"), shouldExpand: true) { + onContinueTapped() + } + .accessibilityIdentifier("HwIntroContinue") + } + .padding(.top, 32) + .padding(.bottom, 16) + } + .padding(.horizontal, 32) + } + .accessibilityIdentifier("HwIntroSheet") + } + + private func onContinueTapped() { + if isBluetoothUsable { + viewModel.onIntroContinue() + } else { + showBluetoothAlert = true + } + } + + // MARK: - Helpers + + private var foundDeviceModel: String { + viewModel.foundDeviceModel.isEmpty ? t("hardware__device_model_trezor") : viewModel.foundDeviceModel + } + + private var labelBinding: Binding { + Binding(get: { viewModel.labelInput }, set: { viewModel.onLabelChange($0) }) + } + + /// Changes whenever the paired wallet's name or balance changes, so the balance shown on the + /// Paired step tracks incoming watcher updates. + private var connectedWalletKey: String { + guard let deviceId = viewModel.pairedDeviceId else { return "" } + guard let wallet = hwWalletManager.wallets.first(where: { $0.id == deviceId || $0.deviceIds.contains(deviceId) }) + else { return "" } + return "\(wallet.name)\u{1}\(wallet.balanceSats)" + } + + private var isBluetoothUsable: Bool { + switch trezorManager.bluetoothState { + case .poweredOn, .unknown, .resetting: + return true + default: + return false + } + } + + private var bluetoothAlertTitle: String { + switch trezorManager.bluetoothState { + case .poweredOff: return t("hardware__bluetooth_off_title") + case .unauthorized: return t("hardware__bluetooth_unauthorized_title") + case .unsupported: return t("hardware__bluetooth_unsupported_title") + default: return t("hardware__bluetooth_unavailable_title") + } + } + + private var bluetoothAlertMessage: String { + switch trezorManager.bluetoothState { + case .poweredOff: return t("hardware__bluetooth_off_text") + case .unauthorized: return t("hardware__bluetooth_unauthorized_text") + case .unsupported: return t("hardware__bluetooth_unsupported_text") + default: return t("hardware__bluetooth_unavailable_text") + } + } + + private func openSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} diff --git a/Bitkit/Views/Sheets/HardwareIntroSheet.swift b/Bitkit/Views/Sheets/HardwareIntroSheet.swift deleted file mode 100644 index 12bf3efd3..000000000 --- a/Bitkit/Views/Sheets/HardwareIntroSheet.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI - -struct HardwareIntroSheetItem: SheetItem { - let id: SheetID = .hardwareIntro - let size: SheetSize = .large -} - -/// Intro sheet opened from the Hardware suggestion card. Mirrors bitkit-android's `HwIntroSheet`: -/// staggered device hero, accent title, copy, and Cancel + (disabled) Continue. Continue is -/// intentionally disabled — the connect flow ships in a later PR. -struct HardwareIntroSheet: View { - @EnvironmentObject private var sheets: SheetViewModel - let config: HardwareIntroSheetItem - - var body: some View { - Sheet(id: .hardwareIntro, data: config) { - VStack(spacing: 0) { - SheetHeader(title: t("hardware__intro_title")) - .padding(.horizontal, 16) - - HwDeviceIllustrations() - .frame(maxWidth: .infinity, maxHeight: .infinity) - - VStack(alignment: .leading, spacing: 0) { - DisplayText(t("hardware__intro_header"), accentColor: .blueAccent) - - BodyMText(t("hardware__intro_text")) - .padding(.top, 8) - - HStack(spacing: 16) { - CustomButton(title: t("common__cancel"), variant: .secondary, shouldExpand: true) { - sheets.hideSheet() - } - .accessibilityIdentifier("HwIntroCancel") - - CustomButton(title: t("common__continue"), isDisabled: true, shouldExpand: true) - .accessibilityIdentifier("HwIntroContinue") - } - .padding(.top, 32) - .padding(.bottom, 16) - } - .padding(.horizontal, 32) - } - .accessibilityIdentifier("HwIntroSheet") - } - } -} diff --git a/BitkitTests/HwConnectViewModelTests.swift b/BitkitTests/HwConnectViewModelTests.swift new file mode 100644 index 000000000..0d7f31c07 --- /dev/null +++ b/BitkitTests/HwConnectViewModelTests.swift @@ -0,0 +1,214 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +@MainActor +final class HwConnectViewModelTests: XCTestCase { + private var service: FakeHwConnectService! + private var sut: HwConnectViewModel! + + override func setUp() { + super.setUp() + service = FakeHwConnectService() + sut = HwConnectViewModel(service: service) + } + + override func tearDown() { + sut.reset() + sut = nil + service = nil + super.tearDown() + } + + // MARK: - Searching + + func testOnIntroContinueSearchesThenAdvancesToFoundWithFirstDevice() async { + service.nearbyDevices = [makeDevice(id: "dev1", model: "Safe 3")] + + sut.onIntroContinue() + + await waitUntil { self.sut.phase == .found } + XCTAssertEqual(sut.phase, .found) + XCTAssertEqual(sut.foundDevice?.id, "dev1") + XCTAssertEqual(sut.foundDeviceModel, "Trezor Safe 3") + XCTAssertNil(sut.errorMessage) + } + + func testOnIntroContinueSurfacesSearchFailureWhileSearching() async { + service.scanError = TestError.stub + + sut.onIntroContinue() + + await waitUntil { self.sut.errorMessage != nil } + XCTAssertEqual(sut.phase, .searching) + XCTAssertEqual(sut.errorMessage, t("hardware__search_error")) + } + + // MARK: - Connect + + func testOnConnectConnectsFoundDeviceAndAdvancesToPaired() async { + await givenDeviceFound() + service.connectResult = .success(HwConnectResult(deviceId: "dev1", name: "Trezor Safe 3")) + + sut.onConnect() + + await waitUntil { self.sut.phase == .paired } + XCTAssertEqual(service.connectedDeviceIds, ["dev1"]) + XCTAssertEqual(sut.pairedDeviceId, "dev1") + XCTAssertEqual(sut.deviceName, "Trezor Safe 3") + XCTAssertEqual(sut.labelInput, "Trezor Safe 3") + XCTAssertFalse(sut.isConnecting) + } + + func testOnConnectSurfacesRealErrorMessageAndReturnsToFound() async { + await givenDeviceFound() + let realMessage = t("hardware__pairing_code_invalid") + // Use the module-qualified type: `Errors.swift` is also compiled into the test target, so an + // unqualified `AppError` here is a distinct `BitkitTests.AppError` that the view model's + // `Bitkit.AppError` cast would reject. Production throws `Bitkit.AppError`, so mirror that. + service.connectResult = .failure(Bitkit.AppError(message: realMessage, debugMessage: nil)) + + sut.onConnect() + + await waitUntil { self.sut.errorMessage != nil } + XCTAssertEqual(sut.phase, .found) + XCTAssertFalse(sut.isConnecting) + XCTAssertEqual(sut.errorMessage, realMessage) + XCTAssertEqual(sut.foundDevice?.id, "dev1") + } + + func testOnConnectFallsBackToGenericErrorForNonAppError() async { + await givenDeviceFound() + service.connectResult = .failure(TestError.stub) + + sut.onConnect() + + await waitUntil { self.sut.errorMessage != nil } + XCTAssertEqual(sut.phase, .found) + XCTAssertFalse(sut.isConnecting) + XCTAssertEqual(sut.errorMessage, t("hardware__connect_error")) + } + + // MARK: - Pairing code + + func testPairingCodeRequestSurfacesInlinePairCodeStepWhileConnecting() async { + await givenDeviceFound() + service.connectResult = .success(HwConnectResult(deviceId: "dev1", name: "Trezor Safe 3")) + + // onConnect flips isConnecting synchronously; the connect Task is queued but not yet run, + // so the pairing-code request lands mid-connect exactly as it would on device. + sut.onConnect() + XCTAssertTrue(sut.isConnecting) + sut.onPairingCodeRequested() + + XCTAssertEqual(sut.phase, .pairCode) + } + + func testPairingCodeRequestIgnoredWhenNotConnecting() { + sut.onPairingCodeRequested() + XCTAssertEqual(sut.phase, .intro) + } + + // MARK: - Paired + + func testConnectedWalletUpdatesBalanceOnPairedStep() async { + await givenDevicePaired() + + sut.onWalletsUpdated([makeWallet(id: "dev1", name: "Trezor Safe 3", balance: 10_562_411)]) + + XCTAssertEqual(sut.balanceSats, 10_562_411) + XCTAssertEqual(sut.deviceName, "Trezor Safe 3") + } + + func testOnLabelChangeCapsTheLabelInput() { + sut.onLabelChange(String(repeating: "a", count: 51)) + XCTAssertEqual(sut.labelInput, String(repeating: "a", count: 50)) + } + + func testOnFinishPersistsEditedLabelAndFinishes() async { + await givenDevicePaired() + sut.onLabelChange("My Cold Wallet") + var finished = false + sut.onFinished = { finished = true } + + sut.onFinish() + + XCTAssertEqual(service.setLabelCalls.count, 1) + XCTAssertEqual(service.setLabelCalls.first?.id, "dev1") + XCTAssertEqual(service.setLabelCalls.first?.label, "My Cold Wallet") + XCTAssertTrue(finished) + } + + // MARK: - Helpers + + private func givenDeviceFound() async { + service.nearbyDevices = [makeDevice(id: "dev1", model: "Safe 3")] + sut.onIntroContinue() + await waitUntil { self.sut.phase == .found } + } + + private func givenDevicePaired() async { + await givenDeviceFound() + service.connectResult = .success(HwConnectResult(deviceId: "dev1", name: "Trezor Safe 3")) + sut.onConnect() + await waitUntil { self.sut.phase == .paired } + } + + private func makeDevice(id: String, model: String?) -> TrezorDeviceInfo { + TrezorDeviceInfo( + id: id, + transportType: .bluetooth, + name: nil, + path: "ble:\(id)", + label: nil, + model: model, + isBootloader: false + ) + } + + private func makeWallet(id: String, name: String, balance: UInt64) -> HwWallet { + HwWallet(id: id, walletId: "trezor:\(id)", name: name, model: nil, isConnected: true, balanceSats: balance) + } + + private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } +} + +private enum TestError: Error { + case stub +} + +@MainActor +private final class FakeHwConnectService: HwConnectServicing { + var nearbyDevices: [TrezorDeviceInfo] = [] + var scanError: Error? + var connectResult: Result = .failure(TestError.stub) + + private(set) var scanCount = 0 + private(set) var connectedDeviceIds: [String] = [] + private(set) var setLabelCalls: [(id: String, label: String)] = [] + private(set) var cancelPairingCount = 0 + + func scanForUnpairedDevices() async throws -> [TrezorDeviceInfo] { + scanCount += 1 + if let scanError { throw scanError } + return nearbyDevices + } + + func connect(to device: TrezorDeviceInfo) async throws -> HwConnectResult { + connectedDeviceIds.append(device.id) + return try connectResult.get() + } + + func setDeviceLabel(id: String, label: String) { + setLabelCalls.append((id, label)) + } + + func cancelPairingCode() { + cancelPairingCount += 1 + } +} diff --git a/changelog.d/next/614.added.md b/changelog.d/next/614.added.md new file mode 100644 index 000000000..659af0007 --- /dev/null +++ b/changelog.d/next/614.added.md @@ -0,0 +1 @@ +Connect a Trezor hardware wallet from the home suggestion card or Hardware Wallets settings to watch its balance, with a guided pairing flow that finds the device, enters the one-time pairing code, and labels the funds.