Files

135 lines
4.9 KiB
Swift

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..<separator])
let queryText = String(text[text.index(after: separator)...])
for pair in queryText.split(separator: "&") {
let parts = pair.split(separator: "=", maxSplits: 1)
guard let key = parts.first else { continue }
let value = parts.count > 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))
}
}