Refactor QR scan and share and add QRS support
This commit is contained in:
@@ -1,249 +0,0 @@
|
||||
#if os(iOS)
|
||||
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class QRSScannerController: NSObject, ObservableObject {
|
||||
private var captureSession: AVCaptureSession?
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var metadataOutput: AVCaptureMetadataOutput?
|
||||
|
||||
@Published var decoder = LubyTransformDecoder()
|
||||
@Published var lastError: String?
|
||||
@Published var isComplete = false
|
||||
@Published var progress: Double = 0
|
||||
@Published var framesScanned = 0
|
||||
@Published var availableCameras: [AVCaptureDevice] = []
|
||||
@Published var selectedCamera: AVCaptureDevice?
|
||||
|
||||
private var seenBlockHashes = Set<Data>()
|
||||
|
||||
let previewView = UIView()
|
||||
var onComplete: ((Data) -> Void)?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
previewView.backgroundColor = .black
|
||||
refreshCameraList()
|
||||
}
|
||||
|
||||
func refreshCameraList() {
|
||||
var deviceTypes: [AVCaptureDevice.DeviceType] = [
|
||||
.builtInWideAngleCamera,
|
||||
.builtInUltraWideCamera,
|
||||
.builtInTelephotoCamera,
|
||||
]
|
||||
if #available(iOS 17.0, *) {
|
||||
deviceTypes.append(.external)
|
||||
}
|
||||
let discoverySession = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: deviceTypes,
|
||||
mediaType: .video,
|
||||
position: .unspecified
|
||||
)
|
||||
availableCameras = discoverySession.devices
|
||||
if selectedCamera == nil {
|
||||
selectedCamera = availableCameras.first
|
||||
}
|
||||
}
|
||||
|
||||
func selectCamera(_ camera: AVCaptureDevice) {
|
||||
guard camera.uniqueID != selectedCamera?.uniqueID else { return }
|
||||
selectedCamera = camera
|
||||
|
||||
if captureSession != nil {
|
||||
stopScanning()
|
||||
captureSession = nil
|
||||
previewLayer?.removeFromSuperlayer()
|
||||
previewLayer = nil
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
decoder = LubyTransformDecoder()
|
||||
seenBlockHashes.removeAll()
|
||||
lastError = nil
|
||||
isComplete = false
|
||||
progress = 0
|
||||
framesScanned = 0
|
||||
}
|
||||
|
||||
func startScanning() {
|
||||
guard captureSession == nil else {
|
||||
if captureSession?.isRunning == false {
|
||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||
self?.captureSession?.startRunning()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
setupCaptureSession()
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
if granted {
|
||||
self?.setupCaptureSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func stopScanning() {
|
||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||
self?.captureSession?.stopRunning()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupCaptureSession() {
|
||||
let session = AVCaptureSession()
|
||||
|
||||
guard let videoCaptureDevice = selectedCamera ?? AVCaptureDevice.default(for: .video) else {
|
||||
return
|
||||
}
|
||||
|
||||
let videoInput: AVCaptureDeviceInput
|
||||
do {
|
||||
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
guard session.canAddInput(videoInput) else { return }
|
||||
session.addInput(videoInput)
|
||||
|
||||
let metadataOutput = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(metadataOutput) else { return }
|
||||
session.addOutput(metadataOutput)
|
||||
metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
|
||||
metadataOutput.metadataObjectTypes = [.qr]
|
||||
|
||||
self.metadataOutput = metadataOutput
|
||||
captureSession = session
|
||||
|
||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
previewLayer.frame = previewView.bounds
|
||||
previewView.layer.addSublayer(previewLayer)
|
||||
self.previewLayer = previewLayer
|
||||
|
||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||
self?.captureSession?.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
func updatePreviewFrame(_ frame: CGRect) {
|
||||
previewLayer?.frame = frame
|
||||
}
|
||||
|
||||
private func processQRContent(_ content: String) {
|
||||
guard !isComplete else { return }
|
||||
|
||||
guard let block = EncodedBlock.fromBase64(content) else {
|
||||
return
|
||||
}
|
||||
|
||||
let hash = block.toBinary()
|
||||
guard !seenBlockHashes.contains(hash) else { return }
|
||||
seenBlockHashes.insert(hash)
|
||||
|
||||
framesScanned += 1
|
||||
|
||||
do {
|
||||
let complete = try decoder.addBlock(block)
|
||||
progress = decoder.progress
|
||||
|
||||
if complete {
|
||||
isComplete = true
|
||||
stopScanning()
|
||||
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
||||
|
||||
if let data = try? decoder.getDecoded() {
|
||||
onComplete?(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension QRSScannerController: AVCaptureMetadataOutputObjectsDelegate {
|
||||
nonisolated func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
Task { @MainActor in
|
||||
for metadataObject in metadataObjects {
|
||||
guard let readable = metadataObject as? AVMetadataMachineReadableCodeObject,
|
||||
let content = readable.stringValue
|
||||
else { continue }
|
||||
processQRContent(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct QRSScannerControllerView: UIViewControllerRepresentable {
|
||||
let controller: QRSScannerController
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
let viewController = QRSScannerViewController(controller: controller)
|
||||
return viewController
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
}
|
||||
|
||||
private class QRSScannerViewController: UIViewController {
|
||||
let controller: QRSScannerController
|
||||
|
||||
init(controller: QRSScannerController) {
|
||||
self.controller = controller
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
view.addSubview(controller.previewView)
|
||||
controller.previewView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
controller.previewView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
controller.previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
controller.previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
controller.previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
controller.updatePreviewFrame(view.bounds)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
controller.startScanning()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
controller.stopScanning()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,261 +0,0 @@
|
||||
#if os(macOS)
|
||||
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
import Vision
|
||||
|
||||
@MainActor
|
||||
final class QRSScannerController: NSObject, ObservableObject {
|
||||
private var captureSession: AVCaptureSession?
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var videoOutput: AVCaptureVideoDataOutput?
|
||||
|
||||
@Published var decoder = LubyTransformDecoder()
|
||||
@Published var lastError: String?
|
||||
@Published var isComplete = false
|
||||
@Published var progress: Double = 0
|
||||
@Published var framesScanned = 0
|
||||
@Published var availableCameras: [AVCaptureDevice] = []
|
||||
@Published var selectedCamera: AVCaptureDevice?
|
||||
|
||||
private var seenBlockHashes = Set<Data>()
|
||||
|
||||
let previewView = NSView()
|
||||
var onComplete: ((Data) -> Void)?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
previewView.wantsLayer = true
|
||||
previewView.layer?.backgroundColor = NSColor.black.cgColor
|
||||
refreshCameraList()
|
||||
}
|
||||
|
||||
func refreshCameraList() {
|
||||
let discoverySession = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.builtInWideAngleCamera, .externalUnknown],
|
||||
mediaType: .video,
|
||||
position: .unspecified
|
||||
)
|
||||
availableCameras = discoverySession.devices
|
||||
if selectedCamera == nil {
|
||||
selectedCamera = availableCameras.first
|
||||
}
|
||||
}
|
||||
|
||||
func selectCamera(_ camera: AVCaptureDevice) {
|
||||
guard camera.uniqueID != selectedCamera?.uniqueID else { return }
|
||||
selectedCamera = camera
|
||||
|
||||
if captureSession != nil {
|
||||
stopScanning()
|
||||
captureSession = nil
|
||||
previewLayer?.removeFromSuperlayer()
|
||||
previewLayer = nil
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
decoder = LubyTransformDecoder()
|
||||
seenBlockHashes.removeAll()
|
||||
lastError = nil
|
||||
isComplete = false
|
||||
progress = 0
|
||||
framesScanned = 0
|
||||
}
|
||||
|
||||
func startScanning() {
|
||||
guard captureSession == nil else {
|
||||
if captureSession?.isRunning == false {
|
||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||
self?.captureSession?.startRunning()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
setupCaptureSession()
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
if granted {
|
||||
self?.setupCaptureSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func stopScanning() {
|
||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||
self?.captureSession?.stopRunning()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupCaptureSession() {
|
||||
let session = AVCaptureSession()
|
||||
|
||||
guard let videoCaptureDevice = selectedCamera ?? AVCaptureDevice.default(for: .video) else {
|
||||
return
|
||||
}
|
||||
|
||||
let videoInput: AVCaptureDeviceInput
|
||||
do {
|
||||
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
guard session.canAddInput(videoInput) else { return }
|
||||
session.addInput(videoInput)
|
||||
|
||||
let videoOutput = AVCaptureVideoDataOutput()
|
||||
videoOutput.videoSettings = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
]
|
||||
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "QRSScannerQueue"))
|
||||
|
||||
guard session.canAddOutput(videoOutput) else { return }
|
||||
session.addOutput(videoOutput)
|
||||
|
||||
self.videoOutput = videoOutput
|
||||
captureSession = session
|
||||
|
||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
previewLayer.frame = previewView.bounds
|
||||
previewView.layer?.addSublayer(previewLayer)
|
||||
self.previewLayer = previewLayer
|
||||
|
||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||
self?.captureSession?.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
func updatePreviewFrame(_ frame: CGRect) {
|
||||
previewLayer?.frame = frame
|
||||
}
|
||||
|
||||
private func processQRContent(_ content: String) {
|
||||
guard !isComplete else { return }
|
||||
|
||||
guard let block = EncodedBlock.fromBase64(content) else {
|
||||
return
|
||||
}
|
||||
|
||||
let hash = block.toBinary()
|
||||
guard !seenBlockHashes.contains(hash) else { return }
|
||||
seenBlockHashes.insert(hash)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
framesScanned += 1
|
||||
|
||||
do {
|
||||
let complete = try decoder.addBlock(block)
|
||||
progress = decoder.progress
|
||||
|
||||
if complete {
|
||||
isComplete = true
|
||||
stopScanning()
|
||||
NSSound.beep()
|
||||
|
||||
if let data = try? decoder.getDecoded() {
|
||||
onComplete?(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension QRSScannerController: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||
nonisolated func captureOutput(
|
||||
_ output: AVCaptureOutput,
|
||||
didOutput sampleBuffer: CMSampleBuffer,
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||
|
||||
let request = VNDetectBarcodesRequest { [weak self] request, _ in
|
||||
guard let results = request.results as? [VNBarcodeObservation] else { return }
|
||||
|
||||
for result in results {
|
||||
if result.symbology == .qr, let payload = result.payloadStringValue {
|
||||
self?.processQRContent(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
request.symbologies = [.qr]
|
||||
|
||||
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
|
||||
try? handler.perform([request])
|
||||
}
|
||||
}
|
||||
|
||||
struct QRSScannerControllerView: NSViewControllerRepresentable {
|
||||
let controller: QRSScannerController
|
||||
|
||||
func makeNSViewController(context: Context) -> NSViewController {
|
||||
let viewController = QRSScannerViewController(controller: controller)
|
||||
return viewController
|
||||
}
|
||||
|
||||
func updateNSViewController(_ nsViewController: NSViewController, context: Context) {}
|
||||
}
|
||||
|
||||
private class QRSScannerViewController: NSViewController {
|
||||
let controller: QRSScannerController
|
||||
|
||||
init(controller: QRSScannerController) {
|
||||
self.controller = controller
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
view = NSView()
|
||||
view.wantsLayer = true
|
||||
view.layer?.backgroundColor = NSColor.black.cgColor
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.addSubview(controller.previewView)
|
||||
controller.previewView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
controller.previewView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
controller.previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
controller.previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
controller.previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
override func viewDidLayout() {
|
||||
super.viewDidLayout()
|
||||
controller.updatePreviewFrame(view.bounds)
|
||||
}
|
||||
|
||||
override func viewWillAppear() {
|
||||
super.viewWillAppear()
|
||||
controller.startScanning()
|
||||
}
|
||||
|
||||
override func viewWillDisappear() {
|
||||
super.viewWillDisappear()
|
||||
controller.stopScanning()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,169 +0,0 @@
|
||||
#if !os(tvOS)
|
||||
|
||||
import AVFoundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct QRSScannerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var alert: AlertState?
|
||||
@StateObject private var controller = QRSScannerController()
|
||||
|
||||
private let onComplete: (Data) -> Void
|
||||
|
||||
public init(onComplete: @escaping (Data) -> Void) {
|
||||
self.onComplete = onComplete
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
#if os(iOS)
|
||||
iOSBody
|
||||
#elseif os(macOS)
|
||||
macOSBody
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var iOSBody: some View {
|
||||
NavigationStackCompat {
|
||||
ZStack {
|
||||
QRSScannerControllerView(controller: controller)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
progressOverlay
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Scan QRS")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
Button {
|
||||
controller.reset()
|
||||
controller.startScanning()
|
||||
} label: {
|
||||
Label("Reset", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
|
||||
if controller.availableCameras.count > 1 {
|
||||
Menu("Camera") {
|
||||
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
|
||||
Button {
|
||||
controller.selectCamera(camera)
|
||||
} label: {
|
||||
if camera.uniqueID == controller.selectedCamera?.uniqueID {
|
||||
Label(camera.localizedName, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(camera.localizedName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert($alert)
|
||||
.onAppear {
|
||||
controller.onComplete = { data in
|
||||
dismiss()
|
||||
onComplete(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
private var macOSBody: some View {
|
||||
VStack(spacing: 0) {
|
||||
ZStack {
|
||||
QRSScannerControllerView(controller: controller)
|
||||
.frame(minWidth: 400, minHeight: 300)
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
progressOverlay
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
if controller.availableCameras.count > 1 {
|
||||
Picker("Camera", selection: Binding(
|
||||
get: { controller.selectedCamera },
|
||||
set: { camera in
|
||||
if let camera {
|
||||
controller.selectCamera(camera)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
|
||||
Text(camera.localizedName).tag(camera as AVCaptureDevice?)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 200)
|
||||
}
|
||||
|
||||
Button("Reset") {
|
||||
controller.reset()
|
||||
controller.startScanning()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.alert($alert)
|
||||
.onAppear {
|
||||
controller.onComplete = { data in
|
||||
dismiss()
|
||||
onComplete(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var progressOverlay: some View {
|
||||
VStack(spacing: 8) {
|
||||
ProgressView(value: controller.progress)
|
||||
.progressViewStyle(.linear)
|
||||
.frame(maxWidth: 200)
|
||||
|
||||
Text("Decoded: \(Int(controller.progress * 100))%")
|
||||
.font(.headline)
|
||||
|
||||
if controller.decoder.k > 0 {
|
||||
Text("\(controller.decoder.decodedCount)/\(controller.decoder.k) blocks")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Text("Frames scanned: \(controller.framesScanned)")
|
||||
.font(.caption)
|
||||
|
||||
if let error = controller.lastError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -6,6 +6,7 @@ public enum QRScanError: Error, LocalizedError {
|
||||
case permissionDenied
|
||||
case scanFailed(Error)
|
||||
case invalidCode
|
||||
case qrsDecodeFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -17,16 +18,22 @@ public enum QRScanError: Error, LocalizedError {
|
||||
return error.localizedDescription
|
||||
case .invalidCode:
|
||||
return String(localized: "Invalid QR code")
|
||||
case .qrsDecodeFailed:
|
||||
return String(localized: "Failed to decode QRS data")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct QRScanResult: Sendable {
|
||||
public let string: String
|
||||
public let type: AVMetadataObject.ObjectType
|
||||
public enum QRScanResult: Sendable {
|
||||
case qrCode(string: String, type: AVMetadataObject.ObjectType)
|
||||
case qrsData(Data)
|
||||
|
||||
public init(string: String, type: AVMetadataObject.ObjectType) {
|
||||
self.string = string
|
||||
self.type = type
|
||||
public var string: String? {
|
||||
switch self {
|
||||
case let .qrCode(string, _):
|
||||
return string
|
||||
case .qrsData:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
@Published var availableCameras: [AVCaptureDevice] = []
|
||||
@Published var selectedCamera: AVCaptureDevice?
|
||||
|
||||
@Published var qrsMode = false
|
||||
@Published var decoder: LubyTransformDecoder?
|
||||
@Published var progress: Double = 0
|
||||
@Published var framesScanned = 0
|
||||
private var seenBlockIds = Set<String>()
|
||||
private let decodingQueue = DispatchQueue(label: "QRSDecoding", qos: .userInitiated)
|
||||
|
||||
let previewView = UIView()
|
||||
var onScan: ((Result<QRScanResult, QRScanError>) -> Void)?
|
||||
|
||||
@@ -95,6 +102,11 @@
|
||||
|
||||
func reset() {
|
||||
didFinishScanning = false
|
||||
qrsMode = false
|
||||
decoder = nil
|
||||
progress = 0
|
||||
framesScanned = 0
|
||||
seenBlockIds.removeAll()
|
||||
}
|
||||
|
||||
private func setupCaptureSession() {
|
||||
@@ -129,7 +141,7 @@
|
||||
metadataOutput.metadataObjectTypes = [.qr]
|
||||
|
||||
self.metadataOutput = metadataOutput
|
||||
self.captureSession = session
|
||||
captureSession = session
|
||||
|
||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
@@ -149,24 +161,79 @@
|
||||
|
||||
extension QRScannerController: AVCaptureMetadataOutputObjectsDelegate {
|
||||
nonisolated func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
_: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
from _: AVCaptureConnection
|
||||
) {
|
||||
Task { @MainActor in
|
||||
guard !didFinishScanning,
|
||||
let metadataObject = metadataObjects.first,
|
||||
let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
|
||||
let stringValue = readableObject.stringValue
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard !didFinishScanning else { return }
|
||||
|
||||
for metadataObject in metadataObjects {
|
||||
guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
|
||||
let stringValue = readableObject.stringValue
|
||||
else { continue }
|
||||
|
||||
processScannedContent(stringValue, type: readableObject.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processScannedContent(_ content: String, type: AVMetadataObject.ObjectType) {
|
||||
if let block = EncodedBlock.fromQRSString(content) {
|
||||
processQRSBlock(block)
|
||||
} else if !qrsMode {
|
||||
didFinishScanning = true
|
||||
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
||||
onScan?(.success(.qrCode(string: content, type: type)))
|
||||
}
|
||||
}
|
||||
|
||||
let result = QRScanResult(string: stringValue, type: readableObject.type)
|
||||
onScan?(.success(result))
|
||||
private func processQRSBlock(_ block: EncodedBlock) {
|
||||
if let currentChecksum = decoder?.meta?.checksum,
|
||||
block.checksum != currentChecksum
|
||||
{
|
||||
decoder = LubyTransformDecoder()
|
||||
seenBlockIds.removeAll()
|
||||
progress = 0
|
||||
framesScanned = 0
|
||||
}
|
||||
|
||||
if !qrsMode {
|
||||
qrsMode = true
|
||||
decoder = LubyTransformDecoder()
|
||||
}
|
||||
|
||||
let blockId = "\(block.checksum):\(block.indices.sorted().map(String.init).joined(separator: ","))"
|
||||
guard !seenBlockIds.contains(blockId) else { return }
|
||||
seenBlockIds.insert(blockId)
|
||||
|
||||
framesScanned += 1
|
||||
|
||||
guard let decoder else { return }
|
||||
decodingQueue.async { [weak self] in
|
||||
do {
|
||||
let complete = try decoder.addBlock(block)
|
||||
let currentProgress = decoder.progress
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard let self, !self.didFinishScanning else { return }
|
||||
self.progress = currentProgress
|
||||
|
||||
if complete {
|
||||
self.didFinishScanning = true
|
||||
self.stopScanning()
|
||||
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
||||
|
||||
if let data = try? decoder.getDecoded() {
|
||||
self.onScan?(.success(.qrsData(data)))
|
||||
} else {
|
||||
self.onScan?(.failure(.qrsDecodeFailed))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Checksum mismatch is handled above, ignore other errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,12 +241,12 @@
|
||||
struct QRScannerControllerView: UIViewControllerRepresentable {
|
||||
let controller: QRScannerController
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
func makeUIViewController(context _: Context) -> UIViewController {
|
||||
let viewController = QRScannerViewController(controller: controller)
|
||||
return viewController
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
func updateUIViewController(_: UIViewController, context _: Context) {}
|
||||
}
|
||||
|
||||
private class QRScannerViewController: UIViewController {
|
||||
@@ -191,7 +258,7 @@
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
@Published var availableCameras: [AVCaptureDevice] = []
|
||||
@Published var selectedCamera: AVCaptureDevice?
|
||||
|
||||
@Published var qrsMode = false
|
||||
@Published var decoder: LubyTransformDecoder?
|
||||
@Published var progress: Double = 0
|
||||
@Published var framesScanned = 0
|
||||
private var seenBlockIds = Set<String>()
|
||||
private let decodingQueue = DispatchQueue(label: "QRSDecoding", qos: .userInitiated)
|
||||
|
||||
let previewView = NSView()
|
||||
var onScan: ((Result<QRScanResult, QRScanError>) -> Void)?
|
||||
|
||||
@@ -89,6 +96,11 @@
|
||||
|
||||
func reset() {
|
||||
didFinishScanning = false
|
||||
qrsMode = false
|
||||
decoder = nil
|
||||
progress = 0
|
||||
framesScanned = 0
|
||||
seenBlockIds.removeAll()
|
||||
}
|
||||
|
||||
private func setupCaptureSession() {
|
||||
@@ -126,7 +138,7 @@
|
||||
session.addOutput(videoOutput)
|
||||
|
||||
self.videoOutput = videoOutput
|
||||
self.captureSession = session
|
||||
captureSession = session
|
||||
|
||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
@@ -143,23 +155,75 @@
|
||||
previewLayer?.frame = frame
|
||||
}
|
||||
|
||||
private func processQRCode(_ payloadString: String) {
|
||||
guard !didFinishScanning else { return }
|
||||
didFinishScanning = true
|
||||
|
||||
private func processScannedContent(_ content: String) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
NSSound.beep()
|
||||
let result = QRScanResult(string: payloadString, type: .qr)
|
||||
self?.onScan?(.success(result))
|
||||
guard let self, !didFinishScanning else { return }
|
||||
|
||||
if let block = EncodedBlock.fromQRSString(content) {
|
||||
processQRSBlock(block)
|
||||
} else if !qrsMode {
|
||||
didFinishScanning = true
|
||||
NSSound.beep()
|
||||
onScan?(.success(.qrCode(string: content, type: .qr)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processQRSBlock(_ block: EncodedBlock) {
|
||||
if let currentChecksum = decoder?.meta?.checksum,
|
||||
block.checksum != currentChecksum
|
||||
{
|
||||
decoder = LubyTransformDecoder()
|
||||
seenBlockIds.removeAll()
|
||||
progress = 0
|
||||
framesScanned = 0
|
||||
}
|
||||
|
||||
if !qrsMode {
|
||||
qrsMode = true
|
||||
decoder = LubyTransformDecoder()
|
||||
}
|
||||
|
||||
let blockId = "\(block.checksum):\(block.indices.sorted().map(String.init).joined(separator: ","))"
|
||||
guard !seenBlockIds.contains(blockId) else { return }
|
||||
seenBlockIds.insert(blockId)
|
||||
|
||||
framesScanned += 1
|
||||
|
||||
guard let decoder else { return }
|
||||
decodingQueue.async { [weak self] in
|
||||
do {
|
||||
let complete = try decoder.addBlock(block)
|
||||
let currentProgress = decoder.progress
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard let self, !self.didFinishScanning else { return }
|
||||
self.progress = currentProgress
|
||||
|
||||
if complete {
|
||||
self.didFinishScanning = true
|
||||
self.stopScanning()
|
||||
NSSound.beep()
|
||||
|
||||
if let data = try? decoder.getDecoded() {
|
||||
self.onScan?(.success(.qrsData(data)))
|
||||
} else {
|
||||
self.onScan?(.failure(.qrsDecodeFailed))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Checksum mismatch is handled above, ignore other errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension QRScannerController: AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||
nonisolated func captureOutput(
|
||||
_ output: AVCaptureOutput,
|
||||
_: AVCaptureOutput,
|
||||
didOutput sampleBuffer: CMSampleBuffer,
|
||||
from connection: AVCaptureConnection
|
||||
from _: AVCaptureConnection
|
||||
) {
|
||||
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||
|
||||
@@ -168,8 +232,7 @@
|
||||
|
||||
for result in results {
|
||||
if result.symbology == .qr, let payload = result.payloadStringValue {
|
||||
self?.processQRCode(payload)
|
||||
return
|
||||
self?.processScannedContent(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,12 +246,12 @@
|
||||
struct QRScannerControllerView: NSViewControllerRepresentable {
|
||||
let controller: QRScannerController
|
||||
|
||||
func makeNSViewController(context: Context) -> NSViewController {
|
||||
func makeNSViewController(context _: Context) -> NSViewController {
|
||||
let viewController = QRScannerViewController(controller: controller)
|
||||
return viewController
|
||||
}
|
||||
|
||||
func updateNSViewController(_ nsViewController: NSViewController, context: Context) {}
|
||||
func updateNSViewController(_: NSViewController, context _: Context) {}
|
||||
}
|
||||
|
||||
private class QRScannerViewController: NSViewController {
|
||||
@@ -200,7 +263,7 @@
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
|
||||
@@ -27,36 +27,42 @@
|
||||
#if os(iOS)
|
||||
private var iOSBody: some View {
|
||||
NavigationStackCompat {
|
||||
QRScannerControllerView(controller: controller)
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
if controller.availableCameras.count > 1 {
|
||||
Menu("Camera") {
|
||||
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
|
||||
Button {
|
||||
controller.selectCamera(camera)
|
||||
} label: {
|
||||
if camera.uniqueID == controller.selectedCamera?.uniqueID {
|
||||
Label(camera.localizedName, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(camera.localizedName)
|
||||
}
|
||||
ZStack {
|
||||
QRScannerControllerView(controller: controller)
|
||||
.ignoresSafeArea()
|
||||
|
||||
if controller.qrsMode {
|
||||
qrsProgressOverlay
|
||||
}
|
||||
}
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
if controller.availableCameras.count > 1 {
|
||||
Menu("Camera") {
|
||||
ForEach(controller.availableCameras, id: \.uniqueID) { camera in
|
||||
Button {
|
||||
controller.selectCamera(camera)
|
||||
} label: {
|
||||
if camera.uniqueID == controller.selectedCamera?.uniqueID {
|
||||
Label(camera.localizedName, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(camera.localizedName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert($alert)
|
||||
.onAppear {
|
||||
@@ -68,8 +74,14 @@
|
||||
#if os(macOS)
|
||||
private var macOSBody: some View {
|
||||
VStack(spacing: 0) {
|
||||
QRScannerControllerView(controller: controller)
|
||||
.frame(minWidth: 400, minHeight: 300)
|
||||
ZStack {
|
||||
QRScannerControllerView(controller: controller)
|
||||
|
||||
if controller.qrsMode {
|
||||
qrsProgressOverlay
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 400, minHeight: 300)
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -102,11 +114,29 @@
|
||||
}
|
||||
#endif
|
||||
|
||||
private var qrsProgressOverlay: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.5)
|
||||
.ignoresSafeArea()
|
||||
|
||||
let k = controller.decoder?.k ?? 0
|
||||
let framesScanned = controller.framesScanned
|
||||
let scanProgress = k > 0 ? min(1.0, Double(framesScanned) / Double(k) / 1.2) : 0
|
||||
|
||||
CircularProgressView(
|
||||
progress: scanProgress,
|
||||
total: k
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleScanResult(_ result: Result<QRScanResult, QRScanError>) {
|
||||
switch result {
|
||||
case let .success(scanResult):
|
||||
dismiss()
|
||||
onScan(scanResult)
|
||||
Task { @MainActor in
|
||||
onScan(scanResult)
|
||||
}
|
||||
case let .failure(error):
|
||||
switch error {
|
||||
case .permissionDenied:
|
||||
@@ -124,4 +154,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircularProgressView: View {
|
||||
let progress: Double
|
||||
let total: Int
|
||||
|
||||
private let size: CGFloat = 96
|
||||
private let lineWidth: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.3), lineWidth: lineWidth)
|
||||
.frame(width: size, height: size)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.frame(width: size, height: size)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.2), value: progress)
|
||||
|
||||
if total > 0 {
|
||||
Text("\(min(99, Int(progress * 100)))%")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Text("QRS")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.offset(y: -size / 2 - 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user