import SwiftUI import CoreImage import CoreImage.CIFilterBuiltins #if os(iOS) import UIKit #else import AppKit #endif /// Placeholder view to display the user's verification QR payload as text. struct MyQRView: View { let qrString: String @Environment(\.colorScheme) var colorScheme private var boxColor: Color { Color.gray.opacity(0.1) } private enum Strings { static let title: LocalizedStringKey = "verification.my_qr.title" static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", comment: "Accessibility label describing the verification QR code") } var body: some View { VStack(spacing: 12) { Text(Strings.title) .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) VStack(spacing: 10) { QRCodeImage(data: qrString, size: 240) .accessibilityLabel(Strings.accessibilityLabel) // Non-scrolling, fully visible URL (wraps across lines) Text(qrString) .font(.bitchatSystem(size: 11, design: .monospaced)) .textSelection(.enabled) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) .padding(8) .background(boxColor) .cornerRadius(8) } .padding() .frame(maxWidth: .infinity) .background(boxColor) .cornerRadius(8) } .padding() } } // Render a QR code image for a given string using CoreImage struct QRCodeImage: View { let data: String let size: CGFloat private let context = CIContext() private let filter = CIFilter.qrCodeGenerator() private enum Strings { static let unavailable: LocalizedStringKey = "verification.my_qr.unavailable" } var body: some View { Group { if let image = generateImage() { ImageWrapper(image: image) .frame(width: size, height: size) } else { RoundedRectangle(cornerRadius: 8) .stroke(Color.gray.opacity(0.5), lineWidth: 1) .frame(width: size, height: size) .overlay( Text(Strings.unavailable) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.gray) ) } } } private func generateImage() -> CGImage? { let inputData = Data(data.utf8) filter.message = inputData filter.correctionLevel = "M" guard let outputImage = filter.outputImage else { return nil } let scale = max(1, Int(size / 32)) let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale))) return context.createCGImage(transformed, from: transformed.extent) } } // Platform-specific wrapper to display CGImage in SwiftUI struct ImageWrapper: View { let image: CGImage var body: some View { #if os(iOS) let ui = UIImage(cgImage: image) return Image(uiImage: ui) .interpolation(.none) .resizable() #else let ns = NSImage(cgImage: image, size: .zero) return Image(nsImage: ns) .interpolation(.none) .resizable() #endif } } /// Placeholder scanner UI; real camera scanning will be added later. struct QRScanView: View { @EnvironmentObject var viewModel: ChatViewModel var isActive: Bool = true var onSuccess: (() -> Void)? = nil // Called when verification succeeds @State private var input = "" @State private var result: String = "" // not shown for iOS scanner @State private var lastValid: String = "" private enum Strings { static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt" static let validate: LocalizedStringKey = "verification.scan.validate" static func requested(_ nickname: String) -> String { String( format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"), locale: .current, nickname ) } static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request") static let invalid = String(localized: "verification.scan.status.invalid", comment: "Status when a scanned QR payload is invalid") } var body: some View { VStack(alignment: .leading, spacing: 12) { #if os(iOS) CameraScannerView(isActive: isActive) { code in // Deduplicate: ignore if we just processed this exact QR code guard code != lastValid else { return } if let qr = VerificationService.shared.verifyScannedQR(code) { let ok = viewModel.beginQRVerification(with: qr) if ok { // Successfully initiated verification; remember this QR to prevent re-scanning lastValid = code // Close scanner and return to "My QR" view onSuccess?() } // If !ok, peer not found or already pending - don't set lastValid so user can retry } else { // ignore invalid reads; continue scanning } } .frame(height: 260) .clipShape(RoundedRectangle(cornerRadius: 8)) #else Text(Strings.pastePrompt) .font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced)) TextEditor(text: $input) .frame(height: 100) .border(Color.gray.opacity(0.4)) Button(Strings.validate) { // Deduplicate: ignore if we just processed this exact QR guard input != lastValid else { result = Strings.requested("") // Already processed return } if let qr = VerificationService.shared.verifyScannedQR(input) { let ok = viewModel.beginQRVerification(with: qr) if ok { result = Strings.requested(qr.nickname) lastValid = input // Close scanner and return to "My QR" view onSuccess?() } else { result = Strings.notFound } } else { result = Strings.invalid } } .buttonStyle(.bordered) #endif // No status text under camera per design Spacer() } .padding() } } #if os(iOS) import AVFoundation struct CameraScannerView: UIViewRepresentable { typealias UIViewType = PreviewView var isActive: Bool var onCode: (String) -> Void func makeUIView(context: Context) -> PreviewView { let view = PreviewView() context.coordinator.setup(sessionOwner: view, onCode: onCode) context.coordinator.setActive(isActive) return view } func updateUIView(_ uiView: PreviewView, context: Context) { context.coordinator.setActive(isActive) } func makeCoordinator() -> Coordinator { Coordinator() } final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { private var onCode: ((String) -> Void)? private weak var owner: PreviewView? private let session = AVCaptureSession() private var isRunning = false private var permissionGranted = false private var desiredActive = false 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 self.permissionGranted = granted if granted && self.desiredActive && !self.isRunning { self.setActive(true) } } } func setActive(_ active: Bool) { desiredActive = active guard permissionGranted else { return } if active && !isRunning { isRunning = true DispatchQueue.global(qos: .userInitiated).async { if !self.session.isRunning { self.session.startRunning() } } } else if !active && isRunning { isRunning = false DispatchQueue.global(qos: .userInitiated).async { if self.session.isRunning { self.session.stopRunning() } } } } 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 // Combined sheet: shows my QR by default with a button to scan instead struct VerificationSheetView: View { @EnvironmentObject var viewModel: ChatViewModel @Binding var isPresented: Bool @State private var showingScanner = false @Environment(\.colorScheme) var colorScheme private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white } private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } private var boxColor: Color { Color.gray.opacity(0.1) } private func myQRString() -> String { let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? "" } var body: some View { VStack(spacing: 0) { // Top header (always at top) HStack { Text("verification.sheet.title") .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) .foregroundColor(accentColor) Spacer() Button(action: { showingScanner = false isPresented = false }) { Image(systemName: "xmark") .font(.bitchatSystem(size: 14, weight: .semibold)) .foregroundColor(accentColor) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 8) Divider() // Content area Group { if showingScanner { VStack(alignment: .leading, spacing: 12) { Text("verification.scan.prompt_friend") .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) .frame(maxWidth: .infinity) .multilineTextAlignment(.center) .foregroundColor(accentColor) #if os(iOS) QRScanView(isActive: showingScanner, onSuccess: { showingScanner = false }) .environmentObject(viewModel) .frame(height: 280) .clipShape(RoundedRectangle(cornerRadius: 10)) #else QRScanView(onSuccess: { showingScanner = false }) .environmentObject(viewModel) #endif } .padding() .frame(maxWidth: .infinity) .background(boxColor) .cornerRadius(8) } else { let qr = myQRString() MyQRView(qrString: qr) } } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) // Centered controls moved up VStack(spacing: 10) { if showingScanner { Button(action: { showingScanner = false }) { Label("show my qr", systemImage: "qrcode") .font(.bitchatSystem(size: 13, design: .monospaced)) } .buttonStyle(.bordered) } else { Button(action: { showingScanner = true }) { Label("scan someone else's qr", systemImage: "camera.viewfinder") .font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced)) } .buttonStyle(.bordered) .tint(.gray) } // Optional: Remove verification for selected peer (if verified) if let pid = viewModel.selectedPrivateChatPeer, let fp = viewModel.getFingerprint(for: pid), viewModel.verifiedFingerprints.contains(fp) { Button(action: { viewModel.unverifyFingerprint(for: pid) }) { Label("remove verification", systemImage: "minus.circle") .font(.bitchatSystem(size: 12, design: .monospaced)) } .buttonStyle(.bordered) .tint(.gray) } } .frame(maxWidth: .infinity) .padding(.vertical, 14) } .background(backgroundColor) .onDisappear { showingScanner = false } } }