From cde591b9987cd4863377f52e1120f1bfb999e1a9 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Thu, 5 Mar 2026 22:48:41 -0800 Subject: [PATCH] feat(rn-sdk-test-app): native MRZ camera with progressive detection UX (#1816) * save wip * save working code * finalize code * pr feedback * fix --- packages/rn-sdk-test-app/App.tsx | 36 -- .../rn-sdk-test-app/android/app/build.gradle | 5 + .../android/app/src/main/AndroidManifest.xml | 5 + .../com/selfxyz/demoapp/MainApplication.kt | 5 +- .../selfxyz/demoapp/SelfMRZScannerModule.kt | 103 +++ .../selfxyz/demoapp/SelfMRZScannerPackage.kt | 18 + .../java/com/selfxyz/demoapp/SelfMrzParser.kt | 105 ++++ .../selfxyz/demoapp/SelfMrzScannerActivity.kt | 394 ++++++++++++ .../SelfRNTestApp.xcodeproj/project.pbxproj | 8 + .../ios/SelfRNTestApp/SelfMRZScannerModule.m | 12 + .../SelfRNTestApp/SelfMRZScannerModule.swift | 591 ++++++++++++++++++ .../src/__tests__/CameraHandler.test.ts | 12 + packages/rn-sdk/src/handlers/CameraHandler.ts | 21 + .../onboarding/DocumentCameraScreen.tsx | 8 + specs/ARCHIVE.md | 13 +- specs/archive/sdk/SPEC-TEST-APP-CAMERA.md | 32 + .../rn-sdk/SPEC-MRZ-CONSOLIDATION.md | 228 +++++++ specs/projects/sdk/workstreams/rn-sdk/SPEC.md | 61 +- 18 files changed, 1592 insertions(+), 65 deletions(-) create mode 100644 packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMRZScannerModule.kt create mode 100644 packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMRZScannerPackage.kt create mode 100644 packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMrzParser.kt create mode 100644 packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMrzScannerActivity.kt create mode 100644 packages/rn-sdk-test-app/ios/SelfRNTestApp/SelfMRZScannerModule.m create mode 100644 packages/rn-sdk-test-app/ios/SelfRNTestApp/SelfMRZScannerModule.swift create mode 100644 specs/archive/sdk/SPEC-TEST-APP-CAMERA.md create mode 100644 specs/projects/sdk/workstreams/rn-sdk/SPEC-MRZ-CONSOLIDATION.md diff --git a/packages/rn-sdk-test-app/App.tsx b/packages/rn-sdk-test-app/App.tsx index 147cfeb6a..a62cd80e9 100644 --- a/packages/rn-sdk-test-app/App.tsx +++ b/packages/rn-sdk-test-app/App.tsx @@ -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; - - 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...' } diff --git a/packages/rn-sdk-test-app/android/app/build.gradle b/packages/rn-sdk-test-app/android/app/build.gradle index c7e322126..fbc247e6f 100644 --- a/packages/rn-sdk-test-app/android/app/build.gradle +++ b/packages/rn-sdk-test-app/android/app/build.gradle @@ -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") } diff --git a/packages/rn-sdk-test-app/android/app/src/main/AndroidManifest.xml b/packages/rn-sdk-test-app/android/app/src/main/AndroidManifest.xml index c7dc7a9ae..0ffc763a6 100644 --- a/packages/rn-sdk-test-app/android/app/src/main/AndroidManifest.xml +++ b/packages/rn-sdk-test-app/android/app/src/main/AndroidManifest.xml @@ -38,5 +38,10 @@ android:name="android.nfc.action.TECH_DISCOVERED" android:resource="@xml/nfc_tech_filter" /> + diff --git a/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/MainApplication.kt b/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/MainApplication.kt index 46da342d1..da7afd708 100644 --- a/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/MainApplication.kt +++ b/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/MainApplication.kt @@ -17,7 +17,10 @@ import com.facebook.soloader.SoLoader class MainApplication : Application(), ReactApplication { private val mReactNativeHost: ReactNativeHost = object : DefaultReactNativeHost(this) { - override fun getPackages(): List = PackageList(this).packages + override fun getPackages(): List = + PackageList(this).packages.apply { + add(SelfMRZScannerPackage()) + } override fun getJSMainModuleName(): String = "index" diff --git a/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMRZScannerModule.kt b/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMRZScannerModule.kt new file mode 100644 index 000000000..d5dc0c610 --- /dev/null +++ b/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMRZScannerModule.kt @@ -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 + } +} diff --git a/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMRZScannerPackage.kt b/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMRZScannerPackage.kt new file mode 100644 index 000000000..6b1a8289e --- /dev/null +++ b/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMRZScannerPackage.kt @@ -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 = + listOf(SelfMRZScannerModule(reactContext)) + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMrzParser.kt b/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMrzParser.kt new file mode 100644 index 000000000..3c0716f0a --- /dev/null +++ b/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMrzParser.kt @@ -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? { + 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') +} diff --git a/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMrzScannerActivity.kt b/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMrzScannerActivity.kt new file mode 100644 index 000000000..36a2f33a6 --- /dev/null +++ b/packages/rn-sdk-test-app/android/app/src/main/java/com/selfxyz/demoapp/SelfMrzScannerActivity.kt @@ -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, + 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) +} diff --git a/packages/rn-sdk-test-app/ios/SelfRNTestApp.xcodeproj/project.pbxproj b/packages/rn-sdk-test-app/ios/SelfRNTestApp.xcodeproj/project.pbxproj index 61e028fc1..73885aa5f 100644 --- a/packages/rn-sdk-test-app/ios/SelfRNTestApp.xcodeproj/project.pbxproj +++ b/packages/rn-sdk-test-app/ios/SelfRNTestApp.xcodeproj/project.pbxproj @@ -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 = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = SelfRNTestApp/PrivacyInfo.xcprivacy; sourceTree = ""; }; 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 = ""; }; + 4C6A0E952D8E4727A5D01C4C /* SelfMRZScannerModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = SelfMRZScannerModule.m; path = SelfRNTestApp/SelfMRZScannerModule.m; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = SelfRNTestApp/LaunchScreen.storyboard; sourceTree = ""; }; BFB0C0F12EA8C47500DBA670 /* SelfRNTestApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = SelfRNTestApp.entitlements; path = SelfRNTestApp/SelfRNTestApp.entitlements; sourceTree = ""; }; 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 = ""; }; /* 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; }; diff --git a/packages/rn-sdk-test-app/ios/SelfRNTestApp/SelfMRZScannerModule.m b/packages/rn-sdk-test-app/ios/SelfRNTestApp/SelfMRZScannerModule.m new file mode 100644 index 000000000..2f9de9c81 --- /dev/null +++ b/packages/rn-sdk-test-app/ios/SelfRNTestApp/SelfMRZScannerModule.m @@ -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 +#import + +@interface RCT_EXTERN_MODULE(SelfMRZScannerModule, NSObject) + +RCT_EXTERN_METHOD(startScanning:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/packages/rn-sdk-test-app/ios/SelfRNTestApp/SelfMRZScannerModule.swift b/packages/rn-sdk-test-app/ios/SelfRNTestApp/SelfMRZScannerModule.swift new file mode 100644 index 000000000..5ac8b933d --- /dev/null +++ b/packages/rn-sdk-test-app/ios/SelfRNTestApp/SelfMRZScannerModule.swift @@ -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.. { } }); + 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'); }); diff --git a/packages/rn-sdk/src/handlers/CameraHandler.ts b/packages/rn-sdk/src/handlers/CameraHandler.ts index bafb9c3e5..0e8791804 100644 --- a/packages/rn-sdk/src/handlers/CameraHandler.ts +++ b/packages/rn-sdk/src/handlers/CameraHandler.ts @@ -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', diff --git a/packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx b/packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx index f9699182a..febc25b8e 100644 --- a/packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx +++ b/packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx @@ -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 { diff --git a/specs/ARCHIVE.md b/specs/ARCHIVE.md index 7e657f783..2383e8b3e 100644 --- a/specs/ARCHIVE.md +++ b/specs/ARCHIVE.md @@ -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 | diff --git a/specs/archive/sdk/SPEC-TEST-APP-CAMERA.md b/specs/archive/sdk/SPEC-TEST-APP-CAMERA.md new file mode 100644 index 000000000..594e429f7 --- /dev/null +++ b/specs/archive/sdk/SPEC-TEST-APP-CAMERA.md @@ -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 diff --git a/specs/projects/sdk/workstreams/rn-sdk/SPEC-MRZ-CONSOLIDATION.md b/specs/projects/sdk/workstreams/rn-sdk/SPEC-MRZ-CONSOLIDATION.md new file mode 100644 index 000000000..502a968ae --- /dev/null +++ b/specs/projects/sdk/workstreams/rn-sdk/SPEC-MRZ-CONSOLIDATION.md @@ -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. diff --git a/specs/projects/sdk/workstreams/rn-sdk/SPEC.md b/specs/projects/sdk/workstreams/rn-sdk/SPEC.md index 73f8bb1d0..a1a0a0a6c 100644 --- a/specs/projects/sdk/workstreams/rn-sdk/SPEC.md +++ b/specs/projects/sdk/workstreams/rn-sdk/SPEC.md @@ -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 - - ### Architecture (brief) - +`@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