Document Reading - NFC Implementation
The NFC module is the bridge between your application and the secure silicon chip embedded in modern travel documents, such as ePassports and electronic ID cards. It orchestrates the complex cryptographic handshakes—specifically BAC (Basic Access Control) and PACE (Password Authenticated Connection Establishment) —required to open a secure channel and verify the document's authenticity.
Architectural Philosophy
This module is built using MVVM (Model-View-ViewModel) and Clean Architecture principles. By decoupling the hardware logic from the presentation layer, we provide a flexible integration path regardless of your UI framework:
-
UI Agnostic: The core logic is exposed via a single
StateFlow, making it equally simple to observe from a traditional XML-based View (usinglifecycleScope) or a Jetpack Compose screen (using `collectAsStateWithLifecycle). -
Hardware Abstraction: All NFC hardware complexities—such as Reader Mode flags and presence check delays—are encapsulated within the Data Layer, leaving the ViewModel to handle only logical business states.
-
Reactive Data Flow: We utilize Kotlin Coroutines and Flow to ensure that high-frequency hardware events (like reading progress) are delivered to the UI smoothly and without blocking the main thread.
Domain Layer
The NFC Reader Interface
This abstraction allows the ViewModel to interact with the NFC hardware without knowing the details of the Mobai SDK or Android’s NfcAdapter
interface NfcReader {
fun read(activity: MainActivity, key: MBNfcKeyFactory)
fun stopReading(activity: Activity)
val nfcEventFlow: Flow<NfcEvent>
}
Data Layer
Hardware Implementation
The Data layer contains the concrete implementation of our interfaces, bridging the gap between the Android OS and the SDK.
This class manages the NfcAdapter in Reader Mode. It uses a Flow to broadcast hardware events asynchronously to any listeners.
//Concrete implementation of the NfcReader interface.
//Coordinates between Android's NfcAdapter and the Mobai SDK reader.
class NfcReaderImpl(
private val context: Context,
private val nfcTravelDocumentReader: MBNfcTravelDocumentReader,
): NfcReader {
private val nfcAdapter: NfcAdapter? by lazy {
NfcAdapter.getDefaultAdapter(context)
}
// SharedFlow allows multiple collectors and handles events asynchronously
private val _nfcEventFlow = MutableSharedFlow<NfcEvent>(extraBufferCapacity = 1)
override val nfcEventFlow: Flow<NfcEvent> = _nfcEventFlow.asSharedFlow()
// Interface listener that converts SDK callbacks into Flow events.
private val listener = object : MBNfcTravelDocumentReaderInterface {
override fun onAccessEstablishmentStarted() {
_nfcEventFlow.tryEmit(NfcEvent.AccessStarted)
}
override fun onReadingError(exception: Exception) {
_nfcEventFlow.tryEmit(NfcEvent.Error(exception))
}
override fun onReadingFinished(document: NfcTravelDocumentReaderResult) {
_nfcEventFlow.tryEmit(NfcEvent.Success(document))
}
override fun onProgress(progress: Int) {
_nfcEventFlow.tryEmit(NfcEvent.Progress(progress))
}
override fun onDataAuthenticationStarted() {
_nfcEventFlow.tryEmit(NfcEvent.DataAuthenticationStarted)
}
}
init {
// Attach the listener to the Mobai SDK reader instance
nfcTravelDocumentReader.setListener(listener)
}
// Enables Android Reader Mode.
// When a tag is discovered, it is passed directly to the Mobai SDK for reading.
override fun read(activity: MainActivity, key: MBNfcKeyFactory) {
val options = Bundle().apply {
// Delay check to maintain connection stability during high data transfer
putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 250)
}
nfcAdapter?.enableReaderMode(
activity,
{ tag ->
_nfcEventFlow.tryEmit(NfcEvent.TagFound)
// The actual decryption and reading happens here
nfcTravelDocumentReader.read(tag, key)
},
NfcAdapter.FLAG_READER_NFC_A or
NfcAdapter.FLAG_READER_NFC_B or
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,
options,
)
}
// Disables Reader Mode to release the NFC hardware controller.
override fun stopReading(activity: Activity) {
nfcAdapter?.disableReaderMode(activity)
}
}
Event Definition Hardware Layer
sealed class NfcEvent {
object TagFound : NfcEvent()
object AccessStarted : NfcEvent()
object DataAuthenticationStarted : NfcEvent()
data class Progress(val percent: Int) : NfcEvent()
data class Success(val doc: NfcTravelDocumentReaderResult) : NfcEvent()
data class Error(val e: Exception) : NfcEvent()
}
This is a Sealed Class representing the raw signals coming directly from the hardware and SDK. It is the "source of truth" for what is happening at the physical layer. NfcEvent
| Event | Payload | Description |
|---|---|---|
| TagFound | None | Indicates the reader has begun establishing a secure BAC or PACE session using the provided keys.'s electromagnetic field. |
| AccessStarted | None | Indicates the reader has begun establishing a secure BAC or PACE session using the provided keys. |
| DataAuthenticationStarted | None | Triggered when the reader begins verifying the chip's digital signatures against the Master List (Passive Authentication). |
| Progress(Int) | percent: Int | Reports the current percentage of data group (DG) extraction. |
| Success(Result) | doc: Result | Emitted when the entire data set has been successfully read, decrypted, and cryptographically verified. |
| Error(Exception) | e: Exception | Emitted if the process is interrupted (Tag Lost), timed out, or if the security keys failed to unlock the chip. |
Presentation Layer
NfcReaderViewModel
The NfcReaderViewModel acts as the command center for the NFC module. It serves two primary purposes: transforming low-level hardware signals into high-level UI states and managing the lifecycle of the NFC antenna via the NfcReader.
class NfcReaderViewModel(
private val nfcReader: NfcReader
) : ViewModel() {
// Internal mutable state
private val _uiState = MutableStateFlow<ScanningState>(ScanningState.Idle)
// External immutable state for the Fragment to observe
val uiState: StateFlow<ScanningState> = _uiState.asStateFlow()
init {
// Observe the hardware events as long as the ViewModel exists
viewModelScope.launch {
nfcReader.nfcEventFlow.collect { event ->
_uiState.value = when (event) {
is NfcEvent.AccessStarted -> ScanningState.Access
is NfcEvent.DataAuthenticationStarted -> ScanningState.Verifying
is NfcEvent.Progress -> ScanningState.Scanning(event.percent)
is NfcEvent.Success -> ScanningState.Finished(event.doc)
is NfcEvent.Error -> ScanningState.Error(event.e.message ?: "Unknown Error")
is NfcEvent.TagFound -> _uiState.value
}
}
}
}
// Triggers the NFC Reader Mode on the Activity.
fun enableNfcReading(activity: Activity, documentKeys: MBNfcKeyFactory) {
viewModelScope.launch {
// Re-wrap keys to ensure they match expected internal SDK format if necessary
val keys = MBNfcKeyFactory(
documentKeys.nfcKey.documentNumber,
documentKeys.nfcKey.dateOfExpiry,
documentKeys.nfcKey.dateOfBirth
)
nfcReader.read(activity as MainActivity, keys)
}
}
// Safely disables the NFC Reader Mode to save battery and release hardware.
fun disableNfcReading(activity: Activity) {
nfcReader.stopReading(activity)
}
}
Event Definition Presentation Layer
sealed class ScanningState {
data object Idle : ScanningState()
data class Scanning(val progress: Int) : ScanningState()
data object Access : ScanningState()
data object Verifying : ScanningState()
data class Error(val error: String) : ScanningState()
data class Finished(val doc: NfcTravelDocumentReaderResult) : ScanningState()
}
The ScanningState is a Sealed Class that represents the high-level status of the NFC process. While the hardware layer handles raw signals, the UI layer observes these states to provide clear feedback to the end-user.
NfcEvent
| State | Payload | Description |
|---|---|---|
| Idle | None | The default state. The NfcAdapter is waiting for a compatible tag to enter the field. |
| Access | None | The reader is performing the BAC/PACE handshake using MRZ keys to unlock the chip |
| Scanning | progress: Int | The secure channel is open. The SDK is currently downloading Data Groups. |
| Verifying | None | The chip data is being cryptographically signed and verified against the Master List. |
| Finished | doc: Result | The process is complete. All data has been extracted and validated. |
| Error | error: String | The process was interrupted (e.g., tag lost) or security keys were invalid. |
UI layer
The MBNfcTravelDocumentReader is initialized using a lazy delegate. This ensures the heavy cryptographic resources and license files are only loaded into memory when the user actually navigates to the scanning screen.
Fragment Implementation
Initialize Nfc Reader SDK
private val nfcReaderSDK by lazy {
MBNfcTravelDocumentReader(
context = requireContext().applicationContext,
masterList = readRawResource(R.raw.masterlist_no_pol_2025_03_04_de_2025_04_16),
license = readRawResource(R.raw.iengine),
timeoutMillis = 5000,
isDebugInfoEnabled = true
)
}
private fun readRawResource(id: Int): ByteArray =
resources.openRawResource(id).use { it.readBytes() }
| Parameter | Type | Value / Source | Value / Source |
|---|---|---|---|
| context | Context | applicationContext | Uses the global application context to prevent memory leaks if the Fragment is destroyed and recreated. |
| masterList | ByteArray | R.raw.masterlist_... | A binary file containing trusted CSCA certificates. This is required for Passive Authentication to verify the document's digital signature. |
| license | ByteArray | R.raw.iengine | The license key required to unlock the Mobai NFC scanning features. The SDK will throw a security exception if this is invalid or expired. |
| timeoutMillis | Long | 5000 | The maximum time (5 seconds) the reader will wait for a response from the chip. High values are recommended to accommodate older ePassport chips. |
ViewModel Initialization
Since the NfcReaderViewModel requires an implementation of the NfcReader interface, we use a custom `ViewModelProvider.Factory. This allows us to inject the NfcReaderImpl (which wraps our SDK instance) directly into the ViewModel.
private val viewModel: NfcReaderViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val impl = NfcReaderImpl(requireContext().applicationContext, nfcReaderSDK)
return NfcReaderViewModel(impl) as T
}
}
}
Data Retrieval
In onViewCreated, the Fragment extracts the necessary BAC (Basic Access Control) keys passed from the previous scan. These keys are required to establish the initial secure channel with the passport chip.
-
MRZ Data: The
docNumber,birthDate, andexpiryDateact as the shared secrets. -
Navigation: The `onBackPressed logic ensures that if a user cancels the process, the NFC hardware is explicitly disabled before the Fragment is destroyed.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Retrieve the data using the same keys
docNumber = arguments?.getString("document_number") ?: "FHC002594"
birthDate = arguments?.getString("birth_date") ?: "560423"
expiryDate = arguments?.getString("expiry_date") ?: "250612"
// Observe StateFlow using lifecycle-aware collector
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
renderUi(state)
}
}
}
binding.btnEnableNfc.setOnClickListener {
viewModel.disableNfcReading(requireActivity())
(activity as? MainActivity)?.onBackPressed()
}
}
Lifecycle Methods
Powers up the NFC antenna and puts it in "Reader Mode." This is the only time the device is actively looking for a tag.
override fun onResume() {
super.onResume()
// Provide the BAC keys (Document #, Expiry, DOB)
val keys = MBNfcKeyFactory(docNumber, expiryDate, birthDate)
viewModel.enableNfcReading(requireActivity(), keys)
}
Shuts down the antenna immediately. This prevents the "Tag Lost" sound/vibration if the user pulls the phone away as they navigate away.
override fun onPause() {
super.onPause()
viewModel.disableNfcReading(requireActivity())
}
Jetpack Compose integration
Integrating hardware-level NFC scanning into a declarative UI framework like Jetpack Compose requires a bridge between the Composable lifecycle and the Android Activity lifecycle. Since the NFC antenna is a shared system resource, we must ensure it is only active when the screen is in the foreground.
Lifecycle Synchronization
Because Compose functions can recompose frequently, we use `DisposableEffect to manage the NFC hardware. This ensures that the lifecycle observer is only added once and is properly cleaned up when the user navigates away from the screen.
The DisposableEffect Logic
-
ON_RESUME: We trigger
enableNfcReading. A smalldelay(500L)is added within apostblock to ensure the Activity window is fully focused and the hardware controller is ready to take command. -
ON_PAUSE: We immediately trigger
disableNfcReadingto release the NFC controller, preventing battery drain and allowing other apps (like Google Pay) to function. -
onDispose: This acts as a final fail-safe. If the Composable is removed from the UI tree (navigation), the observer is removed and the hardware is explicitly released.
Composable screen
@Composable
actual fun NfcScannerScreen() {
val nfcReaderViewModel: NfcReaderViewModel = koinViewModel()
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
val context = LocalContext.current
val activity = context as? Activity
val scope = rememberCoroutineScope()
DisposableEffect(lifecycleOwner, activity, nfcScannerViewModel) {
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
// Enable NFC reading only when the Activity is resumed
activity?.let { act ->
act.window.decorView.post {
scope.launch {
delay(500L)
// Check that the activity is still resumed
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
nfcScannerViewModel.enableNfcReading(act)
}
}
}
}
}
Lifecycle.Event.ON_PAUSE -> {
// Disable NFC reading when the Activity is paused
activity?.let { act ->
nfcScannerViewModel.disableNfcReading(act)
}
}
else -> Unit // Ignore other events
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
// Disable NFC reading when the compose is disposed
activity?.let { act ->
nfcScannerViewModel.disableNfcReading(act)
}
}
}
}