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.. 1 ? String(parts[1]) : ""
+ query[key.lowercased()] = value.removingPercentEncoding ?? value
+ }
+ }
+
+ let lowered = body.lowercased()
+ if let range = lowered.range(of: "monero:") {
+ body = String(body[range.upperBound...])
+ } else if let range = lowered.range(of: "ripple:") {
+ body = String(body[range.upperBound...])
+ }
+ while body.hasPrefix("/") { body.removeFirst() }
+
+ let address = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !address.isEmpty else { return nil }
+
+ var payload = QRPayload(address: address)
+ payload.amount = query["tx_amount"] ?? query["amount"]
+ payload.memo = query["tx_description"] ?? query["name"] ?? query["message"]
+ payload.paymentId = query["tx_payment_id"] ?? query["payment_id"] ?? query["pid"]
+ if let tag = query["dt"] ?? query["destination_tag"] ?? query["destinationtag"] {
+ payload.tag = UInt32(tag)
+ }
+ return payload
+ }
+}
+
+enum AddressFormat {
+ private static let moneroAlphabet = Set("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")
+
+ /// Monero addresses are 95 characters (primary/subaddress) or 106
+ /// (integrated), all base58.
+ static func isMonero(_ address: String) -> 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