Fill in payments by scanning a QR code or picking one from photos
This commit is contained in:
@@ -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..<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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user