From 81fa77b761d3e35a004ed9f29f21045408d583ee Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 24 Aug 2025 18:23:37 +0200 Subject: [PATCH] QR: add iOS camera scanner using AVFoundation; integrate into Scan view; add NSCameraUsageDescription to Info.plist --- bitchat/Info.plist | 2 + bitchat/Views/VerificationViews.swift | 95 ++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/bitchat/Info.plist b/bitchat/Info.plist index f8cd5912..7f5a6464 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -37,6 +37,8 @@ bitchat uses Bluetooth to discover and connect with other bitchat users nearby. NSLocationWhenInUseUsageDescription bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared. + NSCameraUsageDescription + bitchat uses the camera to scan QR codes to verify peers. UIBackgroundModes bluetooth-central diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index 1bb40965..94099018 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -104,9 +104,25 @@ struct ImageWrapper: View { struct QRScanView: View { @State private var input = "" @State private var result: String = "" + @State private var lastValid: String = "" var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("Paste QR content to simulate scan:") + #if os(iOS) + Text("Scan a friend's QR") + .font(.system(size: 14, weight: .medium, design: .monospaced)) + CameraScannerView { code in + if let qr = VerificationService.shared.verifyScannedQR(code) { + result = "Valid QR: \(qr.nickname)" + lastValid = code + } else { + result = "Invalid or expired QR" + } + } + .frame(height: 260) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray.opacity(0.3), lineWidth: 1)) + #else + Text("Paste QR content to validate:") .font(.system(size: 14, weight: .medium, design: .monospaced)) TextEditor(text: $input) .frame(height: 100) @@ -119,11 +135,88 @@ struct QRScanView: View { } } .buttonStyle(.bordered) + #endif Text(result) .font(.system(size: 12, design: .monospaced)) .foregroundColor(result.contains("Valid") ? .green : .orange) + if !lastValid.isEmpty { + Text(lastValid) + .font(.system(size: 10, design: .monospaced)) + .textSelection(.enabled) + .foregroundColor(.secondary) + } Spacer() } .padding() } } + +#if os(iOS) +import AVFoundation + +struct CameraScannerView: UIViewRepresentable { + typealias UIViewType = PreviewView + var onCode: (String) -> Void + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + context.coordinator.setup(sessionOwner: view, onCode: onCode) + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + private var onCode: ((String) -> Void)? + private weak var owner: PreviewView? + private let session = AVCaptureSession() + + func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) { + self.owner = sessionOwner + self.onCode = onCode + session.beginConfiguration() + session.sessionPreset = .high + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + if output.availableMetadataObjectTypes.contains(.qr) { + output.metadataObjectTypes = [.qr] + } + session.commitConfiguration() + sessionOwner.videoPreviewLayer.session = session + // Request permission and start + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + if granted { self.session.startRunning() } + } + } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + for obj in metadataObjects { + guard let m = obj as? AVMetadataMachineReadableCodeObject, + m.type == .qr, + let str = m.stringValue else { continue } + onCode?(str) + } + } + } + + final class PreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } + override init(frame: CGRect) { + super.init(frame: frame) + videoPreviewLayer.videoGravity = .resizeAspectFill + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + } +} +#endif