ios_client
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
struct QRCodeScannerView: View {
|
||||
let onScan: (Data) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var cameraPermission: CameraPermission = .unknown
|
||||
|
||||
enum CameraPermission {
|
||||
case unknown, granted, denied
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
switch cameraPermission {
|
||||
case .unknown:
|
||||
ProgressView("Requesting camera access...")
|
||||
case .denied:
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Camera access is required to scan QR codes.")
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Open Settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
case .granted:
|
||||
ScannerRepresentable(onScan: { data in
|
||||
onScan(data)
|
||||
dismiss()
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await checkCameraPermission()
|
||||
}
|
||||
}
|
||||
|
||||
private func checkCameraPermission() async {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
switch status {
|
||||
case .authorized:
|
||||
cameraPermission = .granted
|
||||
case .notDetermined:
|
||||
let granted = await AVCaptureDevice.requestAccess(for: .video)
|
||||
cameraPermission = granted ? .granted : .denied
|
||||
default:
|
||||
cameraPermission = .denied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scanner UIKit wrapper
|
||||
|
||||
private struct ScannerRepresentable: UIViewControllerRepresentable {
|
||||
let onScan: (Data) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> ScannerViewController {
|
||||
let vc = ScannerViewController()
|
||||
vc.onScan = onScan
|
||||
return vc
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {}
|
||||
}
|
||||
|
||||
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
var onScan: ((Data) -> Void)?
|
||||
private var captureSession: AVCaptureSession?
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var hasScanned = false
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setupCamera()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.bounds
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
captureSession?.stopRunning()
|
||||
}
|
||||
|
||||
private func setupCamera() {
|
||||
let session = AVCaptureSession()
|
||||
captureSession = session
|
||||
|
||||
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: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
|
||||
let layer = AVCaptureVideoPreviewLayer(session: session)
|
||||
layer.videoGravity = .resizeAspectFill
|
||||
layer.frame = view.bounds
|
||||
view.layer.addSublayer(layer)
|
||||
previewLayer = layer
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
session.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection) {
|
||||
guard !hasScanned,
|
||||
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
object.type == .qr else { return }
|
||||
|
||||
hasScanned = true
|
||||
captureSession?.stopRunning()
|
||||
|
||||
// QR codes contain base64-encoded binary data (matching Python client)
|
||||
if let stringValue = object.stringValue,
|
||||
let data = Data(base64Encoded: stringValue) {
|
||||
onScan?(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
import CoreImage.CIFilterBuiltins
|
||||
|
||||
struct SafetyNumberView: View {
|
||||
let peerUserId: String
|
||||
let peerUsername: String
|
||||
var chatClient: ChatClient
|
||||
@State private var vm = VerificationVM()
|
||||
@State private var showQRScanner = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Verification status badge
|
||||
VerificationStatusView(status: vm.verificationStatus)
|
||||
.padding(.top)
|
||||
|
||||
// Safety number
|
||||
if let safetyNumber = vm.safetyNumber {
|
||||
VStack(spacing: 8) {
|
||||
Text("Safety Number")
|
||||
.font(.headline)
|
||||
|
||||
Text("If both you and \(peerUsername) see the same number, your communication is secure.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
Text(safetyNumber)
|
||||
.font(.system(.title2, design: .monospaced))
|
||||
.padding()
|
||||
.background(.quaternary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// QR Code
|
||||
if let qrData = vm.qrCodeData {
|
||||
VStack(spacing: 8) {
|
||||
Text("Your QR Code")
|
||||
.font(.headline)
|
||||
|
||||
if let qrImage = generateQRCode(from: qrData) {
|
||||
Image(uiImage: qrImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 200, height: 200)
|
||||
.padding()
|
||||
.background(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fingerprints
|
||||
VStack(spacing: 12) {
|
||||
if let myFP = vm.myFingerprint {
|
||||
VStack(spacing: 4) {
|
||||
Text("Your Fingerprint")
|
||||
.font(.subheadline.bold())
|
||||
Text(myFP)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
}
|
||||
|
||||
if let peerFP = vm.peerFingerprint {
|
||||
VStack(spacing: 4) {
|
||||
Text("\(peerUsername)'s Fingerprint")
|
||||
.font(.subheadline.bold())
|
||||
Text(peerFP)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
VStack(spacing: 12) {
|
||||
if vm.verificationStatus != "verified" {
|
||||
Button {
|
||||
Task { await vm.verifyContact(peerUserId: peerUserId, chatClient: chatClient) }
|
||||
} label: {
|
||||
Label("Mark as Verified", systemImage: "checkmark.shield.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
showQRScanner = true
|
||||
} label: {
|
||||
Label("Scan QR Code", systemImage: "qrcode.viewfinder")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button(role: .destructive) {
|
||||
Task { await vm.unverifyContact(peerUserId: peerUserId, chatClient: chatClient) }
|
||||
} label: {
|
||||
Label("Remove Verification", systemImage: "xmark.shield")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Scan result
|
||||
if let result = vm.scanResult {
|
||||
Text(result)
|
||||
.font(.callout)
|
||||
.foregroundStyle(vm.scanSuccess == true ? .green : .red)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Verify \(peerUsername)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showQRScanner) {
|
||||
QRCodeScannerView { scannedData in
|
||||
showQRScanner = false
|
||||
Task { await vm.verifyQRCode(data: scannedData, chatClient: chatClient) }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await vm.loadVerification(peerUserId: peerUserId, chatClient: chatClient)
|
||||
}
|
||||
}
|
||||
|
||||
private func generateQRCode(from data: Data) -> UIImage? {
|
||||
let context = CIContext()
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
// Base64-encode binary data — raw binary gets corrupted by QR readers (UTF-8 re-encoding)
|
||||
let b64String = data.base64EncodedString()
|
||||
filter.setValue(b64String.data(using: .ascii), forKey: "inputMessage")
|
||||
filter.setValue("M", forKey: "inputCorrectionLevel")
|
||||
guard let outputImage = filter.outputImage else { return nil }
|
||||
let scale = 200.0 / outputImage.extent.width
|
||||
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
|
||||
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil }
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VerificationStatusView: View {
|
||||
let status: String // "verified", "trusted", "unverified"
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: iconName)
|
||||
.foregroundStyle(iconColor)
|
||||
Text(displayText)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(iconColor.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch status {
|
||||
case "verified": return "checkmark.shield.fill"
|
||||
case "trusted": return "shield.fill"
|
||||
default: return "shield.slash"
|
||||
}
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch status {
|
||||
case "verified": return .green
|
||||
case "trusted": return .blue
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var displayText: String {
|
||||
switch status {
|
||||
case "verified": return "Verified"
|
||||
case "trusted": return "Trusted"
|
||||
default: return "Unverified"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user