From 1c1c83c04b846cbdc666a8766c583c452dc32017 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 22 Sep 2026 16:26:36 +0200 Subject: [PATCH] feat: add native seedqr wallet restore --- Bitkit.xcodeproj/project.pbxproj | 19 +- .../xcshareddata/swiftpm/Package.resolved | 13 +- Bitkit/Components/Scanner.swift | 201 ++++++++++++++++-- Bitkit/Managers/ScannerManager.swift | 152 ++++++------- .../Localization/en.lproj/Localizable.strings | 2 + Bitkit/Utilities/QRCodePayload.swift | 43 ++++ Bitkit/Utilities/SeedQRCodeDecoder.swift | 85 ++++++++ .../Views/Onboarding/RestoreWalletView.swift | 25 +++ .../Onboarding/SeedQRCodeScannerView.swift | 59 +++++ .../Advanced/ElectrumSettingsScreen.swift | 3 + .../ProbingTool/ProbingToolScannerSheet.swift | 3 +- BitkitTests/SeedQRCodeDecoderTests.swift | 80 +++++++ changelog.d/next/760.added.md | 1 + 13 files changed, 557 insertions(+), 129 deletions(-) create mode 100644 Bitkit/Utilities/QRCodePayload.swift create mode 100644 Bitkit/Utilities/SeedQRCodeDecoder.swift create mode 100644 Bitkit/Views/Onboarding/SeedQRCodeScannerView.swift create mode 100644 BitkitTests/SeedQRCodeDecoderTests.swift create mode 100644 changelog.d/next/760.added.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index cc7142011..03af77b66 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -22,7 +22,6 @@ 968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 968FE13F2DFB016B0053CD7F /* LDKNode */; }; 96DEA03A2DE8BBA1009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA0392DE8BBA1009932BF /* BitkitCore */; }; 96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA03B2DE8BBAB009932BF /* BitkitCore */; }; - 96E20CD42CB6D91A00C24149 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 96E20CD32CB6D91A00C24149 /* CodeScanner */; }; 96E493A42C942FD1000E8BC2 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A32C942FD1000E8BC2 /* secp256k1 */; }; 96E493A62C94317D000E8BC2 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A52C94317D000E8BC2 /* secp256k1 */; }; 96E493A82C943184000E8BC2 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A72C943184000E8BC2 /* secp256k1 */; }; @@ -272,7 +271,6 @@ 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */, 96E493A42C942FD1000E8BC2 /* secp256k1 in Frameworks */, 96DEA03A2DE8BBA1009932BF /* BitkitCore in Frameworks */, - 96E20CD42CB6D91A00C24149 /* CodeScanner in Frameworks */, 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -403,7 +401,6 @@ packageProductDependencies = ( 9613018B2C5022D700878183 /* LDKNode */, 96E493A32C942FD1000E8BC2 /* secp256k1 */, - 96E20CD32CB6D91A00C24149 /* CodeScanner */, 96DEA0392DE8BBA1009932BF /* BitkitCore */, 4AAB08C92E1FE77600BA63DF /* Lottie */, 18D65DFF2EB964B500252335 /* VssRustClientFfi */, @@ -512,7 +509,6 @@ mainGroup = 96FE1F582C2DE6AA006D0C8B; packageReferences = ( 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */, - 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */, 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */, 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */, 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */, @@ -1214,15 +1210,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.5.14; - }; - }; - 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/twostraws/CodeScanner"; - requirement = { - kind = exactVersion; - version = 2.5.2; + version = 0.5.18; }; }; 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { @@ -1276,11 +1264,6 @@ package = 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */; productName = BitkitCore; }; - 96E20CD32CB6D91A00C24149 /* CodeScanner */ = { - isa = XCSwiftPackageProductDependency; - package = 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */; - productName = CodeScanner; - }; 96E493A32C942FD1000E8BC2 /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; package = 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2bc650a24..b316187a9 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,17 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "890502f241e12305325cdd0f5e46b2752b9aedf4", - "version" : "0.5.14" - } - }, - { - "identity" : "codescanner", - "kind" : "remoteSourceControl", - "location" : "https://github.com/twostraws/CodeScanner", - "state" : { - "revision" : "5e886430238944c7200fc9e10dbf2d9550dba865", - "version" : "2.5.2" + "revision" : "b53fa54a16f04940943072bb6e08dc2650302f93", + "version" : "0.5.18" } }, { diff --git a/Bitkit/Components/Scanner.swift b/Bitkit/Components/Scanner.swift index 08adf7c93..13a8f22ba 100644 --- a/Bitkit/Components/Scanner.swift +++ b/Bitkit/Components/Scanner.swift @@ -1,22 +1,185 @@ -import CodeScanner +import AVFoundation import PhotosUI import SwiftUI +import Vision // MARK: - Scanner Camera Component -private struct ScannerCamera: View { +private struct ScannerCamera: UIViewControllerRepresentable { let isTorchOn: Bool - let onScan: (String) async -> Void + let onScan: (QRCodePayload) async -> Void - var body: some View { - CodeScannerView(codeTypes: [.qr], shouldVibrateOnSuccess: false, isTorchOn: isTorchOn) { response in - if case let .success(result) = response { - Task { - await onScan(result.string) + func makeUIViewController(context _: Context) -> QRCodeScannerViewController { + QRCodeScannerViewController { payload in + Task { + await onScan(payload) + } + } + } + + func updateUIViewController(_ controller: QRCodeScannerViewController, context _: Context) { + controller.setTorch(isOn: isTorchOn) + } +} + +private final class QRCodeScannerViewController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate { + private let captureSession = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "to.bitkit.qr-scanner.session") + private let videoQueue = DispatchQueue(label: "to.bitkit.qr-scanner.video") + private let onScan: (QRCodePayload) -> Void + private var captureDevice: AVCaptureDevice? + private var isTorchRequested = false + private var framesWithoutQRCode = 0 + private var isProcessingFrame = false + private var lastPayloadData: Data? + private var lastPayloadString: String? + private lazy var previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + + init(onScan: @escaping (QRCodePayload) -> Void) { + self.onScan = onScan + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + previewLayer.videoGravity = .resizeAspectFill + view.layer.addSublayer(previewLayer) + configureCaptureSession() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer.frame = view.bounds + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + sessionQueue.async { [weak self] in + guard let self, !captureSession.isRunning else { return } + captureSession.startRunning() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + sessionQueue.async { [weak self] in + guard let self, captureSession.isRunning else { return } + captureSession.stopRunning() + } + } + + func setTorch(isOn: Bool) { + sessionQueue.async { [weak self] in + guard let self else { return } + isTorchRequested = isOn + applyTorchState() + } + } + + private func applyTorchState() { + guard let captureDevice, captureDevice.hasTorch else { return } + + do { + try captureDevice.lockForConfiguration() + captureDevice.torchMode = isTorchRequested ? .on : .off + captureDevice.unlockForConfiguration() + } catch { + Logger.error(error, context: "QR scanner torch") + } + } + + private func configureCaptureSession() { + sessionQueue.async { [weak self] in + guard let self else { return } + + captureSession.beginConfiguration() + defer { captureSession.commitConfiguration() } + captureSession.sessionPreset = .high + + guard let device = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: .back + ) else { + Logger.error("Failed to find QR scanner camera") + return + } + + do { + let input = try AVCaptureDeviceInput(device: device) + guard captureSession.canAddInput(input) else { + Logger.error("Failed to add QR scanner camera input") + return } - } else if case let .failure(error) = response { - Logger.error(error, context: "CodeScanner") + captureSession.addInput(input) + captureDevice = device + applyTorchState() + } catch { + Logger.error(error, context: "QR scanner camera input") + return + } + + let output = AVCaptureVideoDataOutput() + output.alwaysDiscardsLateVideoFrames = true + output.setSampleBufferDelegate(self, queue: videoQueue) + guard captureSession.canAddOutput(output) else { + Logger.error("Failed to add QR scanner video output") + return + } + captureSession.addOutput(output) + } + } + + func captureOutput( + _: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from _: AVCaptureConnection + ) { + guard !isProcessingFrame else { return } + isProcessingFrame = true + defer { isProcessingFrame = false } + + let request = VNDetectBarcodesRequest() + request.symbologies = [.qr] + + do { + let handler = VNImageRequestHandler( + cmSampleBuffer: sampleBuffer, + orientation: .right, + options: [:] + ) + try handler.perform([request]) + } catch { + Logger.error(error, context: "QR scanner frame detection") + return + } + + guard let observation = request.results?.first else { + framesWithoutQRCode += 1 + if framesWithoutQRCode >= 10 { + lastPayloadData = nil + lastPayloadString = nil } + return + } + + framesWithoutQRCode = 0 + let payload = QRCodePayload( + string: observation.payloadStringValue, + data: observation.payloadData + ) + guard payload.data != lastPayloadData || payload.string != lastPayloadString else { return } + + lastPayloadData = payload.data + lastPayloadString = payload.string + DispatchQueue.main.async { [onScan] in + onScan(payload) } } } @@ -65,7 +228,7 @@ private struct ScannerCornerButtons: View { struct Scanner: View { @Environment(CameraManager.self) private var cameraManager - let onScan: (String) async -> Void + let onScan: (QRCodePayload) async -> Void let onImageSelection: (PhotosPickerItem?) async -> Void @State private var isTorchOn = false @@ -73,12 +236,16 @@ struct Scanner: View { var body: some View { ZStack { if cameraManager.hasPermission { - ScannerCamera( - isTorchOn: isTorchOn, - onScan: { uri in - await onScan(uri) - } - ) + #if targetEnvironment(simulator) + Color.black + #else + ScannerCamera( + isTorchOn: isTorchOn, + onScan: { payload in + await onScan(payload) + } + ) + #endif ScannerCornerButtons( isTorchOn: $isTorchOn, diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index 17dd716ae..8cf300667 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -1,6 +1,5 @@ import PhotosUI import SwiftUI -import Vision enum ScannerContext { case addContact @@ -20,6 +19,7 @@ class ScannerManager: ObservableObject { private var sheets: SheetViewModel? private var wallet: WalletViewModel? private weak var hwWalletManager: HwWalletManager? + private var isHandlingScan = false func configure( app: AppViewModel, @@ -44,6 +44,14 @@ class ScannerManager: ObservableObject { } func handleScan(_ uri: String, context: ScannerContext) async { + guard !isHandlingScan else { return } + isHandlingScan = true + defer { isHandlingScan = false } + + await processScan(uri, context: context) + } + + private func processScan(_ uri: String, context: ScannerContext) async { let uri = uri.trimmingCharacters(in: .whitespacesAndNewlines) guard !uri.isEmpty else { return } @@ -55,12 +63,20 @@ class ScannerManager: ObservableObject { case .main: await handleMainScan(uri) case .send: - await handleSendScan(uri) { _ in } + await processSendScan(uri) { _ in } case .electrum: await handleElectrumScan(uri) } } + func handleScan(_ payload: QRCodePayload, context: ScannerContext) async { + guard let uri = payload.string else { + showUnsupportedQRCodeError() + return + } + await handleScan(uri, context: context) + } + private func handleAddContactScan(_ input: String) { navigation?.navigateBack() @@ -133,6 +149,18 @@ class ScannerManager: ObservableObject { _ uri: String, scope: ScanHandlingScope = .unrestricted, completion: @escaping (SendRoute?) -> Void + ) async { + guard !isHandlingScan else { return } + isHandlingScan = true + defer { isHandlingScan = false } + + await processSendScan(uri, scope: scope, completion: completion) + } + + private func processSendScan( + _ uri: String, + scope: ScanHandlingScope = .unrestricted, + completion: @escaping (SendRoute?) -> Void ) async { guard let app, let currency, let settings else { completion(nil) @@ -185,6 +213,19 @@ class ScannerManager: ObservableObject { } } + func handleSendScan( + _ payload: QRCodePayload, + scope: ScanHandlingScope = .unrestricted, + completion: @escaping (SendRoute?) -> Void + ) async { + guard let uri = payload.string else { + showUnsupportedQRCodeError() + completion(nil) + return + } + await handleSendScan(uri, scope: scope, completion: completion) + } + private func shouldOpenPaymentFlow(for uri: String) -> Bool { !SamRockSetupRequest.isProtocolURL(uri) && !PubkyAuthRequest.isProtocolURL(uri) } @@ -243,95 +284,42 @@ class ScannerManager: ObservableObject { guard let app, let item else { return } do { - guard let data = try await item.loadTransferable(type: Data.self), - let image = UIImage(data: data) - else { - app.toast( - type: .error, - title: t("common__error"), - description: t("other__qr_error_load_image") - ) - return - } - - guard let cgImage = image.cgImage else { - app.toast( - type: .error, - title: t("common__error"), - description: t("other__qr_error_process_image") - ) - return - } - - let request = VNDetectBarcodesRequest { [weak self] request, error in - if let error { - Logger.error(error, context: "QR detection failed") - DispatchQueue.main.async { - app.toast( - type: .error, - title: t("other__qr_error_detection_title"), - description: t("other__qr_error_detection_description") - ) - } - return - } - - guard let results = request.results as? [VNBarcodeObservation] else { - Logger.error("No barcode results found") - DispatchQueue.main.async { - app.toast( - type: .error, - title: t("other__qr_error_no_qr_title"), - description: t("other__qr_error_no_qr_description") - ) - } - return - } - - let qrResults = results.filter { $0.symbology == .qr } - - guard let firstResult = qrResults.first, - let payload = firstResult.payloadStringValue - else { - DispatchQueue.main.async { - app.toast( - type: .error, - title: t("other__qr_error_no_qr_title"), - description: t("other__qr_error_no_qr_description") - ) - } - return - } - - DispatchQueue.main.async { - if context == .send { - Task { - await self?.handleSendScan(payload, scope: scope, completion: completion) - } - } else { - Task { - await self?.handleScan(payload, context: context) - } - } - } + let payload = try await QRCodeImageDecoder.decode(item) + if context == .send { + await handleSendScan(payload, scope: scope, completion: completion) + } else { + await handleScan(payload, context: context) } - - #if targetEnvironment(simulator) && compiler(>=5.7) - request.revision = VNDetectBarcodesRequestRevision3 - #endif - - let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) - try handler.perform([request]) + } catch QRCodeImageDecoderError.invalidImage { + app.toast( + type: .error, + title: t("common__error"), + description: t("other__qr_error_load_image") + ) + } catch QRCodeImageDecoderError.noQRCode { + app.toast( + type: .error, + title: t("other__qr_error_no_qr_title"), + description: t("other__qr_error_no_qr_description") + ) } catch { Logger.error(error, context: "Failed to process image") app.toast( type: .error, - title: t("common__error"), - description: t("other__qr_error_generic_description") + title: t("other__qr_error_detection_title"), + description: t("other__qr_error_detection_description") ) } } + private func showUnsupportedQRCodeError() { + app?.toast( + type: .error, + title: t("other__qr_error_header"), + description: t("other__qr_error_text") + ) + } + func handleManualEntry( _ value: String, context: ScannerContext, diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 1c96d453e..fdea49588 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -424,6 +424,8 @@ "onboarding__create_new_wallet" = "Create New Wallet"; "onboarding__restore_header" = "Restore\nyour wallet"; "onboarding__restore_phrase" = "Please type in your recovery phrase from any (paper) backup."; +"onboarding__restore_scan_seedqr" = "Scan SeedQR"; +"onboarding__restore_seedqr_error" = "This QR code does not contain a valid 12-word SeedQR."; "onboarding__restore_red_explain" = "If a word is shown in red, it means that it was not found in the recovery phrase dictionary. Check for spelling errors."; "onboarding__restore_inv_checksum" = "The checksum for the recovery phrase appears to be incorrect. Please double check your recovery phrase."; "onboarding__restore_suggestions" = "SUGGESTIONS"; diff --git a/Bitkit/Utilities/QRCodePayload.swift b/Bitkit/Utilities/QRCodePayload.swift new file mode 100644 index 000000000..b30022324 --- /dev/null +++ b/Bitkit/Utilities/QRCodePayload.swift @@ -0,0 +1,43 @@ +import Foundation +import PhotosUI +import SwiftUI +import Vision + +struct QRCodePayload: Sendable { + let string: String? + let data: Data? +} + +enum QRCodeImageDecoderError: Error { + case invalidImage + case noQRCode +} + +enum QRCodeImageDecoder { + static func decode(_ item: PhotosPickerItem) async throws -> QRCodePayload { + guard let data = try await item.loadTransferable(type: Data.self) else { + throw QRCodeImageDecoderError.invalidImage + } + + return try await Task.detached { + let request = VNDetectBarcodesRequest() + request.symbologies = [.qr] + + #if targetEnvironment(simulator) && compiler(>=5.7) + request.revision = VNDetectBarcodesRequestRevision3 + #endif + + let handler = VNImageRequestHandler(data: data, options: [:]) + try handler.perform([request]) + + guard let observation = request.results?.first else { + throw QRCodeImageDecoderError.noQRCode + } + + return QRCodePayload( + string: observation.payloadStringValue, + data: observation.payloadData + ) + }.value + } +} diff --git a/Bitkit/Utilities/SeedQRCodeDecoder.swift b/Bitkit/Utilities/SeedQRCodeDecoder.swift new file mode 100644 index 000000000..e9331b1f3 --- /dev/null +++ b/Bitkit/Utilities/SeedQRCodeDecoder.swift @@ -0,0 +1,85 @@ +import BitkitCore +import Foundation + +enum SeedQRCodeDecoderError: Error { + case invalidPayload +} + +enum SeedQRCodeDecoder { + private static let compactEntropyLength = 16 + + static func decode(_ payload: QRCodePayload) throws -> String { + if let string = payload.string, + let mnemonic = try decodeStandard(string) + { + return mnemonic + } + + if let data = payload.data { + if let string = String(data: data, encoding: .utf8), + let mnemonic = try decodeStandard(string) + { + return mnemonic + } + + guard let entropy = decodeCompactEntropy(data) else { + throw SeedQRCodeDecoderError.invalidPayload + } + + do { + return try decodeCompactSeedQr(entropy: entropy) + } catch { + throw SeedQRCodeDecoderError.invalidPayload + } + } + + throw SeedQRCodeDecoderError.invalidPayload + } + + private static func decodeStandard(_ payload: String) throws -> String? { + do { + return try decodeStandardSeedQr(payload: payload) + } catch { + return nil + } + } + + private static func decodeCompactEntropy(_ payload: Data) -> Data? { + var reader = SeedQRBitReader(data: payload) + guard reader.read(bitCount: 4) == 4, + reader.read(bitCount: 8) == compactEntropyLength + else { + return nil + } + + var entropy = Data() + entropy.reserveCapacity(compactEntropyLength) + for _ in 0 ..< compactEntropyLength { + guard let byte = reader.read(bitCount: 8) else { return nil } + entropy.append(UInt8(byte)) + } + return entropy + } +} + +private struct SeedQRBitReader { + let data: Data + private var bitOffset = 0 + + init(data: Data) { + self.data = data + } + + mutating func read(bitCount: Int) -> Int? { + guard bitCount > 0, bitOffset + bitCount <= data.count * 8 else { return nil } + + var value = 0 + for _ in 0 ..< bitCount { + let byte = data[bitOffset / 8] + let shift = 7 - (bitOffset % 8) + value = (value << 1) | Int((byte >> shift) & 1) + bitOffset += 1 + } + return value + } +} diff --git a/Bitkit/Views/Onboarding/RestoreWalletView.swift b/Bitkit/Views/Onboarding/RestoreWalletView.swift index 3d960b6c5..684660fd6 100644 --- a/Bitkit/Views/Onboarding/RestoreWalletView.swift +++ b/Bitkit/Views/Onboarding/RestoreWalletView.swift @@ -125,6 +125,22 @@ struct RestoreWalletView: View { } } .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + SeedQRCodeScannerView { mnemonic in + handleScannedMnemonic(mnemonic) + } + } label: { + Image("scan") + .resizable() + .foregroundColor(.textPrimary) + .frame(width: 32, height: 32) + } + .accessibilityLabel(t("onboarding__restore_scan_seedqr")) + .accessibilityIdentifier("RestoreSeedQR") + } + } .safeAreaInset(edge: .bottom) { keyboardAccessory } @@ -289,6 +305,15 @@ struct RestoreWalletView: View { // Close the keyboard focusedField = nil } + + private func handleScannedMnemonic(_ mnemonic: String) { + let scannedWords = mnemonic.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + guard scannedWords.count == wordCount else { return } + + words = scannedWords + firstFieldText = scannedWords[0] + focusedField = nil + } } #Preview { diff --git a/Bitkit/Views/Onboarding/SeedQRCodeScannerView.swift b/Bitkit/Views/Onboarding/SeedQRCodeScannerView.swift new file mode 100644 index 000000000..db0abd206 --- /dev/null +++ b/Bitkit/Views/Onboarding/SeedQRCodeScannerView.swift @@ -0,0 +1,59 @@ +import PhotosUI +import SwiftUI + +struct SeedQRCodeScannerView: View { + @EnvironmentObject private var app: AppViewModel + @Environment(\.dismiss) private var dismiss + + let onScan: (String) -> Void + + var body: some View { + Scanner( + onScan: { payload in + handlePayload(payload) + }, + onImageSelection: { item in + await handleImageSelection(item) + } + ) + .screenshotPreventMask(true) + .padding(.horizontal, 16) + .padding(.bottom, 16) + .navigationTitle(t("onboarding__restore_scan_seedqr")) + .navigationBarTitleDisplayMode(.inline) + } + + private func handleImageSelection(_ item: PhotosPickerItem?) async { + guard let item else { return } + + do { + let payload = try await QRCodeImageDecoder.decode(item) + await MainActor.run { + handlePayload(payload) + } + } catch { + await MainActor.run { + showInvalidSeedQRError() + } + } + } + + private func handlePayload(_ payload: QRCodePayload) { + do { + let mnemonic = try SeedQRCodeDecoder.decode(payload) + Haptics.play(.scanSuccess) + onScan(mnemonic) + dismiss() + } catch { + showInvalidSeedQRError() + } + } + + private func showInvalidSeedQRError() { + app.toast( + type: .error, + title: t("other__qr_error_header"), + description: t("onboarding__restore_seedqr_error") + ) + } +} diff --git a/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift b/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift index 1627e223e..bbabbd66c 100644 --- a/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift +++ b/Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift @@ -18,7 +18,10 @@ struct ElectrumSettingsScreen: View { Image("scan") .resizable() .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) .frame(width: 32, height: 32) + .padding(.leading, 16) + .contentShape(Rectangle()) } .accessibilityIdentifier("NavigationAction")) ) diff --git a/Bitkit/Views/Settings/ProbingTool/ProbingToolScannerSheet.swift b/Bitkit/Views/Settings/ProbingTool/ProbingToolScannerSheet.swift index e521599f4..89b9ab798 100644 --- a/Bitkit/Views/Settings/ProbingTool/ProbingToolScannerSheet.swift +++ b/Bitkit/Views/Settings/ProbingTool/ProbingToolScannerSheet.swift @@ -11,7 +11,8 @@ struct ProbingToolScannerSheet: View { VStack(alignment: .leading, spacing: 0) { Scanner( - onScan: { uri in + onScan: { payload in + guard let uri = payload.string else { return } await MainActor.run { invoice = uri.trimmingCharacters(in: .whitespacesAndNewlines) onScanned() diff --git a/BitkitTests/SeedQRCodeDecoderTests.swift b/BitkitTests/SeedQRCodeDecoderTests.swift new file mode 100644 index 000000000..c61490a2d --- /dev/null +++ b/BitkitTests/SeedQRCodeDecoderTests.swift @@ -0,0 +1,80 @@ +@testable import Bitkit +import Foundation +import XCTest + +final class SeedQRCodeDecoderTests: XCTestCase { + func testDecodesStandardSeedQR() throws { + let payload = QRCodePayload( + string: "073318950739065415961602009907670428187212261116", + data: nil + ) + + let mnemonic = try SeedQRCodeDecoder.decode(payload) + + XCTAssertEqual(mnemonic, "forum undo fragile fade shy sign arrest garment culture tube off merit") + } + + func testDecodesCompactSeedQR() throws { + let payload = QRCodePayload( + string: nil, + data: Data([0x41, 0x05, 0xBB, 0xD9, 0xD7, 0x1A, 0x8E, 0xC7, 0x99, 0x08, 0x31, 0xAF, 0xF3, 0x59, 0xD4, 0x26, 0x54, 0x50, 0xEC]) + ) + + let mnemonic = try SeedQRCodeDecoder.decode(payload) + + XCTAssertEqual(mnemonic, "forum undo fragile fade shy sign arrest garment culture tube off merit") + } + + func testDecodesCompactSeedQRContainingNullBytes() throws { + let payload = QRCodePayload( + string: nil, + data: Data([0x41] + Array(repeating: 0, count: 17) + [0xEC]) + ) + + let mnemonic = try SeedQRCodeDecoder.decode(payload) + + XCTAssertEqual(mnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") + } + + func testRejectsRawCompactEntropy() { + let payload = QRCodePayload( + string: nil, + data: Data([0x5B, 0xBD, 0x9D, 0x71, 0xA8, 0xEC, 0x79, 0x90, 0x83, 0x1A, 0xFF, 0x35, 0x9D, 0x42, 0x65, 0x45]) + ) + + XCTAssertThrowsError(try SeedQRCodeDecoder.decode(payload)) + } + + func testDecodesStandardSeedQRFromRawData() throws { + let standardPayload = "008607501025021714880023171503630517020917211425" + let payload = QRCodePayload(string: nil, data: Data(standardPayload.utf8)) + + let mnemonic = try SeedQRCodeDecoder.decode(payload) + + XCTAssertEqual(mnemonic, "approve fruit lens brass ring actual stool coin doll boss strong rate") + } + + func testRejectsStandardSeedQRWithInvalidChecksum() { + let payload = QRCodePayload( + string: String(repeating: "0000", count: 12), + data: nil + ) + + XCTAssertThrowsError(try SeedQRCodeDecoder.decode(payload)) + } + + func testRejectsOutOfRangeWordIndex() { + let payload = QRCodePayload( + string: "2048" + String(repeating: "0000", count: 11), + data: nil + ) + + XCTAssertThrowsError(try SeedQRCodeDecoder.decode(payload)) + } + + func testRejectsUnsupportedPayloadLength() { + let payload = QRCodePayload(string: nil, data: Data(repeating: 0, count: 15)) + + XCTAssertThrowsError(try SeedQRCodeDecoder.decode(payload)) + } +} diff --git a/changelog.d/next/760.added.md b/changelog.d/next/760.added.md new file mode 100644 index 000000000..765f28659 --- /dev/null +++ b/changelog.d/next/760.added.md @@ -0,0 +1 @@ +Added support for restoring wallets from Standard and Compact SeedQR codes.