Add native iOS QR code scanner (#1405)

This commit is contained in:
Leendert de Borst
2025-11-28 17:07:17 +01:00
committed by Leendert de Borst
parent 6c561e8ece
commit 5414f40c98
5 changed files with 248 additions and 98 deletions

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -212,7 +212,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */ = {
CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -222,84 +222,13 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
CE59C7602E4F47FD0024A246 /* VaultUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultUITests;
sourceTree = "<group>";
};
CE77825E2EA1822400A75E6F /* VaultUtils */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultUtils;
sourceTree = "<group>";
};
CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultStoreKit;
sourceTree = "<group>";
};
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultStoreKitTests;
sourceTree = "<group>";
};
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultUI;
sourceTree = "<group>";
};
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultModels;
sourceTree = "<group>";
};
CEE909812DA548C7008D568F /* Autofill */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = Autofill;
sourceTree = "<group>";
};
CE59C7602E4F47FD0024A246 /* VaultUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUITests; sourceTree = "<group>"; };
CE77825E2EA1822400A75E6F /* VaultUtils */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUtils; sourceTree = "<group>"; };
CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKit; sourceTree = "<group>"; };
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKitTests; sourceTree = "<group>"; };
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUI; sourceTree = "<group>"; };
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultModels; sourceTree = "<group>"; };
CEE909812DA548C7008D568F /* Autofill */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Autofill; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -1418,10 +1347,7 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -1475,10 +1401,7 @@
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;

View File

@@ -291,4 +291,10 @@
[vaultManager authenticateUser:title subtitle:subtitle resolver:resolve rejecter:reject];
}
// MARK: - QR Code Scanner
- (void)scanQRCode:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
[vaultManager scanQRCode:resolve rejecter:reject];
}
@end

View File

@@ -5,6 +5,7 @@ import VaultStoreKit
import VaultModels
import SwiftUI
import VaultUI
import AVFoundation
/**
* This class is used as a bridge to allow React Native to interact with the VaultStoreKit class.
@@ -913,6 +914,40 @@ public class VaultManager: NSObject {
}
}
@objc
func scanQRCode(_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
DispatchQueue.main.async {
// Get the root view controller from React Native
guard let rootVC = RCTPresentedViewController() else {
reject("NO_VIEW_CONTROLLER", "No view controller available", nil)
return
}
// Create QR scanner view
let scannerView = QRScannerView(
onCodeScanned: { code in
// Dismiss and return scanned code
rootVC.dismiss(animated: true) {
resolve(code)
}
},
onCancel: {
// Dismiss and return nil
rootVC.dismiss(animated: true) {
resolve(nil)
}
}
)
let hostingController = UIHostingController(rootView: scannerView)
// Present modally as full screen
hostingController.modalPresentationStyle = .fullScreen
rootVC.present(hostingController, animated: true)
}
}
@objc
func authenticateUser(_ title: String?,
subtitle: String?,

View File

@@ -2444,12 +2444,6 @@ PODS:
- SQLite.swift/standard (0.14.1)
- SwiftLint (0.59.1)
- SWXMLHash (7.0.2)
- VisionCamera (4.7.3):
- VisionCamera/Core (= 4.7.3)
- VisionCamera/React (= 4.7.3)
- VisionCamera/Core (4.7.3)
- VisionCamera/React (4.7.3):
- React-Core
- Yoga (0.0.0)
DEPENDENCIES:
@@ -2567,7 +2561,6 @@ DEPENDENCIES:
- SignalArgon2 (~> 1.3.2)
- SQLite.swift (~> 0.14.0)
- SwiftLint
- VisionCamera (from `../node_modules/react-native-vision-camera`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS:
@@ -2798,8 +2791,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-screens"
RNSVG:
:path: "../node_modules/react-native-svg"
VisionCamera:
:path: "../node_modules/react-native-vision-camera"
Yoga:
:path: "../node_modules/react-native/ReactCommon/yoga"
@@ -2919,7 +2910,6 @@ SPEC CHECKSUMS:
SQLite.swift: 2992550ebf3c5b268bf4352603e3df87d2a4ed72
SwiftLint: 3d48e2fb2a3468fdaccf049e5e755df22fb40c2c
SWXMLHash: dd733a457e9c4fe93b1538654057aefae4acb382
VisionCamera: 7187b3dac1ff3071234ead959ce311875748e14f
Yoga: dc7c21200195acacb62fa920c588e7c2106de45e
PODFILE CHECKSUM: ac288e273086bafdd610cafff08ccca0d164f7c3

View File

@@ -0,0 +1,196 @@
import SwiftUI
import AVFoundation
/// SwiftUI view for scanning QR codes using AVFoundation
public struct QRScannerView: View {
let onCodeScanned: (String) -> Void
let onCancel: () -> Void
@State private var hasScanned = false
@State private var showFlash = false
public init(onCodeScanned: @escaping (String) -> Void, onCancel: @escaping () -> Void) {
self.onCodeScanned = onCodeScanned
self.onCancel = onCancel
}
public var body: some View {
ZStack {
// Camera preview
QRScannerRepresentable(
onCodeScanned: { code in
if !hasScanned {
hasScanned = true
showFlash = true
// Flash animation then callback
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
onCodeScanned(code)
}
}
}
)
.edgesIgnoringSafeArea(.all)
// Overlay with viewfinder
VStack {
Spacer()
// Viewfinder frame
Rectangle()
.stroke(Color.white, lineWidth: 3)
.frame(width: 280, height: 280)
.overlay(
// Flash effect
Rectangle()
.fill(Color.white)
.opacity(showFlash ? 0.7 : 0)
.animation(.easeInOut(duration: 0.2), value: showFlash)
)
Spacer()
// Status text
Text("Scan AliasVault QR Code")
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.7))
.cornerRadius(10)
.padding(.bottom, 50)
}
// Cancel button
VStack {
HStack {
Spacer()
Button(action: onCancel) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 32))
.foregroundColor(.white)
.padding()
}
}
Spacer()
}
}
.background(Color.black)
}
}
/// UIViewControllerRepresentable wrapper for AVFoundation camera
struct QRScannerRepresentable: UIViewControllerRepresentable {
let onCodeScanned: (String) -> Void
func makeUIViewController(context: Context) -> QRScannerViewController {
let controller = QRScannerViewController()
controller.onCodeScanned = onCodeScanned
return controller
}
func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {
// No updates needed
}
}
/// UIViewController that handles AVFoundation QR code scanning
class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var captureSession: AVCaptureSession?
var previewLayer: AVCaptureVideoPreviewLayer?
var onCodeScanned: ((String) -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
setupCamera()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let session = captureSession, !session.isRunning {
DispatchQueue.global(qos: .userInitiated).async {
session.startRunning()
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if let session = captureSession, session.isRunning {
DispatchQueue.global(qos: .userInitiated).async {
session.stopRunning()
}
}
}
private func setupCamera() {
let session = AVCaptureSession()
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
return
}
let videoInput: AVCaptureDeviceInput
do {
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
} catch {
return
}
if session.canAddInput(videoInput) {
session.addInput(videoInput)
} else {
return
}
let metadataOutput = AVCaptureMetadataOutput()
if session.canAddOutput(metadataOutput) {
session.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = [.qr]
} else {
return
}
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.frame = view.layer.bounds
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)
self.captureSession = session
self.previewLayer = previewLayer
DispatchQueue.global(qos: .userInitiated).async {
session.startRunning()
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.layer.bounds
}
func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
if let metadataObject = metadataObjects.first,
let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
let stringValue = readableObject.stringValue {
// Stop scanning
captureSession?.stopRunning()
// Haptic feedback
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.success)
// Callback with scanned code
onCodeScanned?(stringValue)
}
}
}