Fill in payments by scanning a QR code or picking one from photos
This commit is contained in:
@@ -23,6 +23,8 @@
|
|||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Scan a QR code to fill in a payment address.</string>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ enum SelfTest {
|
|||||||
emit("KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)")
|
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))),
|
expect("ripemd160", Hex.encode(RIPEMD160.hash(Array("abc".utf8))),
|
||||||
"8eb208f7e05d987a9b044a8e98c6b087f15a0bfc")
|
"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.
|
// secp256k1 signing must produce a low-S DER signature.
|
||||||
if let keypair = try? XRPKey.keypair(fromSeed: secpSeed),
|
if let keypair = try? XRPKey.keypair(fromSeed: secpSeed),
|
||||||
let message = Hex.decode(signingHex),
|
let message = Hex.decode(signingHex),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,15 +166,7 @@ struct QRCodeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func makeImage(from text: String) -> UIImage? {
|
private static func makeImage(from text: String) -> UIImage? {
|
||||||
guard !text.isEmpty else { return nil }
|
QRCodeImage.make(text)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@@ -124,6 +125,9 @@ struct SendView: View {
|
|||||||
@State private var tag = ""
|
@State private var tag = ""
|
||||||
@State private var priority: TransactionPriority = .defaultPriority
|
@State private var priority: TransactionPriority = .defaultPriority
|
||||||
@State private var localError: String?
|
@State private var localError: String?
|
||||||
|
@State private var status: String?
|
||||||
|
@State private var showScanner = false
|
||||||
|
@State private var photoItem: PhotosPickerItem?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
@@ -131,6 +135,20 @@ struct SendView: View {
|
|||||||
subtitle: "Balance \(model.balance.total) \(model.symbol)",
|
subtitle: "Balance \(model.balance.total) \(model.symbol)",
|
||||||
onBack: { route = .home })
|
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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
|
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
|
||||||
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
|
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
|
||||||
@@ -163,6 +181,9 @@ struct SendView: View {
|
|||||||
if let message = localError ?? model.errorMessage {
|
if let message = localError ?? model.errorMessage {
|
||||||
MessageBanner(message: message, isError: true)
|
MessageBanner(message: message, isError: true)
|
||||||
}
|
}
|
||||||
|
if let message = status {
|
||||||
|
MessageBanner(message: message, isError: false)
|
||||||
|
}
|
||||||
if let message = model.statusMessage {
|
if let message = model.statusMessage {
|
||||||
MessageBanner(message: message, isError: false)
|
MessageBanner(message: message, isError: false)
|
||||||
}
|
}
|
||||||
@@ -184,6 +205,65 @@ struct SendView: View {
|
|||||||
|
|
||||||
Spacer(minLength: 0)
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,15 @@ struct SeedBox: Identifiable {
|
|||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@EnvironmentObject private var model: AppModel
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var route: Route = ProcessInfo.processInfo.arguments.contains("-koinsScreenAccounts")
|
@State private var route: Route = RootView.initialRoute
|
||||||
? .accounts : .home
|
|
||||||
|
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?
|
@State private var seedBox: SeedBox?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ targets:
|
|||||||
UISupportedInterfaceOrientations:
|
UISupportedInterfaceOrientations:
|
||||||
- UIInterfaceOrientationPortrait
|
- UIInterfaceOrientationPortrait
|
||||||
UIUserInterfaceStyle: Dark
|
UIUserInterfaceStyle: Dark
|
||||||
|
NSCameraUsageDescription: Scan a QR code to fill in a payment address.
|
||||||
NSAppTransportSecurity:
|
NSAppTransportSecurity:
|
||||||
NSAllowsArbitraryLoads: true
|
NSAllowsArbitraryLoads: true
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user