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.