ios_client

This commit is contained in:
Filip
2026-03-14 12:43:56 +01:00
parent 5fd80e6dd6
commit 214da18779
74 changed files with 13136 additions and 284 deletions

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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"
}
}
}