mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
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:
@@ -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...' }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
32
specs/archive/sdk/SPEC-TEST-APP-CAMERA.md
Normal file
32
specs/archive/sdk/SPEC-TEST-APP-CAMERA.md
Normal 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
|
||||
228
specs/projects/sdk/workstreams/rn-sdk/SPEC-MRZ-CONSOLIDATION.md
Normal file
228
specs/projects/sdk/workstreams/rn-sdk/SPEC-MRZ-CONSOLIDATION.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user