feat(rn-sdk-test-app): native MRZ camera with progressive detection UX (#1816)

* save wip

* save working code

* finalize code

* pr feedback

* fix
This commit is contained in:
Justin Hernandez
2026-03-05 22:48:41 -08:00
committed by GitHub
parent 62a11cdca3
commit cde591b998
18 changed files with 1592 additions and 65 deletions

View File

@@ -4,7 +4,6 @@
import React, { useMemo, useState } from 'react';
import {
NativeModules,
SafeAreaView,
ScrollView,
StatusBar,
@@ -17,41 +16,6 @@ import {
import { SelfVerification, type SelfSdkError, type VerificationResult } from '@selfxyz/rn-sdk';
const fallbackMrzScannerModule = {
startScanning: async () => ({
documentNumber: 'XK0000000',
dateOfBirth: '900101',
dateOfExpiry: '300101',
documentType: 'P',
countryCode: 'UTO',
}),
};
function ensureMrzScannerModule(): void {
const nativeModules = NativeModules as Record<string, unknown>;
const selfScanner = nativeModules.SelfMRZScannerModule as
| { startScanning?: unknown }
| undefined;
const legacyScanner = nativeModules.MRZScannerModule as
| { startScanning?: unknown }
| undefined;
const hasScanner =
typeof selfScanner?.startScanning === 'function' ||
typeof legacyScanner?.startScanning === 'function';
if (!hasScanner) {
try {
nativeModules.SelfMRZScannerModule = fallbackMrzScannerModule;
} catch {
// No-op: scanner stays unavailable until a native module is linked.
}
}
}
ensureMrzScannerModule();
type CallbackState =
| { status: 'Idle' }
| { status: 'Launching verification...' }

View File

@@ -60,6 +60,11 @@ android {
dependencies {
implementation("com.facebook.react:react-android:0.76.9")
implementation("com.facebook.react:hermes-android:0.76.9")
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:text-recognition:16.0.1")
if (project.hasProperty('newArchEnabled') ? newArchEnabled.toBoolean() : false) {
implementation("com.facebook.react:react-android-codegen:0.76.9")
}

View File

@@ -38,5 +38,10 @@
android:name="android.nfc.action.TECH_DISCOVERED"
android:resource="@xml/nfc_tech_filter" />
</activity>
<activity
android:name=".SelfMrzScannerActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@style/AppTheme" />
</application>
</manifest>

View File

@@ -17,7 +17,10 @@ import com.facebook.soloader.SoLoader
class MainApplication : Application(), ReactApplication {
private val mReactNativeHost: ReactNativeHost = object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> = PackageList(this).packages
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
add(SelfMRZScannerPackage())
}
override fun getJSMainModuleName(): String = "index"

View File

@@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package com.selfxyz.demoapp
import android.app.Activity
import android.content.Intent
import com.facebook.react.bridge.ActivityEventListener
import com.facebook.react.bridge.BaseActivityEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.Arguments
class SelfMRZScannerModule(
reactContext: ReactApplicationContext,
) : ReactContextBaseJavaModule(reactContext) {
private var scanPromise: Promise? = null
private val activityEventListener: ActivityEventListener =
object : BaseActivityEventListener() {
override fun onActivityResult(
activity: Activity,
requestCode: Int,
resultCode: Int,
data: Intent?,
) {
if (requestCode != REQUEST_SCAN_MRZ) {
return
}
val promise = scanPromise
scanPromise = null
if (promise == null) {
return
}
if (resultCode != Activity.RESULT_OK || data == null) {
val errorCode = data?.getStringExtra(SelfMrzScannerActivity.EXTRA_ERROR_CODE)
if (errorCode != null) {
promise.reject(errorCode, "MRZ scanning failed: $errorCode")
} else {
promise.reject("MRZ_SCAN_CANCELLED", "MRZ scanning cancelled")
}
return
}
val documentNumber = data.getStringExtra(SelfMrzScannerActivity.EXTRA_DOCUMENT_NUMBER)
val dateOfBirth = data.getStringExtra(SelfMrzScannerActivity.EXTRA_DATE_OF_BIRTH)
val dateOfExpiry = data.getStringExtra(SelfMrzScannerActivity.EXTRA_DATE_OF_EXPIRY)
if (documentNumber.isNullOrBlank() || dateOfBirth.isNullOrBlank() || dateOfExpiry.isNullOrBlank()) {
promise.reject("MRZ_SCAN_INVALID_RESULT", "MRZ scan returned incomplete data")
return
}
val result: WritableMap = Arguments.createMap().apply {
putString("documentNumber", documentNumber)
putString("dateOfBirth", dateOfBirth)
putString("dateOfExpiry", dateOfExpiry)
}
promise.resolve(result)
}
}
init {
reactApplicationContext.addActivityEventListener(activityEventListener)
}
override fun getName(): String = "SelfMRZScannerModule"
@ReactMethod
fun startScanning(promise: Promise) {
if (scanPromise != null) {
promise.reject("MRZ_SCAN_IN_PROGRESS", "MRZ scanning already in progress")
return
}
val currentActivity = currentActivity
if (currentActivity == null) {
promise.reject("ACTIVITY_DOES_NOT_EXIST", "Activity doesn't exist")
return
}
scanPromise = promise
try {
val intent = Intent(currentActivity, SelfMrzScannerActivity::class.java)
currentActivity.startActivityForResult(intent, REQUEST_SCAN_MRZ)
} catch (error: Exception) {
scanPromise = null
promise.reject("MRZ_SCAN_FAILED", "Failed to launch MRZ scanner", error)
}
}
companion object {
private const val REQUEST_SCAN_MRZ = 8811
}
}

View File

@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package com.selfxyz.demoapp
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class SelfMRZScannerPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(SelfMRZScannerModule(reactContext))
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
}

View File

@@ -0,0 +1,105 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package com.selfxyz.demoapp
internal enum class MrzDetectionState {
NO_TEXT,
TEXT_DETECTED,
ONE_MRZ_LINE,
TWO_MRZ_LINES,
}
internal data class SelfMrzResult(
val documentNumber: String,
val dateOfBirth: String,
val dateOfExpiry: String,
)
internal object SelfMrzParser {
private val mrzTd3Line = Regex("[A-Z0-9<]{44}")
private val mrzTd1Line = Regex("[A-Z0-9<]{30}")
fun parse(rawText: String): SelfMrzResult? {
val lines = extractMrzLines(rawText) ?: return null
return when {
lines.size == 2 && lines[0].length == 44 -> parseTd3(lines[0], lines[1])
lines.size == 3 && lines[0].length == 30 -> parseTd1(lines[0], lines[1])
else -> null
}
}
private fun extractMrzLines(text: String): List<String>? {
val cleanedLines =
text
.lines()
.map { it.trim().replace(" ", "").uppercase() }
.filter { it.isNotEmpty() }
val td3Lines = cleanedLines.filter { mrzTd3Line.matches(it) }
if (td3Lines.size >= 2) {
val first = td3Lines.firstOrNull { it.startsWith("P") || it.startsWith("V") }
if (first != null) {
val index = td3Lines.indexOf(first)
if (index >= 0 && index + 1 < td3Lines.size) {
return listOf(td3Lines[index], td3Lines[index + 1])
}
}
return td3Lines.takeLast(2)
}
val td1Lines = cleanedLines.filter { mrzTd1Line.matches(it) }
if (td1Lines.size >= 3) {
return td1Lines.takeLast(3)
}
return null
}
private fun parseTd3(line1: String, line2: String): SelfMrzResult {
val documentNumber = trimFiller(line2.substring(0, 9))
val dateOfBirth = normalizeDate(line2.substring(13, 19))
val dateOfExpiry = normalizeDate(line2.substring(21, 27))
return SelfMrzResult(
documentNumber = documentNumber,
dateOfBirth = dateOfBirth,
dateOfExpiry = dateOfExpiry,
)
}
private fun parseTd1(line1: String, line2: String): SelfMrzResult {
val documentNumber = trimFiller(line1.substring(5, 14))
val dateOfBirth = normalizeDate(line2.substring(0, 6))
val dateOfExpiry = normalizeDate(line2.substring(8, 14))
return SelfMrzResult(
documentNumber = documentNumber,
dateOfBirth = dateOfBirth,
dateOfExpiry = dateOfExpiry,
)
}
fun detectState(rawText: String): MrzDetectionState {
val cleanedLines =
rawText
.lines()
.map { it.trim().replace(" ", "").uppercase() }
.filter { it.isNotEmpty() }
if (cleanedLines.isEmpty()) return MrzDetectionState.NO_TEXT
val td3Count = cleanedLines.count { mrzTd3Line.matches(it) }
val td1Count = cleanedLines.count { mrzTd1Line.matches(it) }
if (td3Count >= 2 || td1Count >= 3) return MrzDetectionState.TWO_MRZ_LINES
if (td3Count == 1 || td1Count >= 1) return MrzDetectionState.ONE_MRZ_LINE
return MrzDetectionState.TEXT_DETECTED
}
private fun trimFiller(value: String): String = value.replace("<", "").trim()
private fun normalizeDate(value: String): String = value.replace('<', '0')
}

View File

@@ -0,0 +1,394 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package com.selfxyz.demoapp
import android.Manifest
import android.animation.ValueAnimator
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.CornerPathEffect
import android.graphics.Paint
import android.graphics.RectF
import android.os.Bundle
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Button
import android.widget.FrameLayout
import android.widget.TextView
import android.graphics.drawable.GradientDrawable
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class SelfMrzScannerActivity : ComponentActivity() {
private lateinit var previewView: PreviewView
private lateinit var cameraExecutor: ExecutorService
private lateinit var instructionView: TextView
private lateinit var viewfinderOverlay: MrzViewfinderView
private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
@Volatile
private var hasResult = false
private var currentDetectionState = MrzDetectionState.NO_TEXT
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
cameraExecutor = Executors.newSingleThreadExecutor()
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
setResult(RESULT_CANCELED)
finish()
}
})
setContentView(createScannerView())
if (hasCameraPermission()) {
startCamera()
} else {
requestPermissions(arrayOf(Manifest.permission.CAMERA), REQUEST_CAMERA_PERMISSION)
}
}
override fun onDestroy() {
super.onDestroy()
recognizer.close()
cameraExecutor.shutdown()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != REQUEST_CAMERA_PERMISSION) return
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
startCamera()
} else {
val data = Intent().apply {
putExtra(EXTRA_ERROR_CODE, "CAMERA_PERMISSION_DENIED")
}
setResult(RESULT_CANCELED, data)
finish()
}
}
private fun hasCameraPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
private fun updateDetectionState(state: MrzDetectionState) {
if (state == currentDetectionState) return
currentDetectionState = state
runOnUiThread {
instructionView.text = getInstructionText(state)
viewfinderOverlay.setDetectionState(state)
}
}
private fun getInstructionText(state: MrzDetectionState): String =
when (state) {
MrzDetectionState.NO_TEXT ->
"Position the MRZ (Machine Readable Zone) within the frame.\n" +
"The MRZ is the two-line code at the bottom of your passport."
MrzDetectionState.TEXT_DETECTED ->
"Text detected! Move closer to the MRZ code.\n" +
"Make sure the two-line code is clearly visible."
MrzDetectionState.ONE_MRZ_LINE ->
"One line detected! Almost there…\n" +
"Hold steady and ensure both MRZ lines are in frame."
MrzDetectionState.TWO_MRZ_LINES ->
"Both lines detected! Reading passport data…\n" +
"Keep the passport steady."
}
private fun createScannerView(): FrameLayout {
val root = FrameLayout(this)
previewView = PreviewView(this)
previewView.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
root.addView(previewView)
viewfinderOverlay = MrzViewfinderView(this)
viewfinderOverlay.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
root.addView(viewfinderOverlay)
instructionView = TextView(this)
instructionView.text = getInstructionText(MrzDetectionState.NO_TEXT)
instructionView.setBackgroundColor(0xBB000000.toInt())
instructionView.setTextColor(0xFFFFFFFF.toInt())
instructionView.textSize = 14f
instructionView.setPadding(dp(16), dp(12), dp(16), dp(12))
instructionView.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.TOP or Gravity.CENTER_HORIZONTAL,
).apply {
topMargin = dp(24)
marginStart = dp(20)
marginEnd = dp(20)
}
root.addView(instructionView)
val privacyNote = TextView(this)
privacyNote.text = "No photo is captured"
privacyNote.setTextColor(0xFFFFFFFF.toInt())
privacyNote.setBackgroundColor(0xBB000000.toInt())
privacyNote.textSize = 12f
privacyNote.setPadding(dp(12), dp(8), dp(12), dp(8))
privacyNote.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL,
).apply {
bottomMargin = dp(96)
}
root.addView(privacyNote)
val cancelButton = Button(this)
cancelButton.text = "Cancel"
cancelButton.setAllCaps(false)
cancelButton.setTextColor(0xFFFFFFFF.toInt())
cancelButton.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
setColor(0xAA111827.toInt())
cornerRadius = dp(10).toFloat()
}
cancelButton.setOnClickListener {
setResult(RESULT_CANCELED)
finish()
}
cancelButton.layoutParams = FrameLayout.LayoutParams(
dp(140),
dp(44),
Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL,
).apply {
bottomMargin = dp(36)
}
root.addView(cancelButton)
return root
}
private fun dp(value: Int): Int =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
value.toFloat(),
resources.displayMetrics,
).toInt()
private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
val cameraProvider =
try {
cameraProviderFuture.get()
} catch (e: Exception) {
setResult(RESULT_CANCELED)
finish()
return@addListener
}
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val analysis =
ImageAnalysis
.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
analysis.setAnalyzer(cameraExecutor) { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage == null || hasResult) {
imageProxy.close()
return@setAnalyzer
}
val inputImage =
InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
recognizer
.process(inputImage)
.addOnSuccessListener { result ->
if (hasResult) return@addOnSuccessListener
val rawText = result.text
val detectionState = SelfMrzParser.detectState(rawText)
updateDetectionState(detectionState)
val parsed = SelfMrzParser.parse(rawText)
if (parsed != null) {
hasResult = true
val data = Intent().apply {
putExtra(EXTRA_DOCUMENT_NUMBER, parsed.documentNumber)
putExtra(EXTRA_DATE_OF_BIRTH, parsed.dateOfBirth)
putExtra(EXTRA_DATE_OF_EXPIRY, parsed.dateOfExpiry)
}
setResult(RESULT_OK, data)
cameraProvider.unbindAll()
finish()
}
}.addOnCompleteListener {
imageProxy.close()
}
}
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
cameraProvider.unbindAll()
try {
cameraProvider.bindToLifecycle(this, cameraSelector, preview, analysis)
} catch (e: Exception) {
val data = Intent().apply {
putExtra(EXTRA_ERROR_CODE, "CAMERA_INIT_FAILED")
}
setResult(RESULT_CANCELED, data)
finish()
}
}, ContextCompat.getMainExecutor(this))
}
companion object {
const val EXTRA_DOCUMENT_NUMBER = "documentNumber"
const val EXTRA_DATE_OF_BIRTH = "dateOfBirth"
const val EXTRA_DATE_OF_EXPIRY = "dateOfExpiry"
const val EXTRA_ERROR_CODE = "errorCode"
private const val REQUEST_CAMERA_PERMISSION = 1101
}
}
private class MrzViewfinderView(context: android.content.Context) : View(context) {
private val frameWidthRatio = 0.85f
private val frameHeightRatio = 0.25f
private val cornerRadiusDp = 12f
private val bracketLengthDp = 40f
private val bracketThicknessDp = 4f
private val frameBorderDp = 3f
private var detectionState = MrzDetectionState.NO_TEXT
private var pulseAlpha = 1f
private val framePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = dpToPx(frameBorderDp)
pathEffect = CornerPathEffect(dpToPx(cornerRadiusDp))
}
private val bracketPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = dpToPx(bracketThicknessDp)
strokeCap = Paint.Cap.ROUND
}
private val pulseAnimator = ValueAnimator.ofFloat(1f, 0.3f).apply {
duration = 800
interpolator = AccelerateDecelerateInterpolator()
repeatMode = ValueAnimator.REVERSE
repeatCount = ValueAnimator.INFINITE
addUpdateListener { animator ->
pulseAlpha = animator.animatedValue as Float
if (detectionState == MrzDetectionState.TWO_MRZ_LINES) {
invalidate()
}
}
}
fun setDetectionState(state: MrzDetectionState) {
detectionState = state
if (state == MrzDetectionState.TWO_MRZ_LINES) {
if (!pulseAnimator.isRunning) pulseAnimator.start()
} else {
pulseAnimator.cancel()
pulseAlpha = 1f
}
invalidate()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
pulseAnimator.cancel()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val frameWidth = width * frameWidthRatio
val frameHeight = height * frameHeightRatio
val frameLeft = (width - frameWidth) / 2f
val frameTop = (height - frameHeight) / 2f
val rect = RectF(frameLeft, frameTop, frameLeft + frameWidth, frameTop + frameHeight)
val color = getStateColor()
val alpha = if (detectionState == MrzDetectionState.TWO_MRZ_LINES) pulseAlpha else 1f
val colorWithAlpha = Color.argb(
(Color.alpha(color) * alpha).toInt(),
Color.red(color),
Color.green(color),
Color.blue(color),
)
framePaint.color = colorWithAlpha
val cr = dpToPx(cornerRadiusDp)
canvas.drawRoundRect(rect, cr, cr, framePaint)
bracketPaint.color = colorWithAlpha
val bl = dpToPx(bracketLengthDp)
drawCornerBrackets(canvas, rect, bl)
}
private fun getStateColor(): Int =
when (detectionState) {
MrzDetectionState.NO_TEXT -> 0xFFEF5350.toInt()
MrzDetectionState.TEXT_DETECTED -> 0xFFFFA726.toInt()
MrzDetectionState.ONE_MRZ_LINE -> 0xFFFFEE58.toInt()
MrzDetectionState.TWO_MRZ_LINES -> 0xFF66BB6A.toInt()
}
private fun drawCornerBrackets(canvas: Canvas, rect: RectF, bracketLength: Float) {
// Top-left
canvas.drawLine(rect.left, rect.top + bracketLength, rect.left, rect.top, bracketPaint)
canvas.drawLine(rect.left, rect.top, rect.left + bracketLength, rect.top, bracketPaint)
// Top-right
canvas.drawLine(rect.right, rect.top + bracketLength, rect.right, rect.top, bracketPaint)
canvas.drawLine(rect.right, rect.top, rect.right - bracketLength, rect.top, bracketPaint)
// Bottom-left
canvas.drawLine(rect.left, rect.bottom - bracketLength, rect.left, rect.bottom, bracketPaint)
canvas.drawLine(rect.left, rect.bottom, rect.left + bracketLength, rect.bottom, bracketPaint)
// Bottom-right
canvas.drawLine(rect.right, rect.bottom - bracketLength, rect.right, rect.bottom, bracketPaint)
canvas.drawLine(rect.right, rect.bottom, rect.right - bracketLength, rect.bottom, bracketPaint)
}
private fun dpToPx(dp: Float): Float =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
}

View File

@@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
0C80B921A6F3F58F76C31292 /* libPods-SelfRNTestApp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-SelfRNTestApp.a */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
51A5C3E6155F4A1898B657C1 /* SelfMRZScannerModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C6A0E952D8E4727A5D01C4C /* SelfMRZScannerModule.m */; };
61D32E9D9CF14461B379C5D3 /* SelfMRZScannerModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDCA0D6E61EE4D0286778FBF /* SelfMRZScannerModule.swift */; };
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
F99B38F1555059F271D690CE /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
@@ -21,12 +23,14 @@
13B07FB71A68108700A75B9A /* Info-Debug.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = "Info-Debug.plist"; path = "SelfRNTestApp/Info-Debug.plist"; sourceTree = "<group>"; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = SelfRNTestApp/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
3B4392A12AC88292D35C810B /* Pods-SelfRNTestApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfRNTestApp.debug.xcconfig"; path = "Target Support Files/Pods-SelfRNTestApp/Pods-SelfRNTestApp.debug.xcconfig"; sourceTree = "<group>"; };
4C6A0E952D8E4727A5D01C4C /* SelfMRZScannerModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = SelfMRZScannerModule.m; path = SelfRNTestApp/SelfMRZScannerModule.m; sourceTree = "<group>"; };
5709B34CF0A7D63546082F79 /* Pods-SelfRNTestApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SelfRNTestApp.release.xcconfig"; path = "Target Support Files/Pods-SelfRNTestApp/Pods-SelfRNTestApp.release.xcconfig"; sourceTree = "<group>"; };
5DCACB8F33CDC322A6C60F78 /* libPods-SelfRNTestApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SelfRNTestApp.a"; sourceTree = BUILT_PRODUCTS_DIR; };
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = SelfRNTestApp/AppDelegate.swift; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = SelfRNTestApp/LaunchScreen.storyboard; sourceTree = "<group>"; };
BFB0C0F12EA8C47500DBA670 /* SelfRNTestApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = SelfRNTestApp.entitlements; path = SelfRNTestApp/SelfRNTestApp.entitlements; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
EDCA0D6E61EE4D0286778FBF /* SelfMRZScannerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SelfMRZScannerModule.swift; path = SelfRNTestApp/SelfMRZScannerModule.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -47,6 +51,8 @@
BFB0C0F12EA8C47500DBA670 /* SelfRNTestApp.entitlements */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
761780EC2CA45674006654EE /* AppDelegate.swift */,
EDCA0D6E61EE4D0286778FBF /* SelfMRZScannerModule.swift */,
4C6A0E952D8E4727A5D01C4C /* SelfMRZScannerModule.m */,
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* Info-Debug.plist */,
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
@@ -268,6 +274,8 @@
buildActionMask = 2147483647;
files = (
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,
61D32E9D9CF14461B379C5D3 /* SelfMRZScannerModule.swift in Sources */,
51A5C3E6155F4A1898B657C1 /* SelfMRZScannerModule.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(SelfMRZScannerModule, NSObject)
RCT_EXTERN_METHOD(startScanning:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
@end

View File

@@ -0,0 +1,591 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import AVFoundation
import Foundation
import React
import UIKit
import Vision
@objc(SelfMRZScannerModule)
final class SelfMRZScannerModule: NSObject, RCTBridgeModule {
private var resolveBlock: RCTPromiseResolveBlock?
private var rejectBlock: RCTPromiseRejectBlock?
private weak var scannerViewController: SelfMrzScannerViewController?
static func moduleName() -> String! {
"SelfMRZScannerModule"
}
static func requiresMainQueueSetup() -> Bool {
true
}
@objc func startScanning(
_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) {
DispatchQueue.main.async {
guard self.resolveBlock == nil else {
reject("MRZ_SCAN_IN_PROGRESS", "MRZ scanning already in progress", nil)
return
}
guard let presenter = SelfMRZScannerModule.topViewController() else {
reject("NO_VIEW_CONTROLLER", "Unable to find root view controller", nil)
return
}
self.resolveBlock = resolve
self.rejectBlock = reject
let scanner = SelfMrzScannerViewController()
scanner.onSuccess = { [weak self] result in
guard let self = self else { return }
self.resolveBlock?(result)
self.clearCallbacks()
}
scanner.onCancel = { [weak self] in
guard let self = self else { return }
self.rejectBlock?("MRZ_SCAN_CANCELLED", "MRZ scanning cancelled", nil)
self.clearCallbacks()
}
scanner.onError = { [weak self] code, message in
guard let self = self else { return }
self.rejectBlock?(code, message, nil)
self.clearCallbacks()
}
self.scannerViewController = scanner
scanner.modalPresentationStyle = .fullScreen
presenter.present(scanner, animated: true)
}
}
private func clearCallbacks() {
resolveBlock = nil
rejectBlock = nil
scannerViewController = nil
}
private static func topViewController(
from viewController: UIViewController? = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first(where: { $0.isKeyWindow })?
.rootViewController
) -> UIViewController? {
if let navigationController = viewController as? UINavigationController {
return topViewController(from: navigationController.visibleViewController)
}
if let tabController = viewController as? UITabBarController,
let selectedController = tabController.selectedViewController {
return topViewController(from: selectedController)
}
if let presented = viewController?.presentedViewController {
return topViewController(from: presented)
}
return viewController
}
}
// MARK: - Detection State
private enum MrzDetectionState {
case noText
case textDetected
case oneMrzLine
case twoMrzLines
var color: UIColor {
switch self {
case .noText: return UIColor(red: 0.94, green: 0.33, blue: 0.31, alpha: 1) // Red 400
case .textDetected: return UIColor(red: 1.00, green: 0.65, blue: 0.15, alpha: 1) // Orange 400
case .oneMrzLine: return UIColor(red: 1.00, green: 0.93, blue: 0.35, alpha: 1) // Yellow 400
case .twoMrzLines: return UIColor(red: 0.40, green: 0.73, blue: 0.42, alpha: 1) // Green 400
}
}
var instructionText: String {
switch self {
case .noText:
return "Position the MRZ (Machine Readable Zone) within the frame.\nThe MRZ is the two-line code at the bottom of your passport."
case .textDetected:
return "Text detected! Move closer to the MRZ code.\nMake sure the two-line code is clearly visible."
case .oneMrzLine:
return "One line detected! Almost there…\nHold steady and ensure both MRZ lines are in frame."
case .twoMrzLines:
return "Both lines detected! Reading passport data…\nKeep the passport steady."
}
}
}
// MARK: - Viewfinder Overlay
private final class MrzViewfinderOverlay: UIView {
private let frameWidthRatio: CGFloat = 0.85
private let frameHeightRatio: CGFloat = 0.25
private let cornerRadiusValue: CGFloat = 12
private let bracketLength: CGFloat = 40
private let bracketThickness: CGFloat = 4
private let frameBorderWidth: CGFloat = 3
private var detectionState: MrzDetectionState = .noText
private var pulseAlpha: CGFloat = 1.0
private var pulseTimer: CADisplayLink?
private var pulseDirection: CGFloat = -1
private let pulseSpeed: CGFloat = 1.4 // full cycle ~1.4s
func setDetectionState(_ state: MrzDetectionState) {
let changed = detectionState != state
detectionState = state
if state == .twoMrzLines {
if pulseTimer == nil { startPulse() }
} else {
stopPulse()
pulseAlpha = 1.0
}
if changed { setNeedsDisplay() }
}
private func startPulse() {
let link = CADisplayLink(target: self, selector: #selector(pulseTick))
link.add(to: .main, forMode: .common)
pulseTimer = link
}
private func stopPulse() {
pulseTimer?.invalidate()
pulseTimer = nil
}
@objc private func pulseTick() {
let dt = pulseTimer?.duration ?? (1.0 / 60.0)
pulseAlpha += pulseDirection * CGFloat(dt) * pulseSpeed
if pulseAlpha <= 0.3 { pulseAlpha = 0.3; pulseDirection = 1 }
if pulseAlpha >= 1.0 { pulseAlpha = 1.0; pulseDirection = -1 }
setNeedsDisplay()
}
deinit { stopPulse() }
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
let fw = bounds.width * frameWidthRatio
let fh = bounds.height * frameHeightRatio
let fx = (bounds.width - fw) / 2
let fy = (bounds.height - fh) / 2
let frameRect = CGRect(x: fx, y: fy, width: fw, height: fh)
let alpha = detectionState == .twoMrzLines ? pulseAlpha : 1.0
let color = detectionState.color.withAlphaComponent(alpha)
// Frame border
ctx.setStrokeColor(color.cgColor)
ctx.setLineWidth(frameBorderWidth)
let borderPath = UIBezierPath(roundedRect: frameRect, cornerRadius: cornerRadiusValue)
ctx.addPath(borderPath.cgPath)
ctx.strokePath()
// Corner brackets
ctx.setStrokeColor(color.cgColor)
ctx.setLineWidth(bracketThickness)
ctx.setLineCap(.round)
let bl = bracketLength
let r = frameRect
// Top-left
ctx.move(to: CGPoint(x: r.minX, y: r.minY + bl))
ctx.addLine(to: CGPoint(x: r.minX, y: r.minY))
ctx.addLine(to: CGPoint(x: r.minX + bl, y: r.minY))
ctx.strokePath()
// Top-right
ctx.move(to: CGPoint(x: r.maxX, y: r.minY + bl))
ctx.addLine(to: CGPoint(x: r.maxX, y: r.minY))
ctx.addLine(to: CGPoint(x: r.maxX - bl, y: r.minY))
ctx.strokePath()
// Bottom-left
ctx.move(to: CGPoint(x: r.minX, y: r.maxY - bl))
ctx.addLine(to: CGPoint(x: r.minX, y: r.maxY))
ctx.addLine(to: CGPoint(x: r.minX + bl, y: r.maxY))
ctx.strokePath()
// Bottom-right
ctx.move(to: CGPoint(x: r.maxX, y: r.maxY - bl))
ctx.addLine(to: CGPoint(x: r.maxX, y: r.maxY))
ctx.addLine(to: CGPoint(x: r.maxX - bl, y: r.maxY))
ctx.strokePath()
}
}
// MARK: - Scanner View Controller
private final class SelfMrzScannerViewController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate {
var onSuccess: (([String: String]) -> Void)?
var onCancel: (() -> Void)?
var onError: ((String, String) -> Void)?
private let captureSession = AVCaptureSession()
private let videoOutput = AVCaptureVideoDataOutput()
private let recognitionQueue = DispatchQueue(label: "com.selfxyz.rn.mrz.scanner")
private var previewLayer: AVCaptureVideoPreviewLayer?
private var isProcessingFrame = false
private var hasCompleted = false
private let viewfinderOverlay = MrzViewfinderOverlay()
private let instructionLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupCameraSession()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
recognitionQueue.async { [weak self] in
self?.captureSession.startRunning()
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.bounds
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if captureSession.isRunning {
recognitionQueue.async { [weak self] in
self?.captureSession.stopRunning()
}
}
}
private func setupUI() {
view.backgroundColor = .black
viewfinderOverlay.backgroundColor = .clear
viewfinderOverlay.isOpaque = false
viewfinderOverlay.translatesAutoresizingMaskIntoConstraints = false
instructionLabel.text = MrzDetectionState.noText.instructionText
instructionLabel.textColor = .white
instructionLabel.backgroundColor = UIColor.black.withAlphaComponent(0.75)
instructionLabel.textAlignment = .center
instructionLabel.numberOfLines = 0
instructionLabel.font = UIFont.systemFont(ofSize: 14)
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
instructionLabel.layer.cornerRadius = 10
instructionLabel.layer.masksToBounds = true
instructionLabel.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
let privacyLabel = UILabel()
privacyLabel.text = "No photo is captured"
privacyLabel.textColor = .white
privacyLabel.font = UIFont.systemFont(ofSize: 12)
privacyLabel.backgroundColor = UIColor.black.withAlphaComponent(0.75)
privacyLabel.textAlignment = .center
privacyLabel.numberOfLines = 1
privacyLabel.translatesAutoresizingMaskIntoConstraints = false
privacyLabel.layer.cornerRadius = 8
privacyLabel.layer.masksToBounds = true
let cancelButton = UIButton(type: .system)
cancelButton.setTitle("Cancel", for: .normal)
cancelButton.tintColor = .white
cancelButton.backgroundColor = UIColor(red: 0.07, green: 0.09, blue: 0.16, alpha: 0.85)
cancelButton.layer.cornerRadius = 8
cancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
cancelButton.translatesAutoresizingMaskIntoConstraints = false
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
view.addSubview(viewfinderOverlay)
view.addSubview(instructionLabel)
view.addSubview(privacyLabel)
view.addSubview(cancelButton)
NSLayoutConstraint.activate([
viewfinderOverlay.topAnchor.constraint(equalTo: view.topAnchor),
viewfinderOverlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
viewfinderOverlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
viewfinderOverlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
instructionLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
instructionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
instructionLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
privacyLabel.bottomAnchor.constraint(equalTo: cancelButton.topAnchor, constant: -18),
privacyLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
privacyLabel.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, multiplier: 0.8),
cancelButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24),
cancelButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
cancelButton.widthAnchor.constraint(equalToConstant: 140),
cancelButton.heightAnchor.constraint(equalToConstant: 44)
])
}
private func updateDetectionState(_ state: MrzDetectionState) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.instructionLabel.text = state.instructionText
self.viewfinderOverlay.setDetectionState(state)
}
}
private func setupCameraSession() {
guard AVCaptureDevice.authorizationStatus(for: .video) != .denied else {
onError?("CAMERA_PERMISSION_DENIED", "Camera permission denied")
dismiss(animated: true)
return
}
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
guard let self = self else { return }
if !granted {
DispatchQueue.main.async {
self.onError?("CAMERA_PERMISSION_DENIED", "Camera permission denied")
self.dismiss(animated: true)
}
return
}
self.recognitionQueue.async {
self.configureSessionInputs()
}
}
}
private func configureSessionInputs() {
captureSession.beginConfiguration()
captureSession.sessionPreset = .high
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device),
captureSession.canAddInput(input) else {
captureSession.commitConfiguration()
DispatchQueue.main.async {
self.onError?("CAMERA_INIT_FAILED", "Failed to initialize camera input")
self.dismiss(animated: true)
}
return
}
captureSession.addInput(input)
videoOutput.alwaysDiscardsLateVideoFrames = true
videoOutput.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
]
videoOutput.setSampleBufferDelegate(self, queue: recognitionQueue)
guard captureSession.canAddOutput(videoOutput) else {
captureSession.commitConfiguration()
DispatchQueue.main.async {
self.onError?("CAMERA_INIT_FAILED", "Failed to initialize camera output")
self.dismiss(animated: true)
}
return
}
captureSession.addOutput(videoOutput)
captureSession.commitConfiguration()
DispatchQueue.main.async {
let layer = AVCaptureVideoPreviewLayer(session: self.captureSession)
layer.videoGravity = .resizeAspectFill
layer.frame = self.view.bounds
self.view.layer.insertSublayer(layer, at: 0)
self.previewLayer = layer
}
}
@objc private func cancelTapped() {
guard !hasCompleted else { return }
hasCompleted = true
onCancel?()
dismiss(animated: true)
}
func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
guard !hasCompleted, !isProcessingFrame,
let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
isProcessingFrame = true
let request = VNRecognizeTextRequest { [weak self] request, _ in
guard let self = self else { return }
defer { self.isProcessingFrame = false }
guard !self.hasCompleted,
let observations = request.results as? [VNRecognizedTextObservation] else {
return
}
let recognizedLines: [String] = observations
.compactMap { observation in
observation.topCandidates(1).first.map { (text: $0.string, y: observation.boundingBox.origin.y) }
}
.sorted { $0.y > $1.y }
.map { $0.text }
let detectionState = SelfMrzSwiftParser.detectState(lines: recognizedLines)
self.updateDetectionState(detectionState)
let parsed = SelfMrzSwiftParser.parse(lines: recognizedLines)
guard let result = parsed else { return }
self.hasCompleted = true
self.captureSession.stopRunning()
DispatchQueue.main.async {
self.onSuccess?([
"documentNumber": result.documentNumber,
"dateOfBirth": result.dateOfBirth,
"dateOfExpiry": result.dateOfExpiry
])
self.dismiss(animated: true)
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = false
do {
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .up)
try handler.perform([request])
} catch {
isProcessingFrame = false
}
}
}
// MARK: - MRZ Parser
private struct SelfMrzSwiftResult {
let documentNumber: String
let dateOfBirth: String
let dateOfExpiry: String
}
private enum SelfMrzSwiftParser {
private static let td3Regex = try! NSRegularExpression(pattern: "^[A-Z0-9<]{44}$")
private static let td1Regex = try! NSRegularExpression(pattern: "^[A-Z0-9<]{30}$")
static func detectState(lines: [String]) -> MrzDetectionState {
let normalized = lines
.map { $0.uppercased().replacingOccurrences(of: " ", with: "") }
.filter { !$0.isEmpty }
if normalized.isEmpty { return .noText }
let td3Count = normalized.filter { matches(td3Regex, text: $0) }.count
let td1Count = normalized.filter { matches(td1Regex, text: $0) }.count
if td3Count >= 2 || td1Count >= 3 { return .twoMrzLines }
if td3Count >= 1 || td1Count >= 1 { return .oneMrzLine }
return .textDetected
}
static func parse(lines: [String]) -> SelfMrzSwiftResult? {
guard let extracted = extractMrzLines(lines: lines) else {
return nil
}
if extracted.count == 2, extracted[0].count == 44 {
return parseTd3(line2: extracted[1])
}
if extracted.count == 3, extracted[0].count == 30 {
return parseTd1(line1: extracted[0], line2: extracted[1])
}
return nil
}
private static func extractMrzLines(lines: [String]) -> [String]? {
let normalized = lines
.map { $0.uppercased().replacingOccurrences(of: " ", with: "") }
.filter { !$0.isEmpty }
let td3 = normalized.filter { matches(td3Regex, text: $0) }
if td3.count >= 2 {
if let first = td3.first(where: { $0.hasPrefix("P") || $0.hasPrefix("V") }),
let index = td3.firstIndex(of: first),
index + 1 < td3.count {
return [td3[index], td3[index + 1]]
}
return Array(td3.suffix(2))
}
let td1 = normalized.filter { matches(td1Regex, text: $0) }
if td1.count >= 3 {
return Array(td1.suffix(3))
}
return nil
}
private static func parseTd3(line2: String) -> SelfMrzSwiftResult {
let documentNumber = trimFiller(String(slice(line2, from: 0, length: 9)))
let dateOfBirth = normalizeDate(String(slice(line2, from: 13, length: 6)))
let dateOfExpiry = normalizeDate(String(slice(line2, from: 21, length: 6)))
return SelfMrzSwiftResult(
documentNumber: documentNumber,
dateOfBirth: dateOfBirth,
dateOfExpiry: dateOfExpiry
)
}
private static func parseTd1(line1: String, line2: String) -> SelfMrzSwiftResult {
let documentNumber = trimFiller(String(slice(line1, from: 5, length: 9)))
let dateOfBirth = normalizeDate(String(slice(line2, from: 0, length: 6)))
let dateOfExpiry = normalizeDate(String(slice(line2, from: 8, length: 6)))
return SelfMrzSwiftResult(
documentNumber: documentNumber,
dateOfBirth: dateOfBirth,
dateOfExpiry: dateOfExpiry
)
}
private static func matches(_ regex: NSRegularExpression, text: String) -> Bool {
let range = NSRange(location: 0, length: text.utf16.count)
return regex.firstMatch(in: text, options: [], range: range) != nil
}
private static func trimFiller(_ value: String) -> String {
value.replacingOccurrences(of: "<", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func normalizeDate(_ value: String) -> String {
value.replacingOccurrences(of: "<", with: "0")
}
private static func slice(_ text: String, from start: Int, length: Int) -> Substring {
let startIndex = text.index(text.startIndex, offsetBy: start)
let endIndex = text.index(startIndex, offsetBy: length)
return text[startIndex..<endIndex]
}
}

View File

@@ -66,6 +66,18 @@ describe('CameraHandler', () => {
}
});
it('scanMRZ throws MRZ_SCAN_CANCELLED when scanner cancellation is reported', async () => {
startScanning.mockRejectedValue({ code: 'MRZ_SCAN_CANCELLED' });
try {
await handler.handle('scanMRZ', {});
expect.unreachable('Should have thrown');
} catch (err: unknown) {
expect((err as { code: string }).code).toBe('MRZ_SCAN_CANCELLED');
expect((err as Error).message).toBe('MRZ scan cancelled');
}
});
it('unknown method throws METHOD_NOT_FOUND', async () => {
await expect(handler.handle('foo', {})).rejects.toThrow('Unknown camera method: foo');
});

View File

@@ -68,6 +68,15 @@ function normalizeMrzScanResult(result: unknown): MrzScanData {
};
}
function extractNativeErrorCode(err: unknown): string | undefined {
if (typeof err !== 'object' || err === null || !('code' in err)) {
return undefined;
}
const code = (err as { code?: unknown }).code;
return typeof code === 'string' ? code : undefined;
}
export class CameraHandler implements BridgeHandler {
readonly domain: BridgeDomain = 'camera';
@@ -92,6 +101,18 @@ export class CameraHandler implements BridgeHandler {
if (err instanceof BridgeHandlerError) {
throw err;
}
const nativeCode = extractNativeErrorCode(err);
if (nativeCode === 'MRZ_SCAN_CANCELLED') {
throw new BridgeHandlerError('MRZ_SCAN_CANCELLED', 'MRZ scan cancelled');
}
if (nativeCode === 'CAMERA_PERMISSION_DENIED') {
throw new BridgeHandlerError('CAMERA_PERMISSION_DENIED', 'Camera permission denied');
}
if (nativeCode === 'CAMERA_INIT_FAILED') {
throw new BridgeHandlerError('CAMERA_INIT_FAILED', 'Failed to initialize camera');
}
throw new BridgeHandlerError(
'MRZ_SCAN_FAILED',
'MRZ scan failed',

View File

@@ -20,6 +20,7 @@ import { useSelfClient } from '../../providers/SelfClientProvider';
const GENERIC_SCAN_ERROR_MESSAGE = 'We could not read your document. Please try again.';
const CAMERA_UNAVAILABLE_MESSAGE = 'Camera is not available on this device.';
const MRZ_INVALID_DATA_ERROR = 'MRZ_INVALID_DATA';
const MRZ_SCAN_CANCELLED_ERROR = 'MRZ_SCAN_CANCELLED';
export const DocumentCameraScreen: React.FC = () => {
const navigate = useNavigate();
@@ -108,6 +109,13 @@ export const DocumentCameraScreen: React.FC = () => {
(err instanceof Error && err.message === MRZ_INVALID_DATA_ERROR)
? 'MRZ_INVALID_DATA'
: 'MRZ_SCAN_FAILED';
if (bridgeErrorCode === MRZ_SCAN_CANCELLED_ERROR) {
analytics.trackEvent('camera_mrz_scan_cancelled');
navigate('/');
return;
}
setError(GENERIC_SCAN_ERROR_MESSAGE);
analytics.trackEvent('camera_mrz_scan_failed', { errorCode });
} finally {

View File

@@ -4,9 +4,10 @@ Append-only log of retired specs. When a spec is fully done and no longer needed
For full retirement process, see [SPECS-REORG-PLAN.md](./archive/SPECS-REORG-PLAN.md) placement rule 6.
| Spec | Retired | Outcome | Key decisions / lessons | Final PR(s) |
| ----------------------------------------------- | ---------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `specs/SPECS-REORG-PLAN.md` | 2026-03-05 | Reorganization completed and stabilized | Project-first structure adopted; singleton status folder removed; project-level naming standardized | N/A |
| `specs/projects/sdk/SPEC-AGENT-OPTIMIZATION.md` | 2026-03-05 | Agent-optimization rollout completed | All 6 execution chunks marked done; canonical guidance consolidated and stale scaffold reduced | N/A |
| `specs/projects/kmp/*` | 2026-03-05 | KMP specs retired from active project tree | KMP planning/execution remains under SDK workstreams; historical KMP context kept in `specs/archive/kmp/` | N/A |
| `specs/topics/CI-COVERAGE-GAPS.md` | 2026-03-06 | CI coverage expansion delivered | Added dedicated CI coverage workflows across webview, KMP, RN test app, and Swift package; moved to archive after rollout | N/A |
| Spec | Retired | Outcome | Key decisions / lessons | Final PR(s) |
| --------------------------------------------------------------- | ---------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `specs/SPECS-REORG-PLAN.md` | 2026-03-05 | Reorganization completed and stabilized | Project-first structure adopted; singleton status folder removed; project-level naming standardized | N/A |
| `specs/projects/sdk/SPEC-AGENT-OPTIMIZATION.md` | 2026-03-05 | Agent-optimization rollout completed | All 6 execution chunks marked done; canonical guidance consolidated and stale scaffold reduced | N/A |
| `specs/projects/kmp/*` | 2026-03-05 | KMP specs retired from active project tree | KMP planning/execution remains under SDK workstreams; historical KMP context kept in `specs/archive/kmp/` | N/A |
| `specs/topics/CI-COVERAGE-GAPS.md` | 2026-03-06 | CI coverage expansion delivered | Added dedicated CI coverage workflows across webview, KMP, RN test app, and Swift package; moved to archive after rollout | N/A |
| `specs/projects/sdk/workstreams/rn-sdk/SPEC-TEST-APP-CAMERA.md` | 2026-03-05 | Native MRZ camera wired into RN test app | Replaced JS fallback with real CameraX+MLKit (Android) and AVFoundation+Vision (iOS) native modules; added cancellation handling | N/A |

View File

@@ -0,0 +1,32 @@
# RN SDK Test App Native MRZ Camera Integration Plan
> Date: 2026-03-05
> Status: **Done** — archived 2026-03-05
## Goal
Wire real native MRZ scanning into the RN SDK test app so the WebView camera screen can execute `camera.scanMRZ` through `@selfxyz/rn-sdk` without the JavaScript fallback module.
## Scope
Primary scope: `packages/rn-sdk-test-app/`
Additional scope (cancellation behavior fix):
- `packages/rn-sdk/src/handlers/CameraHandler.ts` — map native `MRZ_SCAN_CANCELLED` distinctly
- `packages/rn-sdk/src/__tests__/CameraHandler.test.ts` — cancellation test
- `packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx` — handle cancellation as clean exit
## What Was Delivered
1. Removed JS fallback injection from `App.tsx`
2. Android native MRZ module (CameraX + ML Kit): `SelfMRZScannerModule.kt`, `SelfMRZScannerPackage.kt`, `SelfMrzScannerActivity.kt`, `SelfMrzParser.kt`
3. iOS native MRZ module (AVFoundation + Vision): `SelfMRZScannerModule.swift`, `SelfMRZScannerModule.m`
4. Cancellation behavior: CameraHandler distinguishes `MRZ_SCAN_CANCELLED` from generic failures; DocumentCameraScreen navigates home on cancel
5. Scanner UX: guide frame, instruction text, privacy note, styled cancel button on both platforms
## Validation
- `yarn workspace @selfxyz/rn-sdk test` — 65 tests pass
- Android `./gradlew assembleDebug` — pass
- iOS `xcodebuild` simulator build — pass

View File

@@ -0,0 +1,228 @@
# RN Test App MRZ Logic Consolidation — Follow-Up Spec
> Last updated: 2026-03-05
> Owner: Person 5 (RN SDK)
> Parent: [SDK Overview](../../OVERVIEW.md)
> Status: Active (Blocked on infra prerequisites)
## North Star
- **Goal:** Reuse KMP/Swift MRZ camera logic in the RN test app and remove duplicate native parsing/scanning logic.
- **Success metric:** RN test app `camera.scanMRZ` behavior matches KMP helper behavior on Android and iOS while preserving existing RN bridge semantics.
- **Success metric:** RN test app scanner UX matches KMP test app UX for viewfinder layout, detection-state transitions, instructional copy, and cancellation flow.
- **Constraint:** Native code stays thin wrappers; parsing/detection logic is not duplicated across shells.
## Overview
You are consolidating MRZ camera logic in `packages/rn-sdk-test-app/` to use existing SDK-native implementations (`CameraMrzBridgeHandler` on Android and `MrzCameraHelper` from `self-sdk-swift` on iOS). This matters because the current test app duplicates scanner/parser logic in four files and will drift from the KMP/Swift reference over time. You must land this as a follow-up PR only after two infrastructure blockers are resolved.
## Prerequisites
- Familiarity with RN Android Gradle setup (`settings.gradle`, `app/build.gradle`) and local composite builds.
- Familiarity with iOS local SPM linking in `project.pbxproj`.
- Read [RN SDK Spec](./SPEC.md) and [Native Shells Spec](../native-shells/SPEC.md) for handler contracts.
- Infra prerequisites must be merged first:
- AGP compatibility fix for composite build (`includeBuild`) between test app and `packages/kmp-sdk`.
- Android variant publication fix for `kmp-sdk/shared` Maven publication.
- Kotlin version mismatch: RN test app uses Kotlin 2.0.0 (required by RN 0.76.9), KMP SDK uses 2.1.0. Bumping the test app to 2.1.0 fails with `Found interface KotlinTopLevelExtension, but class was expected` — KGP 2.1.0 has a binary-incompatible API change that breaks the React Native Gradle plugin. This is a third infra blocker alongside AGP and Maven publication.
## The Problem
| File | Issue |
| -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMrzParser.kt` | Duplicates MRZ extraction, parsing, and detection-state logic already available in SDK-side implementation. |
| `packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMrzScannerActivity.kt` | Duplicates CameraX + ML Kit scan pipeline; should delegate to SDK handler logic while keeping local overlay UX. |
| `packages/rn-sdk-test-app/ios/SelfRNTestApp/SelfMRZScannerModule.swift` | Contains local parser + detection enum that duplicate Swift SDK helper behavior. |
| `packages/rn-sdk-test-app/android/app/build.gradle` | Declares CameraX/ML Kit deps locally despite these being available from SDK `shared` module once wiring is fixed. |
| `packages/rn-sdk-test-app/android/settings.gradle` | Missing `includeBuild("../../kmp-sdk")` wiring for local dependency substitution. |
| `packages/rn-sdk-test-app/ios/SelfRNTestApp.xcodeproj/project.pbxproj` | Missing local SPM dependency for `../../self-sdk-swift`. |
## Design Principles
1. **Keep RN bridge contract stable.** `SelfMRZScannerModule` cancellation and success payloads remain unchanged (`MRZ_SCAN_CANCELLED`, `documentNumber`, `dateOfBirth`, `dateOfExpiry`).
2. **Reuse SDK-native logic, mimic KMP test app UX.** Keep RN scanner UI behavior aligned with KMP test app (frame geometry, color-state mapping, instruction copy, pulse behavior, cancel affordance), while delegating camera/OCR/parsing/detection internals to SDK helper classes.
3. **Gate on infra, then refactor.** Do not start rewrite until composite build and publication prerequisites are passing.
4. **No scope creep into production SDK packages.** This follow-up is test-app integration cleanup, not a bridge protocol or `@selfxyz/rn-sdk` API change.
## Scope of Work
### Chunk MRZ-Infra-A: Composite Build Compatibility Gate (separate infra PR)
**Goal:** Make `includeBuild("../../kmp-sdk")` feasible for RN test app Android build.
**Known blocker (validated 2026-03-05):** `includeBuild("../../kmp-sdk")` in `settings.gradle` fails with:
```
Could not determine whether value 8.7.3 is compatible with value 8.11.2
using AgpVersionCompatibilityRule.
Using multiple versions of the Android Gradle plugin(8.7.3, 8.11.2)
in the same build is not allowed.
```
RN test app uses AGP 8.11.2 (via `com.android.tools.build:gradle:8.11.2` in `build.gradle`). KMP SDK uses AGP 8.7.3 (via `agp = "8.7.3"` in `gradle/libs.versions.toml`). Gradle forbids mixed AGP in composite builds.
**Also validated:** Kotlin version mismatch — RN test app is locked to Kotlin 2.0.0 by RN 0.76.9's Gradle plugin. KMP SDK uses 2.1.0. Bumping the test app to 2.1.0 causes a binary incompatibility (`KotlinTopLevelExtension` class→interface change). Resolution requires either downgrading KMP SDK to Kotlin 2.0.0 or upgrading RN to 0.78+ (which supports KGP 2.1.x).
**Likely touchpoints:**
- `packages/rn-sdk-test-app/android/build.gradle` — AGP version
- `packages/kmp-sdk/gradle/libs.versions.toml` — AGP version
- `packages/kmp-sdk/shared/build.gradle.kts` — verify no breaking changes with AGP bump
- Validate all other KMP SDK consumers still build after AGP alignment
**Definition of done:**
- `packages/rn-sdk-test-app/android/settings.gradle` can include `../../kmp-sdk` without AGP compatibility failure.
- `./gradlew :app:assembleDebug` succeeds in `packages/rn-sdk-test-app/android` with composite build enabled.
**You will NOT:**
- Rewrite MRZ scanner modules in this chunk.
- Change JS bridge payloads.
### Chunk MRZ-Infra-B: Android Variant Publication Gate (separate infra PR)
**Goal:** Publish/resolve the Android variant of `kmp-sdk/shared` with attributes usable by RN test app.
**Known blocker (validated 2026-03-05):** `publishToMavenLocal` publishes KMP metadata, JVM, and iOS variants but **not** the Android AAR. The `afterEvaluate` block in `shared/build.gradle.kts` references `components["release"]` which is null — the KMP Android target does not register as a standard Gradle component.
The published module metadata at `~/.m2/repository/xyz/self/sdk/shared/0.1.0/shared-0.1.0.module` contains variants for `iosArm64`, `iosSimulatorArm64`, `jvm`, and `metadata` — no Android variant.
**Likely touchpoints:**
- `packages/kmp-sdk/shared/build.gradle.kts` — fix Android variant publication (replace `components["release"]` with proper KMP Android publication wiring)
- `packages/kmp-sdk/build.gradle.kts` — if root-level publish config needed
**Definition of done:**
- `./gradlew :shared:publishToMavenLocal` produces an Android AAR or equivalent artifact under `~/.m2/repository/xyz/self/sdk/`.
- RN test app can resolve `implementation("xyz.self.sdk:shared")` (or agreed final coordinate) without missing-variant errors.
- `./gradlew :app:dependencies --configuration debugRuntimeClasspath` in RN test app shows resolved SDK Android artifact.
**You will NOT:**
- Change iOS integration in this chunk.
- Modify RN scanner UI.
### Chunk MRZ-Consolidation: Native MRZ Consolidation in RN Test App (follow-up PR)
**Depends on:** Chunk `MRZ-Infra-A` + `MRZ-Infra-B`.
**Goal:** Remove duplicate MRZ parsing/scanning logic from RN test app by delegating to SDK-native implementations.
**Steps:**
1. Android wiring:
- Add `includeBuild("../../kmp-sdk")` in `packages/rn-sdk-test-app/android/settings.gradle`.
- Add SDK dependency in `packages/rn-sdk-test-app/android/app/build.gradle`.
- Remove local CameraX/ML Kit dependencies from app module if provided transitively by SDK module.
2. Android scanner rewrite:
- Rewrite `SelfMrzScannerActivity.kt` to delegate camera+OCR+parse+detection progress to `CameraMrzBridgeHandler` (preview variant).
- Keep result intent contract and align overlay/instruction/cancel UX to match KMP test app behavior.
- Delete `SelfMrzParser.kt`.
3. iOS wiring:
- Add local SPM package `../../self-sdk-swift` in `packages/rn-sdk-test-app/ios/SelfRNTestApp.xcodeproj/project.pbxproj`.
- **Risk:** `self-sdk-swift` depends on `NFCPassportReader` via git SSH (`git@github.com:selfxyz/NFCPassportReader.git`). CI must have SSH key access to resolve this.
- **Risk:** CocoaPods (`Podfile`) and SPM can coexist in one Xcode workspace, but `pod install` / workspace regeneration can silently drop SPM-linked targets. Verify after every `pod install`.
- Minimum iOS target: Swift SDK requires iOS 15, RN test app targets iOS 15.1. Compatible.
4. iOS scanner rewrite:
- Rewrite `SelfMRZScannerModule.swift` to use `MrzCameraHelper` from `SelfSdkSwift`.
- API mapping for `MrzCameraHelper`:
- `createCameraPreviewView(frame:)` → returns `UIView` with camera preview
- `startCamera()` / `stopCamera()` → session lifecycle
- `scanMrzWithCallbacks(progress:completion:)` → progress callback receives `MrzDetectionStateIndex` (Int 0-3 mapping to NO_TEXT/TEXT_DETECTED/ONE_MRZ_LINE/TWO_MRZ_LINES), completion receives `(Bool, String)` where the string is JSON on success or error message on failure
- Map `MrzCameraHelper` completion to RN promise: success → `resolve(parsed JSON dict)`, failure → `reject("MRZ_SCAN_FAILED", ...)`, cancel → `reject("MRZ_SCAN_CANCELLED", ...)`.
- Remove local `SelfMrzSwiftParser`, `SelfMrzSwiftResult`, and `MrzDetectionState` enum.
- Preserve existing module exports and cancellation semantics in `SelfMRZScannerModule.m` and Swift bridge code.
- Align scanner UX behavior to KMP test app for state copy/colors/pulse and cancellation affordance.
**You will NOT:**
- Modify `packages/rn-sdk/src/handlers/CameraHandler.ts` contract.
- Modify `packages/webview-app/` camera flow semantics.
- Introduce new native modules or third-party camera libs in RN test app.
## Input / Output — Chunk Validation
**Input:**
```bash
# Android
cd packages/rn-sdk-test-app/android
./gradlew :app:assembleDebug
./gradlew :app:dependencies --configuration debugRuntimeClasspath
# iOS
cd packages/rn-sdk-test-app/ios
xcodebuild -workspace SelfRNTestApp.xcworkspace -scheme SelfRNTestApp -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16'
# JS/bridge regression
cd /Volumes/files/Projects/selfxyz/selfapp
yarn workspace @selfxyz/rn-sdk test
```
**Expected output:**
- Android debug build succeeds with SDK dependency resolution and no duplicate-class/dependency conflicts.
- iOS simulator build succeeds with local SPM package linked.
- `@selfxyz/rn-sdk` tests still pass (including cancellation behavior for camera handler).
**Manual verification:**
1. Launch RN test app camera flow and cancel scan on Android and iOS.
2. Confirm app receives `MRZ_SCAN_CANCELLED` and exits camera screen cleanly.
3. Scan a valid passport MRZ on Android and iOS.
4. Confirm result payload fields: `documentNumber`, `dateOfBirth`, `dateOfExpiry`.
5. Compare RN test app scanner screens against KMP test app and confirm parity for:
- Viewfinder geometry and corner treatment.
- Detection-state color transitions.
- Instructional copy per state.
- Pulse animation behavior in final detection state.
- Cancel button placement and behavior.
## Tests
| Test | Type | What it validates |
| --------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rn-test-app.android-assemble` | Build | Android app builds with SDK dependency wiring. |
| `rn-test-app.android-runtime-classpath` | Build | SDK artifact/variant resolution includes expected camera stack. |
| `rn-test-app.ios-build` | Build | Local `self-sdk-swift` SPM package is linked and compiles. |
| `camera.cancelled.android` | Manual | Cancellation still maps to `MRZ_SCAN_CANCELLED`. |
| `camera.cancelled.ios` | Manual | Cancellation still maps to `MRZ_SCAN_CANCELLED`. |
| `camera.success.android` | Manual | Returns required MRZ fields with delegated handler logic. |
| `camera.success.ios` | Manual | Returns required MRZ fields with delegated helper logic. |
| `camera.error-codes.contract` | Unit | Native modules produce exact same error codes before/after rewrite (`MRZ_SCAN_CANCELLED`, `MRZ_SCAN_FAILED`, `MRZ_SCAN_IN_PROGRESS`, `MRZ_SCAN_INVALID_RESULT`). |
## PR Strategy
1. **PR 1 (Infra):** Composite build AGP compatibility.
2. **PR 2 (Infra):** Android variant publication/resolution from `kmp-sdk/shared`.
3. **PR 3 (This spec):** MRZ logic consolidation in RN test app.
## Explored Paths (validated and blocked)
These approaches were tested on 2026-03-05 and failed. Do not re-attempt without resolving the underlying issue.
| Approach | Result | Root cause |
| ---------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `includeBuild("../../kmp-sdk")` in `settings.gradle` | BUILD FAILED | AGP 8.7.3 vs 8.11.2 — Gradle forbids mixed AGP in composite builds |
| `publishToMavenLocal` + `mavenLocal()` repo | Resolves metadata module only, no Android AAR | KMP SDK `afterEvaluate` publishing block references `components["release"]` which is null; KMP Android target doesn't register as standard Gradle component |
| `publishReleasePublicationToMavenLocal` explicitly | Produces empty POM, no AAR | Same root cause — no Android component registered |
| Bump RN test app Kotlin to 2.1.0 | BUILD FAILED: `Found interface KotlinTopLevelExtension, but class was expected` | KGP 2.1.0 has binary-incompatible API change vs RN 0.76.9 Gradle plugin |
## Fallback Plan
If infra PRs (MRZ-Infra-A + MRZ-Infra-B) remain blocked for >2 weeks, consider:
1. **Extract lightweight shared module.** Copy `MrzParser.kt`, `MrzDetectionState.kt`, and `MrzCameraHelper.swift` into a standalone `packages/mrz-shared/` module with minimal dependencies (no WebView, no NFC, no full SDK). Publish as its own artifact. Both KMP test app and RN test app consume it.
2. **Accept duplication with lint guard.** Keep current duplicated files but add a CI check that diffs the RN test app parser against the SDK parser and fails if they diverge.
Option 1 is preferable but adds a new package. Option 2 is zero-cost but fragile.
## Definition of Done
- Duplicate MRZ parser logic is removed from RN test app (`SelfMrzParser.kt` + local Swift parser/state types).
- RN test app scanner modules are thin wrappers around SDK-native camera helpers.
- Existing RN bridge API and cancellation semantics are unchanged.
- Android/iOS builds pass with dependency wiring in place.
- Follow-up PR description documents that the change is DRY consolidation after infra unblock.

View File

@@ -624,8 +624,12 @@ export class CameraHandler {
Input: { domain: "camera", method: "isAvailable", params: {} }
Output: true
Input: { domain: "camera", method: "scanMRZ", params: {} }
Output: (not yet implemented — throws { code: "NOT_IMPLEMENTED", message: "MRZ scan not yet implemented" })
Input: { domain: "camera", method: "scanMRZ", params: { documentType: "p", countryCode: "NLD" } }
Output: { documentNumber: "L898902C3", dateOfBirth: "740812", dateOfExpiry: "120415", documentType: "P", countryCode: "UTO" }
Error (cancelled): { code: "MRZ_SCAN_CANCELLED", message: "MRZ scan cancelled" }
Error (generic): { code: "MRZ_SCAN_FAILED", message: "MRZ scan failed" }
Error (no module): { code: "NOT_AVAILABLE", message: "MRZ scanner module is not installed" }
```
#### 6e. LifecycleHandler
@@ -984,16 +988,18 @@ Typecheck: No errors
#### Tests
| Test | Type | What it validates |
| --------------------------------------- | ----------- | --------------------------------------------------- |
| `NfcHandler.isSupported` | Unit | Delegates to NfcManager.isSupported() |
| `NfcHandler.cancelScan` | Unit | Calls NfcManager.cancelTechnologyRequest() |
| `NfcHandler.scan-progress-events` | Integration | Progress events stream to WebView during scan |
| `NfcHandler.scan-success` | Device | Full passport scan returns valid data |
| `NfcHandler.scan-nfc-unsupported` | Unit | Returns NFC_NOT_SUPPORTED error on incapable device |
| `NfcHandler.unknown-method` | Unit | Throws METHOD_NOT_FOUND |
| `CameraHandler.isAvailable` | Unit | Returns true |
| `CameraHandler.scanMRZ-not-implemented` | Unit | Throws NOT_IMPLEMENTED |
| Test | Type | What it validates |
| --------------------------------- | ----------- | --------------------------------------------------- |
| `NfcHandler.isSupported` | Unit | Delegates to NfcManager.isSupported() |
| `NfcHandler.cancelScan` | Unit | Calls NfcManager.cancelTechnologyRequest() |
| `NfcHandler.scan-progress-events` | Integration | Progress events stream to WebView during scan |
| `NfcHandler.scan-success` | Device | Full passport scan returns valid data |
| `NfcHandler.scan-nfc-unsupported` | Unit | Returns NFC_NOT_SUPPORTED error on incapable device |
| `NfcHandler.unknown-method` | Unit | Throws METHOD_NOT_FOUND |
| `CameraHandler.isAvailable` | Unit | Returns true when native module is present |
| `CameraHandler.scanMRZ-success` | Unit | Returns normalized MRZ data from native module |
| `CameraHandler.scanMRZ-cancelled` | Unit | Maps `MRZ_SCAN_CANCELLED` as distinct error code |
| `CameraHandler.scanMRZ-failed` | Unit | Maps generic native errors to `MRZ_SCAN_FAILED` |
---
@@ -1134,27 +1140,37 @@ ls packages/rn-sdk/assets/self-wallet/index.html # Assets bundled
## What Was Built
<!-- Added post-completion. Brief and factual. -->
### Architecture (brief)
<!-- 3-5 sentences. Pattern used, key decisions made during implementation. -->
`@selfxyz/rn-sdk` is a thin React Native wrapper (~300 LOC component + ~500 LOC handlers) around `react-native-webview`. `SelfVerification` renders a WebView loading the Vite bundle. `MessageRouter` dispatches bridge JSON messages to domain-specific handlers. Each handler wraps a single RN native module with no business logic. NFC uses APDU-level passport reading via `react-native-nfc-manager`. Camera delegates to a `SelfMRZScannerModule` native module provided by the host app. The bridge protocol is identical to the KMP native shell — the WebView cannot distinguish which shell it runs in.
### Deviations from Spec
| Spec said | We did | Why |
| --------- | ------ | --- |
| | | |
| Spec said | We did | Why |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| CameraHandler `scanMRZ` throws `NOT_IMPLEMENTED` | Delegates to `NativeModules.SelfMRZScannerModule` with result normalization and cancellation mapping | Real native MRZ scanning was implemented for the RN test app; handler updated to support it |
| CameraHandler has no error differentiation | Added `MRZ_SCAN_CANCELLED` as a distinct error code separate from `MRZ_SCAN_FAILED` | Cancellation is a clean UX exit, not a failure; WebView camera screen needs to distinguish them |
| `createHandlers` signature | Added `router` parameter for NfcHandler event streaming | Discovered during Chunk 5A that NFC progress events require router access |
### Key Files (final)
| File | Role |
| ---- | ---- |
| | |
| File | Role |
| -------------------------------------------------- | ------------------------------------------------------------------------ |
| `packages/rn-sdk/src/SelfVerification.tsx` | Public component — WebView wrapper with platform-aware asset loading |
| `packages/rn-sdk/src/bridge/MessageRouter.ts` | Bridge message dispatcher — routes JSON to handlers by domain |
| `packages/rn-sdk/src/handlers/NfcHandler.ts` | NFC passport reading with APDU + progress events |
| `packages/rn-sdk/src/handlers/CameraHandler.ts` | MRZ scanning via native module with cancellation support |
| `packages/rn-sdk/src/handlers/BiometricHandler.ts` | Biometric authentication wrapper |
| `packages/rn-sdk/src/handlers/KeychainHandler.ts` | Secure storage with `self_sdk_` prefix |
| `packages/rn-sdk/src/handlers/LifecycleHandler.ts` | Config delivery + result/dismiss callbacks |
| `packages/rn-sdk/src/types.ts` | Shared types (breaks circular dependency between component and handlers) |
### Lessons / Gotchas
- (to be filled post-implementation)
- `react-native-webview` must be a `peerDependency`, not a direct dependency — having it as direct causes JS/native version mismatch in host apps.
- `crypto.randomUUID()` is not available in all RN environments. Fallback: `crypto.randomUUID?.() ?? \`${Date.now()}-${Math.random()}\``.
- iOS asset loading uses RN `require()` + Metro `html` asset support. This avoids adding `react-native-fs` as a peer dependency.
- CameraHandler normalizes both `data`-wrapped and flat result payloads, plus legacy field names (`passportNumber`/`birthDate`/`expiryDate`), because different native module versions may return different shapes.
---
@@ -1165,6 +1181,7 @@ ls packages/rn-sdk/assets/self-wallet/index.html # Assets bundled
| Self Wallet migration to `SelfVerification` | Spec writing | Separate migration spec after SDK is stable |
| MiniPay RN sample integration | Spec writing | `integrations/SPEC.md` (already exists) |
| Camera library selection for MRZ scanning | Chunk 5C planning | Depends on host app camera setup -- may need configurable adapter |
| RN test app MRZ DRY consolidation | Post-MRZ shipping | [MRZ Consolidation Spec](./SPEC-MRZ-CONSOLIDATION.md) |
| iOS asset loading strategy (RNFS vs require) | PR #1765 review | **Decided:** Use RN `require()` + Metro `html` asset support |
## Spec Deviations