135 lines
4.9 KiB
Swift
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))
|
|
}
|
|
} |