Document Reading - NFC Implementation
The NFC module enables reading secure data from electronic travel documents (ePassports and eID cards) using the device's NFC reader. It handles the cryptographic protocols (BAC and PACE) required to establish a secure channel and verify document authenticity.
Architecture Overview
The iOS implementation follows a delegate-based pattern that integrates seamlessly with UIKit and SwiftUI:
-
Delegate Pattern: The SDK uses delegates to communicate reading progress and results, making it easy to integrate with any UI framework.
-
Lifecycle Management: NFC reading sessions are managed automatically, with proper handling of device capabilities and user permissions.
-
Error Handling: Comprehensive error handling for common scenarios like missing NFC support, tag loss, and authentication failures.
Core Components
MBDocumentReadingServiceNfc
The main class responsible for NFC reading operations. It requires a license and implements the delegate pattern for result handling.
import MobaiNfc
import CoreNFC
class NFCReadingService: MBDocumentReadingServiceNfcDelegate {
private lazy var nfcReadingExecutor: MBDocumentReadingServiceNfc = {
let executor = MBDocumentReadingServiceNfc(license: license)
executor.delegate = self
return executor
}()
func startReading(with results: MBResults) {
_ = nfcReadingExecutor.execute(result: results)
}
}
MBDocumentReadingServiceNfcDelegate
The delegate protocol that handles NFC reading events:
protocol MBDocumentReadingServiceNfcDelegate {
func onReadingStarted()
func onReadingFinished(result: MBNfcReadingResult)
func onReadingError(message: String)
}
| Method | Description |
|---|---|
onReadingStarted() | Called when NFC reading begins |
onReadingFinished(result:) | Called when reading completes successfully with the extracted document data |
onReadingError(message:) | Called when an error occurs during reading |
Data Models
MBResults
Contains the MRZ-derived keys needed to establish the secure channel with the document chip:
struct MBResults {
let documentNumber: String
let dateOfExpiry: String // Format: YYMMDD
let dateOfBirth: String // Format: YYMMDD
}
MBNfcReadingResult
The result object containing all extracted data from the NFC chip:
struct MBNfcReadingResult {
let image: UIImage
let travelDocument: TravelDocument // Contains all data groups
}
Complete Integration Example
Step 1: License Loading
Create a helper to load the license from the app bundle:
import Foundation
enum LicenseHelper {
static func loadLicense() throws -> Data {
guard let url = Bundle.main.url(forResource: "iengine", withExtension: "lic") else {
throw LicenseError.missingLicenseFile
}
do {
return try Data(contentsOf: url)
} catch {
throw LicenseError.unreadableLicenseFile(underlying: error)
}
}
}
enum LicenseError: LocalizedError {
case missingLicenseFile
case unreadableLicenseFile(underlying: Error)
var errorDescription: String? {
switch self {
case .missingLicenseFile:
return "Missing iengine.lic in the app bundle."
case .unreadableLicenseFile(let underlying):
return "Unable to read iengine.lic (\(underlying.localizedDescription))."
}
}
}
Step 2: NFC Reading Flow
Implement a complete flow that handles document capture, MRZ extraction, and NFC reading:
import UIKit
import SwiftUI
import CoreNFC
import MobaiNfc
import MobaiDocument
final class OCRNfcCaptureFlow: MBDocumentReadingServiceNfcDelegate {
private let navigationController: UINavigationController
private let license: Data
private var captureRouter: DocumentCaptureRouter?
private var lastResults: MBResults?
private lazy var nfcReadingExecutor: MBDocumentReadingServiceNfc = {
let executor = MBDocumentReadingServiceNfc(license: license)
executor.delegate = self
return executor
}()
init(navigationController: UINavigationController, license: Data) {
self.navigationController = navigationController
self.license = license
}
func start() {
let configuration = MBDocumentCaptureOptions(isMrzReadingEnabled: true)
let captureRouter = DocumentCaptureRouter(
title: "Capture",
navigationController: navigationController,
configuration: configuration,
license: license
)
self.captureRouter = captureRouter
captureRouter.start()
captureRouter.onCaptureResult = { [weak self] result in
self?.handleCaptureResult(result)
}
}
// MARK: - MBDocumentReadingServiceNfcDelegate
func onReadingStarted() {
// Optional: Show loading indicator
}
func onReadingFinished(result: MBNfcReadingResult) {
// Display results in a SwiftUI view
let vc = UIHostingController(
rootView: MBNfcReadingResultView(result: result)
)
navigationController.pushViewController(vc, animated: true)
}
func onReadingError(message: String) {
let alert = UIAlertController(
title: "Something went wrong",
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
// Optionally add retry if we have previous results
if let lastResults {
alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in
_ = self?.startNfcReading(with: lastResults)
})
}
navigationController.topViewController?.present(alert, animated: true)
}
// MARK: - Private Methods
private func startNfcReading(with results: MBResults) {
_ = nfcReadingExecutor.execute(result: results)
}
private func ensureNfcAvailable() -> Bool {
guard NFCTagReaderSession.readingAvailable else {
navigationController.topViewController?.presentOkAlert(
title: "NFC unavailable",
message: "NFC is not available on this device. Use a physical iPhone with NFC support."
)
return false
}
return true
}
private func createResult(from captureResult: MBDocumentCaptureResult) -> MBResults? {
guard let machineReadableZone = captureResult.machineReadableZone,
let travelDocumentType = captureResult.documentType else {
navigationController.topViewController?.presentOkAlert(
title: "Unable to read machine readable zone from document.",
message: ""
) { [weak self] in
self?.navigationController.popViewController(animated: true)
}
return nil
}
return MBResults(
documentNumber: MBDcoumentResultFactory.getDocumentNumber(
machineReadableZone: machineReadableZone,
travelDocumentType: travelDocumentType
),
dateOfExpiry: MBDcoumentResultFactory.getDateOfExpiry(
machineReadableZone: machineReadableZone,
travelDocumentType: travelDocumentType
),
dateOfBirth: MBDcoumentResultFactory.getDateOfBirth(
machineReadableZone: machineReadableZone,
travelDocumentType: travelDocumentType
)
)
}
}
Step 3: Handling Document Capture Results
Add this method inside OCRNfcCaptureFlow to connect document capture to NFC reading:
private func handleCaptureResult(_ result: MBDocumentCaptureResult) {
guard let mbResults = createResult(from: result) else { return }
guard ensureNfcAvailable() else { return }
lastResults = mbResults
// Check for master list (optional, for verification)
guard Bundle.main.url(forResource: "master_list", withExtension: "pem") != nil else {
navigationController.topViewController?.presentOkAlert(
title: "Missing master list",
message: "Add master_list.pem to the app bundle to enable passport NFC verification. NFC reading will continue without verification.",
onOk: { [weak self] in
self?.startNfcReading(with: mbResults)
}
)
return
}
startNfcReading(with: mbResults)
}
SwiftUI Integration
Result Display View
Create a SwiftUI view to display NFC reading results:
import SwiftUI
import MobaiNfc
struct MBNfcReadingResultView: View {
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return encoder
}()
let result: MBNfcReadingResult
var body: some View {
ScrollView {
VStack(spacing: 16) {
Image(uiImage: result.image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: 300)
if let data = try? encoder.encode(result.travelDocument),
let description = String(data: data, encoding: .utf8) {
Text(description)
.font(.system(.footnote, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
} else {
Text("No Description Available")
.italic()
.padding()
}
}
.padding()
.navigationTitle("Result")
}
.background(Color(.systemBackground).ignoresSafeArea())
}
}
SwiftUI Entry Point
Bridge SwiftUI with UIKit navigation:
import SwiftUI
struct DocumentOCRNfcEntryView: View {
var body: some View {
if #available(iOS 16.0, *) {
content
.toolbar(.hidden, for: .navigationBar)
} else {
content
.navigationBarHidden(true)
}
}
private var content: DocumentOCRNfcFlowView {
DocumentOCRNfcFlowView()
}
}
struct DocumentOCRNfcFlowView: UIViewControllerRepresentable {
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> UINavigationController {
let navigationController = UINavigationController(rootViewController: makeRootController())
navigationController.delegate = context.coordinator
return navigationController
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
context.coordinator.onDismiss = { dismiss() }
context.coordinator.startIfNeeded(in: uiViewController)
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
final class Coordinator: NSObject, UINavigationControllerDelegate {
private var hasStarted = false
private var flow: OCRNfcCaptureFlow?
var onDismiss: (() -> Void)?
private var didStartFlow = false
func startIfNeeded(in navigationController: UINavigationController) {
guard !hasStarted else { return }
hasStarted = true
guard let license = loadLicense(in: navigationController) else { return }
let flow = OCRNfcCaptureFlow(navigationController: navigationController, license: license)
self.flow = flow
flow.start()
didStartFlow = true
}
private func loadLicense(in navigationController: UINavigationController) -> Data? {
do {
return try LicenseHelper.loadLicense()
} catch {
navigationController.topViewController?.presentOkAlert(
title: "License missing",
message: error.localizedDescription
)
return nil
}
}
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
guard didStartFlow, navigationController.viewControllers.count == 1 else { return }
onDismiss?()
}
}
private func makeRootController() -> UIViewController {
let root = UIViewController()
root.view.backgroundColor = .systemBackground
return root
}
}
Error Handling
Common Error Scenarios
| Error | Cause | Solution |
|---|---|---|
| "NFC unavailable" | Device doesn't support NFC or NFC is disabled | Check NFCTagReaderSession.readingAvailable before starting |
| "Tag Lost" | Device moved away from document during reading | Retry the reading operation |
| "Authentication failed" | Invalid MRZ keys | Verify document number, date of birth, and expiry date are correct |
| "License missing" | License file not found in bundle | Ensure iengine.lic is added to the app target |
Retry Logic
Implement retry functionality for transient errors:
func onReadingError(message: String) {
let alert = UIAlertController(
title: "Something went wrong",
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
if let lastResults {
alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in
_ = self?.startNfcReading(with: lastResults)
})
}
navigationController.topViewController?.present(alert, animated: true)
}
Master List (Optional)
For passive authentication and document verification, you can include a master list of CSCA (Country Signing Certificate Authority) certificates:
- Add
master_list.pemto your app bundle - The SDK will automatically use it for verification if present
- If missing, NFC reading will continue without verification
Best Practices
- Check NFC Availability: Always verify
NFCTagReaderSession.readingAvailablebefore attempting to read - Handle Permissions: Ensure NFC usage description is in Info.plist
- Store Last Results: Keep the last
MBResultsto enable retry functionality - User Feedback: Provide clear feedback during the reading process
- Error Recovery: Implement retry logic for transient errors like tag loss
Sample Code
For complete working examples and reference implementations, see the iOS SDK Samples repository on GitHub.