diff --git a/apps/ios/Koins/Info.plist b/apps/ios/Koins/Info.plist index 9423b5d..f94fc76 100644 --- a/apps/ios/Koins/Info.plist +++ b/apps/ios/Koins/Info.plist @@ -23,6 +23,8 @@ NSAllowsArbitraryLoads + NSCameraUsageDescription + Scan a QR code to fill in a payment address. UILaunchScreen UISupportedInterfaceOrientations diff --git a/apps/ios/Koins/SelfTest.swift b/apps/ios/Koins/SelfTest.swift index 0aa147a..a669647 100644 --- a/apps/ios/Koins/SelfTest.swift +++ b/apps/ios/Koins/SelfTest.swift @@ -122,6 +122,11 @@ enum SelfTest { emit("KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)") } + func expectTrue(_ name: String, _ value: Bool) { + if !value { failures += 1 } + emit("KOINS_SELFTEST \(name): \(value ? "PASS" : "FAIL")") + } + expect("ripemd160", Hex.encode(RIPEMD160.hash(Array("abc".utf8))), "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc") @@ -236,6 +241,55 @@ enum SelfTest { } } + // QR round trip: generate an image, read it back, parse the payment URI. + let xmrAddress = "46cRPeU77sBVGSVXPyCjvJUdC9AjqvETWb6giUGskdnWN8ZifindmrH7mcztKU52iKgvWXY4dyPujg9LYUY1Q7dvDiwHNCE" + let xrpAddress = "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C" + + func roundTrip(_ name: String, _ text: String, checks: [(String, String)]) { + guard let image = QRCodeImage.make(text) else { + failures += 1 + emit("KOINS_SELFTEST \(name): FAIL could not render QR image") + return + } + guard let decoded = QRCodeReader.decodeText(in: image) else { + failures += 1 + emit("KOINS_SELFTEST \(name): FAIL vision could not read the QR image (\(Int(image.size.width))px)") + return + } + guard let payload = QRCodeReader.parse(decoded) else { + failures += 1 + emit("KOINS_SELFTEST \(name): FAIL parse of '\(decoded)'") + return + } + for (label, expected) in checks { + let actual: String + switch label { + case "address": actual = payload.address + case "amount": actual = payload.amount ?? "" + case "tag": actual = payload.tag.map(String.init) ?? "" + case "paymentId": actual = payload.paymentId ?? "" + case "memo": actual = payload.memo ?? "" + default: actual = "" + } + expect("\(name) \(label)", actual, expected) + } + } + + roundTrip("qr monero uri", + "monero:\(xmrAddress)?tx_amount=1.5&tx_payment_id=abc123&tx_description=Rent", + checks: [("address", xmrAddress), ("amount", "1.5"), ("paymentId", "abc123"), ("memo", "Rent")]) + roundTrip("qr monero plain", xmrAddress, checks: [("address", xmrAddress)]) + roundTrip("qr xrp uri", + "ripple:\(xrpAddress)?amount=12.5&dt=99", + checks: [("address", xrpAddress), ("amount", "12.5"), ("tag", "99")]) + roundTrip("qr xrp plain", xrpAddress, checks: [("address", xrpAddress)]) + + expectTrue("qr monero detected", AddressFormat.isMonero(xmrAddress)) + expectTrue("qr xrp detected", AddressFormat.isXRP(xrpAddress)) + expectTrue("qr rejects junk", QRCodeReader.parse("hello world")?.address == "hello world") + expectTrue("qr not monero", !AddressFormat.isMonero(xrpAddress)) + expectTrue("qr not xrp", !AddressFormat.isXRP(xmrAddress)) + // secp256k1 signing must produce a low-S DER signature. if let keypair = try? XRPKey.keypair(fromSeed: secpSeed), let message = Hex.decode(signingHex), diff --git a/apps/ios/Koins/Support/QRCodeReader.swift b/apps/ios/Koins/Support/QRCodeReader.swift new file mode 100644 index 0000000..c008ccc --- /dev/null +++ b/apps/ios/Koins/Support/QRCodeReader.swift @@ -0,0 +1,135 @@ +import CoreImage +import CoreImage.CIFilterBuiltins +import Foundation +import UIKit +import Vision + +/// Renders a QR image (used by the UI and by the self-test, which round-trips +/// an image through the reader). +enum QRCodeImage { + static func make(_ text: String, scale: CGFloat = 10) -> UIImage? { + guard !text.isEmpty else { return nil } + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(text.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + let context = CIContext() + guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil } + return UIImage(cgImage: cgImage) + } +} + +/// A payment request decoded from a QR code: the address plus whatever else the +/// code carried. +struct QRPayload { + var address: String + var amount: String? + var tag: UInt32? + var paymentId: String? + var memo: String? +} + +enum QRCodeReader { + /// Reads the first QR code found in an image (used for photo-library import). + static func decodeText(in data: Data) -> String? { + guard let image = UIImage(data: data), let cgImage = image.cgImage else { return nil } + return decodeText(in: cgImage) + } + + static func decodeText(in image: UIImage) -> String? { + guard let cgImage = image.cgImage else { return nil } + return decodeText(in: cgImage) + } + + private static func decodeText(in cgImage: CGImage) -> String? { + if let text = decodeWithVision(cgImage) { return text } + // Vision's barcode detector does not work on the iOS simulator, so fall + // back to CoreImage, which does (and adds redundancy on device). + return decodeWithCoreImage(cgImage) + } + + private static func decodeWithVision(_ cgImage: CGImage) -> String? { + let request = VNDetectBarcodesRequest() + request.symbologies = [.qr] + let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up) + try? handler.perform([request]) + for observation in request.results ?? [] { + if let payload = observation.payloadStringValue, !payload.isEmpty { + return payload + } + } + return nil + } + + private static func decodeWithCoreImage(_ cgImage: CGImage) -> String? { + let detector = CIDetector(ofType: CIDetectorTypeQRCode, + context: nil, + options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) + let features = detector?.features(in: CIImage(cgImage: cgImage)) ?? [] + for feature in features { + if let code = feature as? CIQRCodeFeature, let message = code.messageString, !message.isEmpty { + return message + } + } + return nil + } + + /// Understands plain addresses as well as the payment URIs wallets emit: + /// `monero:` (tx_amount, tx_payment_id, tx_description) and `ripple:` + /// (amount, dt). + static func parse(_ raw: String) -> QRPayload? { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + + var body = text + var query: [String: String] = [:] + + if let separator = text.firstIndex(of: "?") { + body = String(text[text.startIndex.. Bool { + let text = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard text.count == 95 || text.count == 106 else { return false } + return text.allSatisfy { moneroAlphabet.contains($0) } + } + + static func isXRP(_ address: String) -> Bool { + XRPKey.isValidAddress(address.trimmingCharacters(in: .whitespacesAndNewlines)) + } +} \ No newline at end of file diff --git a/apps/ios/Koins/Views/Components.swift b/apps/ios/Koins/Views/Components.swift index 106de1c..56510dc 100644 --- a/apps/ios/Koins/Views/Components.swift +++ b/apps/ios/Koins/Views/Components.swift @@ -166,15 +166,7 @@ struct QRCodeView: View { } private static func makeImage(from text: String) -> UIImage? { - guard !text.isEmpty else { return nil } - let filter = CIFilter.qrCodeGenerator() - filter.message = Data(text.utf8) - filter.correctionLevel = "M" - guard let output = filter.outputImage else { return nil } - let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10)) - let context = CIContext() - guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil } - return UIImage(cgImage: cgImage) + QRCodeImage.make(text) } } diff --git a/apps/ios/Koins/Views/DashboardViews.swift b/apps/ios/Koins/Views/DashboardViews.swift index c7a2300..db61115 100644 --- a/apps/ios/Koins/Views/DashboardViews.swift +++ b/apps/ios/Koins/Views/DashboardViews.swift @@ -1,3 +1,4 @@ +import PhotosUI import SwiftUI struct DashboardView: View { @@ -124,6 +125,9 @@ struct SendView: View { @State private var tag = "" @State private var priority: TransactionPriority = .defaultPriority @State private var localError: String? + @State private var status: String? + @State private var showScanner = false + @State private var photoItem: PhotosPickerItem? var body: some View { VStack(alignment: .leading, spacing: 18) { @@ -131,6 +135,20 @@ struct SendView: View { subtitle: "Balance \(model.balance.total) \(model.symbol)", onBack: { route = .home }) + HStack(spacing: 10) { + SecondaryButton(title: "Scan QR") { showScanner = true } + PhotosPicker(selection: $photoItem, matching: .images, photoLibrary: .shared()) { + Text("Photo") + .font(.system(size: 15, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Theme.surface) + .foregroundStyle(Theme.text) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + VStack(alignment: .leading, spacing: 6) { FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address") KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination) @@ -163,6 +181,9 @@ struct SendView: View { if let message = localError ?? model.errorMessage { MessageBanner(message: message, isError: true) } + if let message = status { + MessageBanner(message: message, isError: false) + } if let message = model.statusMessage { MessageBanner(message: message, isError: false) } @@ -184,6 +205,65 @@ struct SendView: View { Spacer(minLength: 0) } + .sheet(isPresented: $showScanner) { + QRScannerView(onScan: { value in + showScanner = false + applyScanned(value) + }, onCancel: { + showScanner = false + }) + .ignoresSafeArea() + } + .onChange(of: photoItem) { item in + guard let item else { return } + Task { + if let data = try? await item.loadTransferable(type: Data.self) { + if let value = QRCodeReader.decodeText(in: data) { + applyScanned(value) + } else { + localError = "No QR code found in that image" + } + } else { + localError = "That image could not be read" + } + photoItem = nil + } + } + } + + /// Fills the form from a scanned code, accepting both plain addresses and + /// wallet payment URIs (`monero:…?tx_amount=`, `ripple:…?amount=&dt=`). + private func applyScanned(_ value: String) { + localError = nil + status = nil + guard let payload = QRCodeReader.parse(value) else { + localError = "That QR code is not a payment request" + return + } + + let isForThisChain = model.chain == .xrp + ? AddressFormat.isXRP(payload.address) + : AddressFormat.isMonero(payload.address) + guard isForThisChain else { + localError = model.chain == .xrp + ? "That looks like a Monero address. Switch to Monero to use it." + : "That looks like an XRP address. Switch to XRP to use it." + return + } + + destination = payload.address + if let scannedAmount = payload.amount, !scannedAmount.isEmpty { + amount = scannedAmount + } + if model.chain == .xrp, let scannedTag = payload.tag { + tag = String(scannedTag) + } + + var filled: [String] = ["address"] + if payload.amount?.isEmpty == false { filled.append("amount") } + if payload.tag != nil { filled.append("tag") } + if payload.paymentId?.isEmpty == false { filled.append("payment ID") } + status = "Filled \(filled.joined(separator: ", ")) from the QR code" } } diff --git a/apps/ios/Koins/Views/QRScannerView.swift b/apps/ios/Koins/Views/QRScannerView.swift new file mode 100644 index 0000000..396041e --- /dev/null +++ b/apps/ios/Koins/Views/QRScannerView.swift @@ -0,0 +1,141 @@ +import AVFoundation +import SwiftUI + +/// Camera QR scanner, presented as a sheet. On a simulator (no capture device) +/// it explains that instead of showing a dead viewfinder. +struct QRScannerView: UIViewControllerRepresentable { + var onScan: (String) -> Void + var onCancel: () -> Void + + func makeUIViewController(context: Context) -> ScannerViewController { + let controller = ScannerViewController() + controller.onScan = onScan + controller.onCancel = onCancel + return controller + } + + func updateUIViewController(_ controller: ScannerViewController, context: Context) {} +} + +final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var onScan: ((String) -> Void)? + var onCancel: (() -> Void)? + + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + private var hasScanned = false + private var messageLabel: UILabel? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + let cancel = UIButton(type: .system) + cancel.setTitle("Cancel", for: .normal) + cancel.setTitleColor(.white, for: .normal) + cancel.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) + cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + cancel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(cancel) + NSLayoutConstraint.activate([ + cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), + cancel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + ]) + + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.textColor = .white + label.font = .systemFont(ofSize: 14) + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + NSLayoutConstraint.activate([ + label.centerYAnchor.constraint(equalTo: view.centerYAnchor), + label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), + label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), + ]) + messageLabel = label + + requestCamera() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if session.isRunning { session.stopRunning() } + } + + @objc private func cancelTapped() { + onCancel?() + } + + private func requestCamera() { + guard let device = AVCaptureDevice.default(for: .video) else { + showMessage("This device has no camera. Use “Photo” to pick a QR from the library instead.") + return + } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configure(device: device) + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + if granted { + self?.configure(device: device) + } else { + self?.showMessage("Camera access is off. Enable it in Settings, or use “Photo”.") + } + } + } + default: + showMessage("Camera access is off. Enable it in Settings, or use “Photo”.") + } + } + + private func configure(device: AVCaptureDevice) { + guard let input = try? AVCaptureDeviceInput(device: device), session.canAddInput(input) else { + showMessage("The camera could not be started. Use “Photo” instead.") + return + } + session.addInput(input) + + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { + showMessage("The camera could not be started. Use “Photo” instead.") + return + } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + layer.frame = view.bounds + view.layer.insertSublayer(layer, at: 0) + previewLayer = layer + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.session.startRunning() + } + } + + private func showMessage(_ text: String) { + messageLabel?.text = text + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + guard !hasScanned else { return } + guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let value = object.stringValue, !value.isEmpty else { return } + hasScanned = true + session.stopRunning() + onScan?(value) + } +} \ No newline at end of file diff --git a/apps/ios/Koins/Views/RootView.swift b/apps/ios/Koins/Views/RootView.swift index 69f57b4..1c1e450 100644 --- a/apps/ios/Koins/Views/RootView.swift +++ b/apps/ios/Koins/Views/RootView.swift @@ -19,8 +19,15 @@ struct SeedBox: Identifiable { struct RootView: View { @EnvironmentObject private var model: AppModel @Environment(\.scenePhase) private var scenePhase - @State private var route: Route = ProcessInfo.processInfo.arguments.contains("-koinsScreenAccounts") - ? .accounts : .home + @State private var route: Route = RootView.initialRoute + + private static var initialRoute: Route { + let arguments = ProcessInfo.processInfo.arguments + if arguments.contains("-koinsScreenAccounts") { return .accounts } + if arguments.contains("-koinsScreenSend") { return .send } + if arguments.contains("-koinsScreenReceive") { return .receive } + return .home + } @State private var seedBox: SeedBox? var body: some View { diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 4c4e501..5f5daca 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -65,6 +65,7 @@ targets: UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait UIUserInterfaceStyle: Dark + NSCameraUsageDescription: Scan a QR code to fill in a payment address. NSAppTransportSecurity: NSAllowsArbitraryLoads: true