[SELF-654] feat: add native modules (#919)

* feat: add ios native modules

* fix: extractMRZ

* Add android OCR native module

* wire native mrz module with adapter

* wire Native modules and fix tests

* fixes

* fix license header logic

* fix tests

* fix types

* fix: ci test

* fix: android build ci

* fix: ios build CI

* add podfile.lock

* add yarn.lock

* update lock files

* add yarn.lock

* add license

* order methods

* update lock

* pipeline fixes

* prettier

* update lock file

* fix native modules on external apps

* bundle @selfxyz/common into mobile-sdk-alpha

* chore: address yarn lock issues (#1004)

* address yarn lock issues

* fix postinstall

* update lock

* fix build issues

* fix pipeline issue

* fix ci

* fix bad merge

* fix android ci

* fix ci errors

* fix mobile sdk ci. stop gap fix for now until we create a package

* tweaks

* retry aapt2 approach

* use ^0.8.4 instead of ^0.8.0 due to the use of custom errors

* workflow fixes

* fix file

* update

* fix ci

* test ci fix

* fix test

---------

Co-authored-by: Justin Hernandez <transphorm@gmail.com>
Co-authored-by: Justin Hernandez <justin.hernandez@self.xyz>
This commit is contained in:
Seshanth.S🐺
2025-09-07 08:11:13 +05:30
committed by GitHub
parent 145cc89487
commit ec93ad564a
96 changed files with 8577 additions and 2502 deletions

98
packages/mobile-sdk-alpha/.gitignore vendored Normal file
View File

@@ -0,0 +1,98 @@
# OSX
#
.DS_Store
# Xcode
#
ios/build
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
ios/.xcode.env.local
**/.xcode.env.local
ios/certs
# Android/IntelliJ
#
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
*.keystore
!debug.keystore
# debug bundled builds
android/app/src/main/assets/*android.bundle
android/app/src/main/res/*/node_modules*
android/.kotlin/
android/app/upload-keystore.jks
android/app/play-store-key.json
android/build/*
# node.js
#
node_modules/
npm-debug.log
yarn-error.log
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/
**/fastlane/report.xml
**/fastlane/Preview.html
**/fastlane/screenshots
**/fastlane/test_output
**/fastlane/.env.secrets
# Bundle artifact
*.jsbundle
# Ruby / CocoaPods
/ios/Pods/
/vendor/bundle/
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
# testing
/coverage
.env
**/Pods/
.expo/
# Yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Bundle analyzer source maps
*-sourcemap.jsonandroid/.kotlin/errors/
# web app
.tamagui/*
# Maestro
maestro-results.xml

View File

@@ -0,0 +1,158 @@
buildscript {
ext {
buildToolsVersion = "35.0.0"
minSdkVersion = 23
compileSdkVersion = 35
targetSdkVersion = 35
ndkVersion = "27.0.11718014"
kotlinVersion = "1.9.24"
}
repositories {
google()
mavenCentral()
maven {
url "https://plugins.gradle.org/m2/"
}
}
dependencies {
classpath("com.android.tools.build:gradle:8.1.0")
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
maven {
url("$rootDir/../../../node_modules/react-native/android")
}
maven {
url("$rootDir/../../../node_modules/jsc-android/dist")
}
}
configurations.configureEach {
resolutionStrategy.dependencySubstitution {
substitute(platform(module('com.gemalto.jp2:jp2-android'))) using module('com.github.Tgo1014:JP2ForAndroid:1.0.4')
}
resolutionStrategy.force 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava'
}
}
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion rootProject.ext.compileSdkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
ndkVersion rootProject.ext.ndkVersion
namespace "com.selfxyz.selfSDK"
defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
// sourceCompatibility JavaVersion.VERSION_17
// targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
// jvmTarget = '1.8'
}
lintOptions {
abortOnError false
}
sourceSets {
main {
java.srcDirs = ['src/main/java']
res.srcDirs = ['src/main/res']
}
}
buildFeatures {
viewBinding true
}
packagingOptions {
exclude 'META-INF/proguard/androidx-annotations.pro'
exclude 'META-INF/androidx.exifinterface_exifinterface.version'
pickFirst '**/libc++_shared.so'
pickFirst '**/libjsc.so'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
// React Native
implementation 'com.facebook.react:react-native:+'
// NFC and Passport Reading dependencies
// implementation 'org.jmrtd:jmrtd:1.7.4'
implementation 'org.jmrtd:jmrtd:0.7.35'
implementation 'net.sf.scuba:scuba-sc-android:0.0.23'
// Bouncy Castle for cryptography
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
implementation 'org.bouncycastle:bcpkix-jdk18on:1.78.1'
// Commons IO for utilities
implementation 'commons-io:commons-io:2.11.0'
// OkHttp for network operations
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
// Gson for JSON handling
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.google.android.gms:play-services-mlkit-text-recognition:18.0.2'
implementation "com.github.fotoapparat:fotoapparat:2.7.0"
implementation 'androidx.multidex:multidex:2.0.1'
// RxJava dependencies
implementation 'io.reactivex.rxjava2:rxjava:2.2.21'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
// React Native dependencies
implementation 'com.facebook.react:react-android'
implementation 'com.facebook.react:react-native:+'
// MLKit dependencies
implementation 'com.google.android.gms:play-services-mlkit-text-recognition:18.0.2'
implementation 'com.google.mlkit:text-recognition:16.0.0'
// Camera dependencies
implementation "androidx.camera:camera-core:1.3.2"
implementation "androidx.camera:camera-camera2:1.3.2"
implementation "androidx.camera:camera-lifecycle:1.3.2"
implementation "androidx.camera:camera-view:1.3.2"
// Utility dependencies
implementation 'com.google.guava:guava:31.1-android'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'com.github.mhshams:jnbis:2.0.2'
}

View File

@@ -0,0 +1,54 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:+UseStringDeduplication
android.defaults.buildfeatures.buildconfig=true
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
android.enableJetifier=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=false
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
android.jetifier.ignorelist=bcprov-jdk18on
# Additional Gradle optimizations for better build performance
org.gradle.caching=true
org.gradle.configureondemand=true
# Better dependency caching and offline support
org.gradle.dependency.verification=off
# Suppress SDK version warnings for better build experience
android.suppressUnsupportedCompileSdk=35

Binary file not shown.

View File

@@ -0,0 +1,55 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# Keep React Native bridge methods
-keep class com.facebook.react.** { *; }
-keepclassmembers class * {
@com.facebook.react.bridge.ReactMethod <methods>;
}
# Keep Bouncy Castle classes
-keep class org.bouncycastle.** { *; }
-dontwarn org.bouncycastle.**
# Keep JMRTD classes
-keep class org.jmrtd.** { *; }
-dontwarn org.jmrtd.**
# Keep SCUBA classes
-keep class net.sf.scuba.** { *; }
-dontwarn net.sf.scuba.**
# Keep Commons IO
-keep class org.apache.commons.io.** { *; }
-dontwarn org.apache.commons.io.**
# Keep Gson
-keep class com.google.gson.** { *; }
-dontwarn com.google.gson.**
# Keep OkHttp
-keep class okhttp3.** { *; }
-dontwarn okhttp3.**
-dontwarn okio.**
# Keep our SDK classes
-keep class com.selfxyz.selfSDK.** { *; }

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.selfxyz.selfSDK">
<!-- NFC permissions -->
<uses-permission android:name="android.permission.NFC" />
<uses-feature
android:name="android.hardware.nfc"
android:required="false" />
<!-- Network permissions for certificate validation -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Camera permissions -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-permission android:name="android.permission.CAMERA_EXPOSURE" />
<!-- Other permissions that might be needed -->
<uses-permission android:name="android.permission.VIBRATE" />
</manifest>

View File

@@ -0,0 +1,892 @@
/*
* Copyright 2016 - 2022 Anton Tananaev (anton.tananaev@gmail.com)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
package com.selfxyz.selfSDK
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.graphics.Bitmap
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.os.AsyncTask
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.util.Base64
import android.util.Log
import android.widget.EditText
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import net.sf.scuba.smartcards.CardService
import org.apache.commons.io.IOUtils
import org.bouncycastle.asn1.ASN1InputStream
import org.bouncycastle.asn1.cms.ContentInfo
import org.bouncycastle.asn1.cms.SignedData
import org.bouncycastle.asn1.ASN1Primitive
import org.bouncycastle.asn1.ASN1Sequence
import org.bouncycastle.asn1.ASN1Set
import org.bouncycastle.asn1.ASN1TaggedObject;
import org.bouncycastle.asn1.icao.DataGroupHash;
import org.bouncycastle.asn1.icao.LDSSecurityObject;
import org.bouncycastle.asn1.x509.Certificate
import org.bouncycastle.jce.spec.ECNamedCurveSpec
import org.bouncycastle.jce.interfaces.ECPublicKey
import org.jmrtd.BACKey
import org.jmrtd.BACKeySpec
import org.jmrtd.AccessKeySpec
import org.jmrtd.PassportService
import org.jmrtd.lds.CardAccessFile
import org.jmrtd.lds.ChipAuthenticationPublicKeyInfo
import org.jmrtd.lds.PACEInfo
import org.jmrtd.PACEKeySpec
import org.jmrtd.lds.SODFile
import org.jmrtd.lds.SecurityInfo
import org.jmrtd.lds.icao.DG14File
import org.jmrtd.lds.icao.DG1File
import org.jmrtd.lds.icao.DG2File
import org.jmrtd.lds.iso19794.FaceImageInfo
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.io.DataInputStream
import java.io.InputStream
import java.io.IOException
import java.io.FileOutputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.security.KeyStore
import java.security.MessageDigest
import java.security.Signature
import java.security.cert.CertPathValidator
import java.security.cert.CertificateFactory
import java.security.cert.PKIXParameters
import java.security.cert.X509Certificate
import java.security.spec.MGF1ParameterSpec
import java.security.spec.PSSParameterSpec
import java.text.ParseException
import java.security.interfaces.RSAPublicKey
import java.text.SimpleDateFormat
import java.util.*
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.security.PublicKey
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import com.google.gson.Gson;
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReadableNativeMap
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.Arguments
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.Callback
// import net.sf.scuba.smartcards.APDUListener
// import net.sf.scuba.smartcards.APDUEvent
// import net.sf.scuba.smartcards.CommandAPDU
// import net.sf.scuba.smartcards.ResponseAPDU
// import org.jmrtd.WrappedAPDUEvent
object Messages {
const val SCANNING = "Scanning....."
const val STOP_MOVING = "Stop moving....."
const val AUTH = "Auth....."
const val COMPARING = "Comparing....."
const val COMPLETED = "Scanning completed"
const val RESET = ""
const val PACE_STARTED = "PACE started"
const val PACE_SUCCEEDED = "PACE succeeded"
const val PACE_FAILED = "PACE failed"
const val BAC_STARTED = "BAC started"
const val BAC_SUCCEEDED = "BAC succeeded"
const val BAC_FAILED = "BAC failed"
const val READING_COM = "Reading COM....."
const val READING_DG1 = "Reading DG1....."
const val READING_DG1_SUCCEEDED = "Reading DG1 succeeded"
const val READING_DG2 = "Reading DG2....."
const val READING_DG2_SUCCEEDED = "Reading DG2 succeeded"
const val READING_SOD = "Reading SOD....."
const val READING_SOD_SUCCEEDED = "Reading SOD succeeded"
const val READING_DG14 = "Reading DG14....."
const val CHIP_AUTH_SUCCEEDED = "Chip authentication succeeded"
}
class Response(json: String) : JSONObject(json) {
val type: String? = this.optString("type")
val data = this.optJSONArray("data")
?.let { 0.until(it.length()).map { i -> it.optJSONObject(i) } } // returns an array of JSONObject
?.map { Foo(it.toString()) } // transforms each JSONObject of the array into Foo
}
class Foo(json: String) : JSONObject(json) {
val id = this.optInt("id")
val title: String? = this.optString("title")
}
class RNSelfPassportReaderModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), LifecycleEventListener {
// private var passportNumberFromIntent = false
// private var encodePhotoToBase64 = false
private var scanPromise: Promise? = null
private var opts: ReadableMap? = null
data class Data(val id: String, val digest: String, val signature: String, val publicKey: String)
data class PassportData(
val dg1File: DG1File,
val dg2File: DG2File,
val sodFile: SODFile
)
interface DataCallback {
fun onDataReceived(data: String)
}
init {
instance = this
reactContext.addLifecycleEventListener(this)
}
override fun onCatalystInstanceDestroy() {
reactContext.removeLifecycleEventListener(this)
}
override fun getName(): String {
return "SelfPassportReader"
}
fun sendDataToJS(passportData: PassportData) {
val gson = Gson()
val dataMap = Arguments.createMap()
dataMap.putString("passportData", gson.toJson(passportData))
// Add all the other fields of the YourDataClass object to the map
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("ReadDataTaskCompleted", dataMap)
}
@ReactMethod
fun scan(opts: ReadableMap, promise: Promise) {
// Log scan start
logAnalyticsEvent("nfc_scan_started", mapOf(
"use_can" to (opts.getBoolean(PARAM_USE_CAN) ?: false),
"has_document_number" to (!opts.getString(PARAM_DOC_NUM).isNullOrEmpty()),
"has_can_number" to (!opts.getString(PARAM_CAN).isNullOrEmpty()),
"platform" to "android"
))
eventMessageEmitter(Messages.SCANNING)
val mNfcAdapter = NfcAdapter.getDefaultAdapter(reactApplicationContext)
// val mNfcAdapter = NfcAdapter.getDefaultAdapter(this.reactContext)
if (mNfcAdapter == null) {
logAnalyticsError("nfc_not_supported", "NFC chip reading not supported")
promise.reject("E_NOT_SUPPORTED", "NFC chip reading not supported")
return
}
if (!mNfcAdapter.isEnabled) {
logAnalyticsError("nfc_not_enabled", "NFC chip reading not enabled")
promise.reject("E_NOT_ENABLED", "NFC chip reading not enabled")
return
}
if (scanPromise != null) {
logAnalyticsError("nfc_already_scanning", "Already running a scan")
promise.reject("E_ONE_REQ_AT_A_TIME", "Already running a scan")
return
}
this.opts = opts
this.scanPromise = promise
Log.d("RNSelfPassportReaderModule", "opts set to: " + opts.toString())
}
private fun resetState() {
scanPromise = null
opts = null
}
override fun onHostDestroy() {
resetState()
}
override fun onHostResume() {
val mNfcAdapter = NfcAdapter.getDefaultAdapter(this.reactContext)
mNfcAdapter?.let {
val activity = reactApplicationContext.currentActivity
activity?.let {
val intent = Intent(it.applicationContext, it.javaClass)
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
val pendingIntent = PendingIntent.getActivity(it, 0, intent, PendingIntent.FLAG_MUTABLE) // PendingIntent.FLAG_UPDATE_CURRENT
val filter = arrayOf(arrayOf(IsoDep::class.java.name))
mNfcAdapter.enableForegroundDispatch(it, pendingIntent, null, filter)
}
}
}
override fun onHostPause() {
val mNfcAdapter = NfcAdapter.getDefaultAdapter(this.reactContext)
mNfcAdapter?.disableForegroundDispatch(reactApplicationContext.currentActivity)
}
fun receiveIntent(intent: Intent) {
Log.d("RNSelfPassportReaderModule", "receiveIntent: " + intent.action)
if (scanPromise == null) return
if (NfcAdapter.ACTION_TECH_DISCOVERED == intent.action) {
val tag: Tag? = intent.extras?.getParcelable(NfcAdapter.EXTRA_TAG)
if (tag?.techList?.contains("android.nfc.tech.IsoDep") == true) {
val passportNumber = opts?.getString(PARAM_DOC_NUM)
val expirationDate = opts?.getString(PARAM_DOE)
val birthDate = opts?.getString(PARAM_DOB)
val cardAccessNumber = opts?.getString(PARAM_CAN)
val useCan = opts?.getBoolean(PARAM_USE_CAN) ?: false
if (useCan && !cardAccessNumber.isNullOrEmpty()) {
val paceKey: PACEKeySpec = PACEKeySpec.createCANKey(cardAccessNumber)
ReadTask(IsoDep.get(tag), paceKey).execute()
}
else if (!passportNumber.isNullOrEmpty() && !expirationDate.isNullOrEmpty() && !birthDate.isNullOrEmpty()) {
val bacKey: BACKeySpec = BACKey(passportNumber, birthDate, expirationDate)
ReadTask(IsoDep.get(tag), bacKey).execute()
}
}
}
}
private fun toBase64(bitmap: Bitmap, quality: Int): String {
val byteArrayOutputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, byteArrayOutputStream)
val byteArray = byteArrayOutputStream.toByteArray()
return JPEG_DATA_URI_PREFIX + Base64.encodeToString(byteArray, Base64.NO_WRAP)
}
@SuppressLint("StaticFieldLeak")
private inner class ReadTask(
private val isoDep: IsoDep,
private val authKey: AccessKeySpec
) : AsyncTask<Void?, Void?, Exception?>() {
private lateinit var dg1File: DG1File
private lateinit var dg2File: DG2File
private lateinit var dg14File: DG14File
private lateinit var sodFile: SODFile
private var imageBase64: String? = null
private var bitmap: Bitmap? = null
private var chipAuthSucceeded = false
private var passiveAuthSuccess = false
private lateinit var dg14Encoded: ByteArray
override fun doInBackground(vararg params: Void?): Exception? {
try {
logAnalyticsEvent("nfc_reading_started")
eventMessageEmitter(Messages.STOP_MOVING)
isoDep.timeout = 20000
Log.e("MY_LOGS", "This should obvsly log")
val cardService = try {
CardService.getInstance(isoDep)
} catch (e: Exception) {
logAnalyticsError("nfc_card_service_failed", "Failed to get CardService instance: ${e.message}")
Log.e("MY_LOGS", "Failed to get CardService instance", e)
throw e
}
try {
cardService.open()
} catch (e: Exception) {
logAnalyticsError("nfc_card_service_open_failed", "Failed to open CardService: ${e.message}")
Log.e("MY_LOGS", "Failed to open CardService", e)
isoDep.close()
Thread.sleep(500)
isoDep.connect()
cardService.open()
}
Log.e("MY_LOGS", "cardService opened")
logAnalyticsEvent("nfc_card_service_opened")
val service = PassportService(
cardService,
PassportService.NORMAL_MAX_TRANCEIVE_LENGTH * 2,
PassportService.DEFAULT_MAX_BLOCKSIZE * 2,
false,
false,
)
// val apduMonitor = APDUMonitor()
// apduMonitor.setupAPDULogging(service)
Log.e("MY_LOGS", "service gotten")
service.open()
Log.e("MY_LOGS", "service opened")
logAnalyticsEvent("nfc_passport_service_opened")
var paceSucceeded = false
try {
Log.e("MY_LOGS", "trying to get cardAccessFile...")
val cardAccessFile = CardAccessFile(service.getInputStream(PassportService.EF_CARD_ACCESS))
Log.e("MY_LOGS", "cardAccessFile: ${cardAccessFile}")
val securityInfoCollection = cardAccessFile.securityInfos
for (securityInfo: SecurityInfo in securityInfoCollection) {
if (securityInfo is PACEInfo) {
Log.e("MY_LOGS", "trying PACE...")
eventMessageEmitter(Messages.PACE_STARTED)
service.doPACE(
authKey,
securityInfo.objectIdentifier,
PACEInfo.toParameterSpec(securityInfo.parameterId),
null,
)
Log.e("MY_LOGS", "PACE succeeded")
paceSucceeded = true
logAnalyticsEvent("nfc_pace_succeeded")
eventMessageEmitter(Messages.PACE_SUCCEEDED)
}
}
} catch (e: Exception) {
logAnalyticsError("nfc_pace_failed", "PACE authentication failed: ${e.message}")
logAnalyticsEvent("nfc_pace_attempted", mapOf(
"success" to false,
"error_type" to e.javaClass.simpleName
))
Log.w("MY_LOGS", e)
eventMessageEmitter(Messages.PACE_FAILED)
}
Log.e("MY_LOGS", "Sending select applet command with paceSucceeded: ${paceSucceeded}") // this is false so PACE doesn't succeed
service.sendSelectApplet(paceSucceeded)
if (!paceSucceeded && authKey is BACKeySpec) {
var bacSucceeded = false
var attempts = 0
val maxAttempts = 3
eventMessageEmitter(Messages.BAC_STARTED)
while (!bacSucceeded && attempts < maxAttempts) {
try {
attempts++
Log.e("MY_LOGS", "BAC attempt $attempts of $maxAttempts")
if (attempts > 1) {
// Wait before retry
Thread.sleep(500)
}
// Try to read EF_COM first
try {
eventMessageEmitter(Messages.READING_COM)
service.getInputStream(PassportService.EF_COM).read()
} catch (e: Exception) {
// EF_COM failed, do BAC
service.doBAC(authKey)
}
bacSucceeded = true
logAnalyticsEvent("nfc_bac_succeeded", mapOf("attempts" to attempts))
logAnalyticsEvent("nfc_bac_attempted", mapOf(
"success" to true,
"attempts" to attempts
))
Log.e("MY_LOGS", "BAC succeeded on attempt $attempts")
eventMessageEmitter(Messages.BAC_SUCCEEDED)
} catch (e: Exception) {
logAnalyticsError("nfc_bac_attempt_failed", "BAC attempt $attempts failed: ${e.message}")
logAnalyticsEvent("nfc_bac_attempted", mapOf(
"success" to false,
"attempt" to attempts,
"error_type" to e.javaClass.simpleName
))
Log.e("MY_LOGS", "BAC attempt $attempts failed: ${e.message}")
if (attempts == maxAttempts) {
eventMessageEmitter(Messages.BAC_FAILED)
throw e // Re-throw on final attempt
}
}
}
}
logAnalyticsEvent("nfc_reading_data_groups")
eventMessageEmitter(Messages.READING_DG1)
logAnalyticsEvent("nfc_reading_dg1_started")
val dg1In = service.getInputStream(PassportService.EF_DG1)
dg1File = DG1File(dg1In)
logAnalyticsEvent("nfc_reading_dg1_completed")
eventMessageEmitter(Messages.READING_DG1_SUCCEEDED)
// eventMessageEmitter("Reading DG2.....")
// val dg2In = service.getInputStream(PassportService.EF_DG2)
// dg2File = DG2File(dg2In)
logAnalyticsEvent("nfc_reading_sod_started")
eventMessageEmitter(Messages.READING_SOD)
val sodIn = service.getInputStream(PassportService.EF_SOD)
sodFile = SODFile(sodIn)
logAnalyticsEvent("nfc_reading_sod_completed")
eventMessageEmitter(Messages.READING_SOD_SUCCEEDED)
// val gson = Gson()
// Log.d(TAG, "============FIRST CONSOLE LOG=============")
// Log.d(TAG, "dg1File: " + gson.toJson(dg1File))
// Log.d(TAG, "dg2File: " + gson.toJson(dg2File))
// Log.d(TAG, "sodFile.docSigningCertificate: ${sodFile.docSigningCertificate}")
// Log.d(TAG, "publicKey: ${sodFile.docSigningCertificate.publicKey}")
// Log.d(TAG, "publicKey: ${sodFile.docSigningCertificate.publicKey.toString()}")
// Log.d(TAG, "publicKey: ${sodFile.docSigningCertificate.publicKey.format}")
// Log.d(TAG, "publicKey: ${Base64.encodeToString(sodFile.docSigningCertificate.publicKey.encoded, Base64.DEFAULT)}")
// Log.d(TAG, "sodFile.docSigningCertificate: ${gson.toJson(sodFile.docSigningCertificate)}")
// Log.d(TAG, "sodFile.dataGroupHashes: ${sodFile.dataGroupHashes}")
// Log.d(TAG, "sodFile.dataGroupHashes: ${gson.toJson(sodFile.dataGroupHashes)}")
// Log.d(TAG, "concatenated: $concatenated")
// Log.d(TAG, "concatenated: ${gson.toJson(concatenated)}")
// Log.d(TAG, "concatenated: ${gson.toJson(concatenated.joinToString("") { "%02x".format(it) })}")
// Log.d(TAG, "sodFile.eContent: ${sodFile.eContent}")
// Log.d(TAG, "sodFile.eContent: ${gson.toJson(sodFile.eContent)}")
// Log.d(TAG, "sodFile.eContent: ${gson.toJson(sodFile.eContent.joinToString("") { "%02x".format(it) })}")
// Log.d(TAG, "sodFile.encryptedDigest: ${sodFile.encryptedDigest}")
// Log.d(TAG, "sodFile.encryptedDigest: ${gson.toJson(sodFile.encryptedDigest)}")
// Log.d(TAG, "sodFile.encryptedDigest: ${gson.toJson(sodFile.encryptedDigest.joinToString("") { "%02x".format(it) })}")
// var id = passportNumberView.text.toString()
// try {
// postData(id, sodFile.eContent.joinToString("") { "%02x".format(it) }, sodFile.encryptedDigest.joinToString("") { "%02x".format(it) }, sodFile.docSigningCertificate.publicKey.toString())
// } catch (e: IOException) {
// e.printStackTrace()
// }
// Log.d(TAG, "============LET'S VERIFY THE SIGNATURE=============")
eventMessageEmitter(Messages.AUTH)
logAnalyticsEvent("nfc_authentication_started")
doChipAuth(service)
doPassiveAuth()
logAnalyticsEvent("nfc_authentication_completed")
// Log.d(TAG, "============SIGNATURE VERIFIED=============")
// sendDataToJS(PassportData(dg1File, dg2File, sodFile))
// Log.d(TAG, "============DATA SENT TO JS=============")
// val allFaceImageInfo: MutableList<FaceImageInfo> = ArrayList()
// dg2File.faceInfos.forEach {
// allFaceImageInfo.addAll(it.faceImageInfos)
// }
// if (allFaceImageInfo.isNotEmpty()) {
// val faceImageInfo = allFaceImageInfo.first()
// val imageLength = faceImageInfo.imageLength
// val dataInputStream = DataInputStream(faceImageInfo.imageInputStream)
// val buffer = ByteArray(imageLength)
// dataInputStream.readFully(buffer, 0, imageLength)
// val inputStream: InputStream = ByteArrayInputStream(buffer, 0, imageLength)
// bitmap = decodeImage(reactContext, faceImageInfo.mimeType, inputStream)
// imageBase64 = Base64.encodeToString(buffer, Base64.DEFAULT)
// }
} catch (e: Exception) {
logAnalyticsError("nfc_reading_failed", "NFC reading failed: ${e.message}")
eventMessageEmitter(Messages.RESET)
return e
}
return null
}
private fun doChipAuth(service: PassportService) {
try {
logAnalyticsEvent("nfc_reading_dg14_started")
eventMessageEmitter(Messages.READING_DG14)
val dg14In = service.getInputStream(PassportService.EF_DG14)
dg14Encoded = IOUtils.toByteArray(dg14In)
val dg14InByte = ByteArrayInputStream(dg14Encoded)
dg14File = DG14File(dg14InByte)
logAnalyticsEvent("nfc_reading_dg14_completed")
val dg14FileSecurityInfo = dg14File.securityInfos
for (securityInfo: SecurityInfo in dg14FileSecurityInfo) {
if (securityInfo is ChipAuthenticationPublicKeyInfo) {
service.doEACCA(
securityInfo.keyId,
ChipAuthenticationPublicKeyInfo.ID_CA_ECDH_AES_CBC_CMAC_256,
securityInfo.objectIdentifier,
securityInfo.subjectPublicKey,
)
chipAuthSucceeded = true
logAnalyticsEvent("nfc_chip_auth_succeeded")
eventMessageEmitter(Messages.CHIP_AUTH_SUCCEEDED)
}
}
} catch (e: Exception) {
logAnalyticsError("nfc_chip_auth_failed", "Chip authentication failed: ${e.message}")
Log.w(TAG, e)
}
}
private fun doPassiveAuth() {
try {
logAnalyticsEvent("nfc_passive_auth_started")
Log.d(TAG, "Starting passive authentication...")
val digest = MessageDigest.getInstance(sodFile.digestAlgorithm)
Log.d(TAG, "Using digest algorithm: ${sodFile.digestAlgorithm}")
val dataHashes = sodFile.dataGroupHashes
val dg14Hash = if (chipAuthSucceeded) digest.digest(dg14Encoded) else ByteArray(0)
val dg1Hash = digest.digest(dg1File.encoded)
// val dg2Hash = digest.digest(dg2File.encoded)
// val gson = Gson()
// Log.d(TAG, "dataHashes " + gson.toJson(dataHashes))
// val hexMap = sodFile.dataGroupHashes.mapValues { (_, value) ->
// value.joinToString("") { "%02x".format(it) }
// }
// Log.d(TAG, "hexMap: ${gson.toJson(hexMap)}")
// Log.d(TAG, "concatenated: $concatenated")
// Log.d(TAG, "concatenated: ${gson.toJson(concatenated)}")
// Log.d(TAG, "concatenated: ${gson.toJson(concatenated.joinToString("") { "%02x".format(it) })}")
// Log.d(TAG, "dg1File.encoded " + gson.toJson(dg1File.encoded))
// Log.d(TAG, "dg1File.encoded.joinToString " + gson.toJson(dg1File.encoded.joinToString("") { "%02x".format(it) }))
// Log.d(TAG, "dg1Hash " + gson.toJson(dg1Hash))
// Log.d(TAG, "dg1Hash.joinToString " + gson.toJson(dg1Hash.joinToString("") { "%02x".format(it) }))
// Log.d(TAG, "dg2File.encoded " + gson.toJson(dg2File.encoded))
// Log.d(TAG, "dg2File.encoded.joinToString " + gson.toJson(dg2File.encoded.joinToString("") { "%02x".format(it) }))
// Log.d(TAG, "dg2Hash " + gson.toJson(dg2Hash))
// Log.d(TAG, "dg2HashjoinToString " + gson.toJson(dg2Hash.joinToString("") { "%02x".format(it) }))
Log.d(TAG, "Comparing data group hashes...")
eventMessageEmitter(Messages.COMPARING)
logAnalyticsEvent("nfc_data_group_hash_verification_started")
// if (Arrays.equals(dg1Hash, dataHashes[1]) && Arrays.equals(dg2Hash, dataHashes[2])
if (Arrays.equals(dg1Hash, dataHashes[1])
&& (!chipAuthSucceeded || Arrays.equals(dg14Hash, dataHashes[14]))) {
Log.d(TAG, "Data group hashes match.")
logAnalyticsEvent("nfc_data_group_hash_verification_succeeded")
val asn1InputStream = ASN1InputStream(getReactApplicationContext().assets.open("masterList"))
val keystore = KeyStore.getInstance(KeyStore.getDefaultType())
keystore.load(null, null)
val cf = CertificateFactory.getInstance("X.509")
var p: ASN1Primitive?
var obj = asn1InputStream.readObject()
while (obj != null) {
p = obj
val asn1 = ASN1Sequence.getInstance(p)
if (asn1 == null || asn1.size() == 0) {
throw IllegalArgumentException("Null or empty sequence passed.")
}
if (asn1.size() != 2) {
throw IllegalArgumentException("Incorrect sequence size: " + asn1.size())
}
val certSet = ASN1Set.getInstance(asn1.getObjectAt(1))
for (i in 0 until certSet.size()) {
val certificate = Certificate.getInstance(certSet.getObjectAt(i))
val pemCertificate = certificate.encoded
val javaCertificate = cf.generateCertificate(ByteArrayInputStream(pemCertificate))
keystore.setCertificateEntry(i.toString(), javaCertificate)
}
obj = asn1InputStream.readObject()
}
val docSigningCertificates = sodFile.docSigningCertificates
Log.d(TAG, "Checking document signing certificates for validity...")
logAnalyticsEvent("nfc_certificate_validation_started", mapOf(
"certificate_count" to docSigningCertificates.size
))
for (docSigningCertificate: X509Certificate in docSigningCertificates) {
docSigningCertificate.checkValidity()
Log.d(TAG, "Certificate: ${docSigningCertificate.subjectDN} is valid.")
}
logAnalyticsEvent("nfc_certificate_validation_succeeded")
val cp = cf.generateCertPath(docSigningCertificates)
val pkixParameters = PKIXParameters(keystore)
pkixParameters.isRevocationEnabled = false
val cpv = CertPathValidator.getInstance(CertPathValidator.getDefaultType())
Log.d(TAG, "Validating certificate path...")
logAnalyticsEvent("nfc_certificate_path_validation_started")
cpv.validate(cp, pkixParameters)
logAnalyticsEvent("nfc_certificate_path_validation_succeeded")
var sodDigestEncryptionAlgorithm = sodFile.docSigningCertificate.sigAlgName
var isSSA = false
if ((sodDigestEncryptionAlgorithm == "SSAwithRSA/PSS")) {
sodDigestEncryptionAlgorithm = "SHA256withRSA/PSS"
isSSA = true
}
val sign = Signature.getInstance(sodDigestEncryptionAlgorithm)
if (isSSA) {
sign.setParameter(PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1))
}
sign.initVerify(sodFile.docSigningCertificate)
sign.update(sodFile.eContent)
logAnalyticsEvent("nfc_signature_verification_started", mapOf(
"algorithm" to sodDigestEncryptionAlgorithm
))
passiveAuthSuccess = sign.verify(sodFile.encryptedDigest)
if (passiveAuthSuccess) {
logAnalyticsEvent("nfc_signature_verification_succeeded")
logAnalyticsEvent("nfc_passive_auth_succeeded")
} else {
logAnalyticsError("nfc_signature_verification_failed", "Signature verification failed")
logAnalyticsError("nfc_passive_auth_failed", "Signature verification failed")
}
Log.d(TAG, "Passive authentication success: $passiveAuthSuccess")
} else {
logAnalyticsError("nfc_passive_auth_failed", "Data group hashes do not match")
}
} catch (e: Exception) {
logAnalyticsError("nfc_passive_auth_failed", "Passive authentication failed: ${e.message}")
eventMessageEmitter(Messages.RESET)
Log.w(TAG, "Exception in passive authentication", e)
}
}
override fun onPostExecute(result: Exception?) {
if (scanPromise == null) return
if (result != null) {
// Log.w(TAG, exceptionStack(result))
if (result is IOException) {
logAnalyticsError("nfc_scan_failed_disconnect", "Lost connection to chip on card")
scanPromise?.reject("E_SCAN_FAILED_DISCONNECT", "Lost connection to chip on card")
} else {
logAnalyticsError("nfc_scan_failed", "Scan failed: ${result.message}")
scanPromise?.reject("E_SCAN_FAILED", result)
}
resetState()
return
}
logAnalyticsEvent("nfc_scan_completed", mapOf(
"chip_auth_succeeded" to chipAuthSucceeded,
"passive_auth_success" to passiveAuthSuccess
))
val mrzInfo = dg1File.mrzInfo
val gson = Gson()
// val signedDataField = SODFile::class.java.getDeclaredField("signedData")
// signedDataField.isAccessible = true
// val signedData = signedDataField.get(sodFile) as SignedData
val eContentAsn1InputStream = ASN1InputStream(sodFile.eContent.inputStream())
// val eContentDecomposed: ASN1Primitive = eContentAsn1InputStream.readObject()
val passport = Arguments.createMap()
passport.putString("mrz", mrzInfo.toString())
passport.putString("signatureAlgorithm", sodFile.docSigningCertificate.sigAlgName) // this one is new
Log.d(TAG, "sodFile.docSigningCertificate: ${sodFile.docSigningCertificate}")
val certificate = sodFile.docSigningCertificate
val certificateBytes = certificate.encoded
val certificateBase64 = Base64.encodeToString(certificateBytes, Base64.DEFAULT)
Log.d(TAG, "certificateBase64: ${certificateBase64}")
passport.putString("documentSigningCertificate", certificateBase64)
val publicKey = sodFile.docSigningCertificate.publicKey
if (publicKey is RSAPublicKey) {
passport.putString("modulus", publicKey.modulus.toString())
} else if (publicKey is ECPublicKey) {
// Handle the elliptic curve public key case
// val w = publicKey.getW()
// passport.putString("publicKeyW", w.toString())
// val ecParams = publicKey.getParams()
// passport.putInt("cofactor", ecParams.getCofactor())
// passport.putString("curve", ecParams.getCurve().toString())
// passport.putString("generator", ecParams.getGenerator().toString())
// passport.putString("order", ecParams.getOrder().toString())
// if (ecParams is ECNamedCurveSpec) {
// passport.putString("curveName", ecParams.getName())
// }
// Old one, probably wrong:
// passport.putString("curveName", (publicKey.parameters as ECNamedCurveSpec).name)
// passport.putString("curveName", (publicKey.parameters.algorithm)) or maybe this
passport.putString("publicKeyQ", publicKey.q.toString())
}
passport.putString("dataGroupHashes", gson.toJson(sodFile.dataGroupHashes))
passport.putString("eContent", gson.toJson(sodFile.eContent))
passport.putString("encryptedDigest", gson.toJson(sodFile.encryptedDigest))
// passport.putString("encapContentInfo", gson.toJson(sodFile.encapContentInfo))
// passport.putString("contentInfo", gson.toJson(sodFile.contentInfo))
passport.putString("digestAlgorithm", gson.toJson(sodFile.digestAlgorithm))
passport.putString("signerInfoDigestAlgorithm", gson.toJson(sodFile.signerInfoDigestAlgorithm))
passport.putString("digestEncryptionAlgorithm", gson.toJson(sodFile.digestEncryptionAlgorithm))
passport.putString("LDSVersion", gson.toJson(sodFile.getLDSVersion()))
passport.putString("unicodeVersion", gson.toJson(sodFile.unicodeVersion))
// Get EncapContent (data group hashes) using reflection in Kotlin
val getENC: Method = SODFile::class.java.getDeclaredMethod("getLDSSecurityObject", SignedData::class.java)
getENC.isAccessible = true
val signedDataField: Field = sodFile::class.java.getDeclaredField("signedData")
signedDataField.isAccessible = true
val signedData: SignedData = signedDataField.get(sodFile) as SignedData
val ldsso: LDSSecurityObject = getENC.invoke(sodFile, signedData) as LDSSecurityObject
passport.putString("encapContent", gson.toJson(ldsso.encoded))
// Convert the document signing certificate to PEM format
val docSigningCert = sodFile.docSigningCertificate
val pemCert = "-----BEGIN CERTIFICATE-----\n" + Base64.encodeToString(docSigningCert.encoded, Base64.DEFAULT) + "-----END CERTIFICATE-----"
passport.putString("documentSigningCertificate", pemCert)
// passport.putString("getDocSigningCertificate", gson.toJson(sodFile.getDocSigningCertificate))
// passport.putString("getIssuerX500Principal", gson.toJson(sodFile.getIssuerX500Principal))
// passport.putString("getSerialNumber", gson.toJson(sodFile.getSerialNumber))
// Another way to get signing time is to get into signedData.signerInfos, then search for the ICO identifier 1.2.840.113549.1.9.5
// passport.putString("signerInfos", gson.toJson(signedData.signerInfos))
// Log.d(TAG, "signedData.digestAlgorithms: ${gson.toJson(signedData.digestAlgorithms)}")
// Log.d(TAG, "signedData.signerInfos: ${gson.toJson(signedData.signerInfos)}")
// Log.d(TAG, "signedData.certificates: ${gson.toJson(signedData.certificates)}")
// var quality = 100
// val base64 = bitmap?.let { toBase64(it, quality) }
// val photo = Arguments.createMap()
// photo.putString("base64", base64 ?: "")
// photo.putInt("width", bitmap?.width ?: 0)
// photo.putInt("height", bitmap?.height ?: 0)
// passport.putMap("photo", photo)
// passport.putString("dg2File", gson.toJson(dg2File))
eventMessageEmitter(Messages.COMPLETED)
scanPromise?.resolve(passport)
eventMessageEmitter(Messages.RESET)
resetState()
}
}
private fun convertDate(input: String?): String? {
if (input == null) {
return null
}
return try {
SimpleDateFormat("yyMMdd", Locale.US).format(SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(input)!!)
} catch (e: ParseException) {
// Log.w(RNSelfPassportReaderModule::class.java.simpleName, e)
null
}
}
private fun eventMessageEmitter(message: String) {
if (reactContext.hasActiveCatalystInstance()) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("NativeEvent", message)
} else {
Log.d(TAG, "Error")
}
}
private fun logAnalyticsEvent(eventName: String, params: Map<String, Any> = emptyMap()) {
try {
val logData = JSONObject()
logData.put("level", "info")
logData.put("category", "NFC")
logData.put("message", "Analytics Event: $eventName")
if (params.isNotEmpty()) {
logData.put("data", JSONObject(Gson().toJson(params)))
}
// Send to React Native via logEvent emission using the same working approach
emitLogEvent(logData.toString())
// Also log to Android logs for debugging
Log.d(TAG, "Analytics event: $eventName with params: $params")
} catch (e: Exception) {
Log.e(TAG, "Error logging analytics event", e)
}
}
private fun logAnalyticsError(eventName: String, message: String) {
try {
val logData = JSONObject()
logData.put("level", "error")
logData.put("category", "NFC")
logData.put("message", "Analytics Error: $message")
logData.put("data", JSONObject().apply {
put("event", eventName)
put("error_description", message)
})
emitLogEvent(logData.toString())
Log.e(TAG, "Analytics error: $eventName - $message")
} catch (e: Exception) {
Log.e(TAG, "Error logging analytics error", e)
}
}
private fun emitLogEvent(message: String) {
if (reactContext.hasActiveCatalystInstance()) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("logEvent", message)
} else {
Log.d(TAG, "Cannot emit logEvent - no active catalyst instance")
}
}
@ReactMethod
fun reset() {
logAnalyticsEvent("nfc_scan_reset")
resetState()
}
companion object {
private val TAG = RNSelfPassportReaderModule::class.java.simpleName
private const val PARAM_DOC_NUM = "documentNumber";
private const val PARAM_DOB = "dateOfBirth";
private const val PARAM_DOE = "dateOfExpiry";
private const val PARAM_CAN = "canNumber";
private const val PARAM_USE_CAN = "useCan";
const val JPEG_DATA_URI_PREFIX = "data:image/jpeg;base64,"
private const val KEY_IS_SUPPORTED = "isSupported"
private var instance: RNSelfPassportReaderModule? = null
fun getInstance(): RNSelfPassportReaderModule {
return instance ?: throw IllegalStateException("RNSelfPassportReaderModule instance is not initialized")
}
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2016 Anton Tananaev (anton.tananaev@gmail.com)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.selfxyz.selfSDK
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class RNSelfPassportReaderPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(
RNSelfPassportReaderModule(reactContext),
SelfMRZScannerModule(reactContext)
)
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return listOf(SelfOCRViewManager(reactContext))
}
}

View File

@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: 2025 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.selfSDK
import androidx.fragment.app.FragmentActivity
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import android.view.ViewGroup
import android.view.View
import android.widget.FrameLayout
import com.selfxyz.selfSDK.ui.CameraMLKitFragment
import org.jmrtd.lds.icao.MRZInfo
class SelfMRZScannerModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext), CameraMLKitFragment.CameraMLKitCallback {
override fun getName() = "SelfMRZScannerModule"
private var scanPromise: Promise? = null
@ReactMethod
fun startScanning(promise: Promise) {
scanPromise = promise
val activity = reactApplicationContext.currentActivity as? FragmentActivity ?: return
activity.runOnUiThread {
val container = FrameLayout(activity)
val containerId = View.generateViewId()
container.id = containerId
activity.addContentView(container, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
))
activity.supportFragmentManager
.beginTransaction()
.replace(containerId, CameraMLKitFragment(this))
.commit()
}
}
override fun onPassportRead(mrzInfo: MRZInfo) {
scanPromise?.resolve(mrzInfo.toString())
scanPromise = null
}
override fun onError(e: Exception) {
scanPromise?.reject(e)
scanPromise = null
}
}

View File

@@ -0,0 +1,151 @@
// SPDX-FileCopyrightText: 2025 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.selfSDK
import android.view.Choreographer
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.FragmentActivity
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.annotations.ReactPropGroup
import com.facebook.react.uimanager.events.RCTEventEmitter
import org.jmrtd.lds.icao.MRZInfo
import com.selfxyz.selfSDK.ui.CameraMLKitFragment
class SelfOCRViewManager(
open val reactContext: ReactApplicationContext
) : ViewGroupManager<FrameLayout>(), CameraMLKitFragment.CameraMLKitCallback {
private var propWidth: Int? = null
private var propHeight: Int? = null
private var reactNativeViewId: Int? = null
override fun getName() = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext) =
FrameLayout(reactContext)
override fun getCommandsMap() = mapOf(
"create" to COMMAND_CREATE,
"destroy" to COMMAND_DESTROY
)
override fun receiveCommand(
root: FrameLayout,
commandId: String,
args: ReadableArray?
) {
super.receiveCommand(root, commandId, args)
val reactNativeViewId = requireNotNull(args).getInt(0)
when (commandId.toInt()) {
COMMAND_CREATE -> createFragment(root, reactNativeViewId)
COMMAND_DESTROY -> destroyFragment(root, reactNativeViewId)
}
}
@ReactPropGroup(names = ["width", "height"], customType = "Style")
fun setStyle(view: FrameLayout, index: Int, value: Int) {
if (index == 0) propWidth = value
if (index == 1) propHeight = value
}
private fun createFragment(root: FrameLayout, reactNativeViewId: Int) {
this.reactNativeViewId = reactNativeViewId
val parentView = root.findViewById<ViewGroup>(reactNativeViewId)
setupLayout(parentView)
val cameraFragment = CameraMLKitFragment(this)
// val cameraFragment = MyFragment()
val activity = reactContext.currentActivity as FragmentActivity
activity.supportFragmentManager
.beginTransaction()
.replace(reactNativeViewId, cameraFragment, reactNativeViewId.toString())
.commit()
}
private fun destroyFragment(root: FrameLayout, reactNativeViewId: Int) {
val parentView = root.findViewById<ViewGroup>(reactNativeViewId)
setupLayout(parentView)
val activity = reactContext.currentActivity as FragmentActivity
val cameraFragment = activity.supportFragmentManager.findFragmentByTag(reactNativeViewId.toString())
cameraFragment?.let {
activity.supportFragmentManager
.beginTransaction()
.remove(it)
.commit()
}
}
private fun setupLayout(view: View) {
Choreographer.getInstance().postFrameCallback(object: Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
manuallyLayoutChildren(view)
view.viewTreeObserver.dispatchOnGlobalLayout()
Choreographer.getInstance().postFrameCallback(this)
}
})
}
private fun manuallyLayoutChildren(view: View) {
// propWidth and propHeight coming from react-native props
val width = requireNotNull(propWidth)
val height = requireNotNull(propHeight)
view.measure(
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY))
view.layout(0, 0, width, height)
}
override fun onPassportRead(mrzInfo: MRZInfo) {
val event = Arguments.createMap()
event.putString("data", mrzInfo.toString())
reactContext
.getJSModule(RCTEventEmitter::class.java)
.receiveEvent(this.reactNativeViewId!!, SUCCESS_EVENT, event)
}
override fun onError(e: Exception) {
val event = Arguments.createMap()
event.putString("errorMessage", "Something went wrong scanning MRZ with camera")
event.putString("error", e.toString())
event.putString("stackTrace", e.stackTraceToString())
reactContext
.getJSModule(RCTEventEmitter::class.java)
.receiveEvent(this.reactNativeViewId!!, FAILURE_EVENT, event)
}
override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any> {
return mapOf(
SUCCESS_EVENT to mapOf(
"phasedRegistrationNames" to mapOf(
"bubbled" to "onPassportRead"
)
),
FAILURE_EVENT to mapOf(
"phasedRegistrationNames" to mapOf(
"bubbled" to "onError"
)
)
)
}
companion object {
private const val REACT_CLASS = "SelfOCRViewManager"
private const val COMMAND_CREATE = 1
private const val COMMAND_DESTROY = 2
private const val SUCCESS_EVENT = "onPassportReadResult"
private const val FAILURE_EVENT = "onPassportReadError"
}
}

View File

@@ -0,0 +1,51 @@
// Copyright 2018 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.selfxyz.selfSDK.mlkit
/** Describing a frame info. */
class FrameMetadata private constructor(val width: Int, val height: Int, val rotation: Int, val cameraFacing: Int) {
/** Builder of [FrameMetadata]. */
class Builder {
private var width: Int = 0
private var height: Int = 0
private var rotation: Int = 0
private var cameraFacing: Int = 0
fun setWidth(width: Int): Builder {
this.width = width
return this
}
fun setHeight(height: Int): Builder {
this.height = height
return this
}
fun setRotation(rotation: Int): Builder {
this.rotation = rotation
return this
}
fun setCameraFacing(facing: Int): Builder {
cameraFacing = facing
return this
}
fun build(): FrameMetadata {
return FrameMetadata(width, height, rotation, cameraFacing)
}
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright (C) The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.selfxyz.selfSDK.mlkit
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.View
import java.util.HashSet
/**
* A view which renders a series of custom graphics to be overlayed on top of an associated preview
* (i.e., the camera preview). The creator can add graphics objects, update the objects, and remove
* them, triggering the appropriate drawing and invalidation within the view.
*
*
* Supports scaling and mirroring of the graphics relative the camera's preview properties. The
* idea is that detection items are expressed in terms of a preview size, but need to be scaled up
* to the full view size, and also mirrored in the case of the front-facing camera.
*
*
* Associated [Graphic] items should use the following methods to convert to view
* coordinates for the graphics that are drawn:
*
*
* 1. [Graphic.scaleX] and [Graphic.scaleY] adjust the size of the
* supplied value from the preview scale to the view scale.
* 1. [Graphic.translateX] and [Graphic.translateY] adjust the
* coordinate from the preview's coordinate system to the view coordinate system.
*
*/
class GraphicOverlay(context: Context, attrs: AttributeSet) : View(context, attrs) {
private val mLock = Any()
private var mPreviewWidth: Int = 0
private var mWidthScaleFactor = 1.0f
private var mPreviewHeight: Int = 0
private var mHeightScaleFactor = 1.0f
private var mIsCameraFacing:Boolean = false
private val mGraphics = HashSet<Graphic>()
/**
* Base class for a custom graphics object to be rendered within the graphic overlay. Subclass
* this and implement the [Graphic.draw] method to define the
* graphics element. Add instances to the overlay using [GraphicOverlay.add].
*/
abstract class Graphic(private val mOverlay: GraphicOverlay) {
/**
* Draw the graphic on the supplied canvas. Drawing should use the following methods to
* convert to view coordinates for the graphics that are drawn:
*
* 1. [Graphic.scaleX] and [Graphic.scaleY] adjust the size of
* the supplied value from the preview scale to the view scale.
* 1. [Graphic.translateX] and [Graphic.translateY] adjust the
* coordinate from the preview's coordinate system to the view coordinate system.
*
*
* @param canvas drawing canvas
*/
abstract fun draw(canvas: Canvas)
/**
* Adjusts a horizontal value of the supplied value from the preview scale to the view
* scale.
*/
fun scaleX(horizontal: Float): Float {
return horizontal * mOverlay.mWidthScaleFactor
}
/**
* Adjusts a vertical value of the supplied value from the preview scale to the view scale.
*/
fun scaleY(vertical: Float): Float {
return vertical * mOverlay.mHeightScaleFactor
}
/**
* Adjusts the x coordinate from the preview's coordinate system to the view coordinate
* system.
*/
fun translateX(x: Float): Float {
return if (mOverlay.mIsCameraFacing == true) {
mOverlay.width - scaleX(x)
} else {
scaleX(x)
}
}
/**
* Adjusts the y coordinate from the preview's coordinate system to the view coordinate
* system.
*/
fun translateY(y: Float): Float {
return scaleY(y)
}
fun postInvalidate() {
mOverlay.postInvalidate()
}
}
/**
* Removes all graphics from the overlay.
*/
fun clear() {
synchronized(mLock) {
mGraphics.clear()
}
postInvalidate()
}
/**
* Adds a graphic to the overlay.
*/
fun add(graphic: Graphic) {
synchronized(mLock) {
mGraphics.add(graphic)
}
postInvalidate()
}
/**
* Removes a graphic from the overlay.
*/
fun remove(graphic: Graphic) {
synchronized(mLock) {
mGraphics.remove(graphic)
}
postInvalidate()
}
/**
* Sets the camera attributes for size and facing direction, which informs how to transform
* image coordinates later.
*/
fun setCameraInfo(previewWidth: Int, previewHeight: Int, isCameraFacing: Boolean) {
synchronized(mLock) {
mPreviewWidth = previewWidth
mPreviewHeight = previewHeight
mIsCameraFacing = isCameraFacing
}
postInvalidate()
}
/**
* Draws the overlay with its associated graphic objects.
*/
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
synchronized(mLock) {
if (mPreviewWidth != 0 && mPreviewHeight != 0) {
mWidthScaleFactor = canvas.width.toFloat() / mPreviewWidth.toFloat()
mHeightScaleFactor = canvas.height.toFloat() / mPreviewHeight.toFloat()
}
for (graphic in mGraphics) {
graphic.draw(canvas)
}
}
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright (C) The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.selfxyz.selfSDK.mlkit
import android.util.Log
import com.google.android.gms.tasks.Task
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.Text
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.TextRecognizer
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import java.io.IOException
/**
* A very simple Processor which receives detected TextBlocks and adds them to the overlay
* as OcrGraphics.
*/
class OcrMrzDetectorProcessor() : VisionProcessorBase<Text>() {
private val detector: TextRecognizer
init {
detector = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
}
override fun stop() {
try {
detector.close()
} catch (e: IOException) {
Log.e(TAG, "Exception thrown while trying to close Text Detector: $e")
}
}
override fun detectInImage(image: InputImage): Task<Text> {
return detector.process(image)
}
companion object {
private val TAG = OcrMrzDetectorProcessor::class.java.simpleName
}
}

View File

@@ -0,0 +1,48 @@
// Copyright 2018 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.selfxyz.selfSDK.mlkit
import android.graphics.Bitmap
import android.media.Image
import com.google.mlkit.vision.common.InputImage
import io.fotoapparat.preview.Frame
import java.nio.ByteBuffer
/** An inferface to process the images with different ML Kit detectors and custom image models. */
interface VisionImageProcessor<T> {
/** Processes the images with the underlying machine learning models. */
fun process(data: ByteBuffer, frameMetadata: FrameMetadata, graphicOverlay: GraphicOverlay?=null, isOriginalImageReturned:Boolean = true, listener: VisionProcessorBase.Listener<T>):Boolean
/** Processes the bitmap images. */
fun process(bitmap: Bitmap, rotation: Int = 0, graphicOverlay: GraphicOverlay?=null, isOriginalImageReturned:Boolean = true, convertToNv21:Boolean = true, listener: VisionProcessorBase.Listener<T>):Boolean
/** Processes the images. */
fun process(image: Image, rotation: Int = 0, graphicOverlay: GraphicOverlay?=null, isOriginalImageReturned:Boolean = true, listener: VisionProcessorBase.Listener<T>):Boolean
/** Processes the bitmap images. */
fun process(frame: Frame, rotation:Int = 0, graphicOverlay: GraphicOverlay?=null, isOriginalImageReturned:Boolean = true, listener: VisionProcessorBase.Listener<T>):Boolean
/** Processes the FirebaseVisionImage */
fun process(image: InputImage, metadata: FrameMetadata?, graphicOverlay: GraphicOverlay?, isOriginalImageReturned:Boolean = true, listener: VisionProcessorBase.Listener<T>):Boolean
/** Stops the underlying machine learning model and release resources. */
fun stop()
fun canHandleNewFrame():Boolean
fun resetThrottle()
}

View File

@@ -0,0 +1,286 @@
// Copyright 2018 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.selfxyz.selfSDK.mlkit
import android.graphics.Bitmap
import android.media.Image
import com.google.android.gms.tasks.Task
import com.google.mlkit.vision.common.InputImage
import com.selfxyz.selfSDK.utils.ImageUtil
import io.fotoapparat.preview.Frame
import org.jmrtd.lds.icao.MRZInfo
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicBoolean
abstract class VisionProcessorBase<T> : VisionImageProcessor<T> {
// Whether we should ignore process(). This is usually caused by feeding input data faster than
// the model can handle.
private val shouldThrottle = AtomicBoolean(false)
override fun canHandleNewFrame():Boolean{
return !shouldThrottle.get()
}
override fun resetThrottle(){
shouldThrottle.set(false)
}
override fun process(
data: ByteBuffer,
frameMetadata: FrameMetadata,
graphicOverlay: GraphicOverlay?,
isOriginalImageReturned:Boolean,
listener: VisionProcessorBase.Listener<T>):Boolean {
if (shouldThrottle.get()) {
return false
}
shouldThrottle.set(true)
try {
/*val metadata = FirebaseVisionImageMetadata.Builder()
.setFormat(FirebaseVisionImageMetadata.IMAGE_FORMAT_NV21)
.setWidth(frameMetadata.width)
.setHeight(frameMetadata.height)
.setRotation(frameMetadata.rotation)
.build()*/
val inputImage = InputImage.fromByteBuffer(data,
frameMetadata.width,
frameMetadata.height,
frameMetadata.rotation,
InputImage.IMAGE_FORMAT_NV21
)
// val firebaseVisionImage = FirebaseVisionImage.fromByteBuffer(data, metadata)
return detectInVisionImage(
inputImage,
frameMetadata,
graphicOverlay,
if (isOriginalImageReturned) inputImage.bitmapInternal else null,
listener)
}catch (e:Exception){
e.printStackTrace()
shouldThrottle.set(false)
return false
}
}
// Bitmap version
override fun process(frame: Frame,
rotation:Int,
graphicOverlay: GraphicOverlay?,
isOriginalImageReturned:Boolean,
listener: VisionProcessorBase.Listener<T>):Boolean {
if (shouldThrottle.get()) {
return false
}
shouldThrottle.set(true)
try{
/* var intFirebaseRotation=FirebaseVisionImageMetadata.ROTATION_0
when(rotation){
0 ->{
intFirebaseRotation = FirebaseVisionImageMetadata.ROTATION_0
}
90 ->{
intFirebaseRotation = FirebaseVisionImageMetadata.ROTATION_90
}
180 ->{
intFirebaseRotation = FirebaseVisionImageMetadata.ROTATION_180
}
270 ->{
intFirebaseRotation = FirebaseVisionImageMetadata.ROTATION_270
}
}*/
val frameMetadata = FrameMetadata.Builder()
.setWidth(frame.size.width)
.setHeight(frame.size.height)
.setRotation(rotation).build()
val inputImage = InputImage.fromByteArray(frame.image,
frameMetadata.width,
frameMetadata.height,
rotation,
InputImage.IMAGE_FORMAT_NV21
)
var originalBitmap:Bitmap?=null
if(isOriginalImageReturned){
try {
originalBitmap = inputImage.bitmapInternal
if (originalBitmap == null) {
val wrap = ByteBuffer.wrap(frame.image)
originalBitmap = ImageUtil.rotateBitmap(ImageUtil.getBitmap(wrap, frameMetadata)!!, frameMetadata.rotation.toFloat())
}
}catch (e:Exception){
e.printStackTrace()
}
}
// val firebaseVisionImage = FirebaseVisionImage.fromByteArray(frame.image, metadata)
return detectInVisionImage(inputImage, frameMetadata, graphicOverlay, if(isOriginalImageReturned) originalBitmap else null, listener)
}catch (e:Exception){
e.printStackTrace()
shouldThrottle.set(false)
return false
}
}
// Bitmap version
override fun process(bitmap: Bitmap, rotation: Int, graphicOverlay: GraphicOverlay?, isOriginalImageReturned:Boolean, convertToNv21:Boolean, listener: VisionProcessorBase.Listener<T>):Boolean {
if (shouldThrottle.get()) {
return false
}
try{
val bitmapToProcess:Bitmap?
when(rotation){
0 -> {
bitmapToProcess = bitmap
}
else -> {
bitmapToProcess = ImageUtil.rotateBitmap(bitmap, rotation.toFloat())
}
}
val frameMetadata = FrameMetadata.Builder()
.setWidth(bitmapToProcess.width)
.setHeight(bitmapToProcess.height)
.setRotation(rotation).build()
/*var byteArray:ByteArray
if(convertToNv21){
byteArray = ImageUtil.toNv21(bitmapToProcess)
} else {
val size = bitmapToProcess.rowBytes * bitmapToProcess.height
val byteBuffer = ByteBuffer.allocate(size)
bitmapToProcess.copyPixelsToBuffer(byteBuffer)
byteArray = byteBuffer.array()
}
bitmapToProcess.recycle()
val byteBuffer = ByteBuffer.wrap(byteArray)*/
val inputImage = InputImage.fromBitmap(bitmapToProcess, rotation)
// val fromBitmap = FirebaseVisionImage.fromBitmap(bitmapToProcess)
return process(inputImage, frameMetadata, graphicOverlay, isOriginalImageReturned, listener)
}catch (e:Exception){
e.printStackTrace()
shouldThrottle.set(false)
return false
}
}
/**
* Detects feature from given media.Image
*
* @return created FirebaseVisionImage
*/
override fun process(image: Image, rotation: Int, graphicOverlay: GraphicOverlay?, isOriginalImageReturned:Boolean, listener: Listener<T>):Boolean {
if (shouldThrottle.get()) {
return false
}
shouldThrottle.set(true)
try {
// This is for overlay display's usage
val frameMetadata = FrameMetadata.Builder().setWidth(image.width).setHeight(image.height).build()
val inputImage = InputImage.fromMediaImage(image, rotation)
//val fbVisionImage = FirebaseVisionImage.fromMediaImage(image, rotation)
return detectInVisionImage(inputImage, frameMetadata, graphicOverlay, if (isOriginalImageReturned) inputImage.bitmapInternal else null, listener)
}catch (e:Exception){
e.printStackTrace()
shouldThrottle.set(false)
return false
}
}
private fun detectInVisionImage(
image: InputImage,
metadata: FrameMetadata?,
graphicOverlay: GraphicOverlay?,
originalBitmap: Bitmap?=null,
listener: Listener<T>
):Boolean {
val start = System.currentTimeMillis()
// val bitmapForDebugging = image.bitmap
detectInImage(image)
.addOnSuccessListener { results ->
val timeRequired = System.currentTimeMillis() - start
listener.onSuccess(results, metadata, timeRequired, originalBitmap, graphicOverlay)
}
.addOnFailureListener { e ->
val timeRequired = System.currentTimeMillis() - start
listener.onFailure(e, timeRequired)
shouldThrottle.set(false)
}
.addOnCanceledListener {
val timeRequired = System.currentTimeMillis() - start
listener.onCanceled(timeRequired)
shouldThrottle.set(false)
}
.addOnCompleteListener {
val timeRequired = System.currentTimeMillis() - start
listener.onCompleted(timeRequired)
shouldThrottle.set(false)
}
// Begin throttling until this frame of input has been processed, either in onSuccess or
// onFailure.
return true
}
override fun process(image: InputImage, metadata: FrameMetadata?, graphicOverlay: GraphicOverlay?, isOriginalImageReturned:Boolean, listener: Listener<T>):Boolean{
if (shouldThrottle.get()) {
return false
}
shouldThrottle.set(true)
try {
return detectInVisionImage(image, metadata, graphicOverlay, if (isOriginalImageReturned) image.bitmapInternal else null, listener)
}catch (e:Exception){
e.printStackTrace()
shouldThrottle.set(false)
return false
}
}
override fun stop() {}
protected abstract fun detectInImage(image: InputImage): Task<T>
interface Listener<T> {
fun onSuccess(results: T, frameMetadata: FrameMetadata?, timeRequired: Long, bitmap: Bitmap?, graphicOverlay: GraphicOverlay?=null)
fun onCanceled(timeRequired:Long)
fun onFailure(e: Exception, timeRequired:Long)
fun onCompleted(timeRequired:Long)
}
companion object {
private val TAG = VisionProcessorBase::class.java.simpleName
}
}

View File

@@ -0,0 +1,417 @@
package com.selfxyz.selfSDK.ui
import android.Manifest
import android.app.AlertDialog
import android.app.Dialog
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics
import android.os.Bundle
import android.util.Log
import android.view.MotionEvent
import android.view.Surface
import android.view.View
import android.view.WindowManager
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.fotoapparat.Fotoapparat
import io.fotoapparat.characteristic.LensPosition
import io.fotoapparat.configuration.CameraConfiguration
import io.fotoapparat.parameter.Zoom
import io.fotoapparat.preview.FrameProcessor
import io.fotoapparat.selector.*
import io.fotoapparat.view.CameraView
abstract class CameraFragment : androidx.fragment.app.Fragment(), ActivityCompat.OnRequestPermissionsResultCallback {
/**
* Camera Manager
*/
protected var fotoapparat: Fotoapparat? = null
protected var hasCameraPermission: Boolean = false
protected var rotation: Int = 0
private var cameraZoom: Zoom.VariableZoom? = null
private var zoomProgress: Int = 0
private var mDist: Float = 0.toFloat()
var configuration = CameraConfiguration(
// A full configuration
// ...
focusMode = firstAvailable(
autoFocus()
),
flashMode = off()
)
////////////////////////////////////////
abstract val callbackFrameProcessor: FrameProcessor
abstract val cameraPreview: CameraView
abstract val requestedPermissions: ArrayList<String>
var initialLensPosition: LensPosition = LensPosition.Back
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (savedInstanceState != null) {
if (savedInstanceState.containsKey(KEY_CURRENT_ZOOM_PROGRESS)) {
zoomProgress = savedInstanceState.getInt(KEY_CURRENT_ZOOM_PROGRESS, 0)
}
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putInt(KEY_CURRENT_ZOOM_PROGRESS, zoomProgress)
super.onSaveInstanceState(outState)
}
fun buildCamera(cameraView: CameraView, lensPosition: LensPosition = LensPosition.Back) {
if (fotoapparat == null) {
fotoapparat = Fotoapparat
.with(context?.applicationContext!!)
.into(cameraView)
.frameProcessor(
callbackFrameProcessor
)
.lensPosition { lensPosition }
.build()
fotoapparat?.updateConfiguration(configuration)
}
cameraView.setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
return onTouchEvent(event!!)
}
})
}
fun configureZoom() {
fotoapparat?.getCapabilities()
?.whenAvailable { capabilities ->
setZoomProperties(capabilities?.zoom as Zoom.VariableZoom)
}
}
private fun setZoomProperties(zoom: Zoom.VariableZoom) {
cameraZoom = zoom
setZoomProgress(zoomProgress, cameraZoom!!)
}
private fun setZoomProgress(progress: Int, zoom: Zoom.VariableZoom) {
zoomProgress = progress
fotoapparat?.setZoom(progress.toFloat() / zoom.maxZoom)
}
/** Determine the space between the first two fingers */
private fun getFingerSpacing(event: MotionEvent): Float {
// ...
val x = event.getX(0) - event.getX(1)
val y = event.getY(0) - event.getY(1)
return Math.sqrt((x * x + y * y).toDouble()).toFloat()
}
protected fun setFlash(isEnable: Boolean) {
configuration = configuration.copy(flashMode = if (isEnable) torch() else off())
fotoapparat?.updateConfiguration(configuration)
}
protected fun setFocusMode(focusModeSelector: FocusModeSelector) {
configuration = configuration.copy(focusMode = focusModeSelector)
fotoapparat?.updateConfiguration(configuration)
}
override fun onResume() {
super.onResume()
rotation = getRotation(context!!, initialLensPosition)
buildCamera(cameraPreview!!, initialLensPosition)
hasCameraPermission = hasCameraPermission()
if (hasCameraPermission) {
checkPermissions(requestedPermissions)
} else {
fotoapparat?.start()
configureZoom()
}
}
override fun onPause() {
hasCameraPermission = hasCameraPermission()
if (!hasCameraPermission) {
fotoapparat?.stop()
}
fotoapparat = null;
super.onPause()
}
override fun onDestroyView() {
super.onDestroyView()
}
override fun onAttach(context: Context) {
super.onAttach(context)
}
override fun onDetach() {
super.onDetach()
}
////////////////////////////////////////////////////////////////////////////////////////
//
// Pinch on Zoom Functionality
//
////////////////////////////////////////////////////////////////////////////////////////
fun onTouchEvent(event: MotionEvent): Boolean {
// Get the pointer ID
val action = event.action
if (event.pointerCount > 1) {
// handle multi-touch events
if (action == MotionEvent.ACTION_POINTER_DOWN) {
mDist = getFingerSpacing(event)
} else if (action == MotionEvent.ACTION_MOVE && cameraZoom != null) {
handleZoom(event)
}
} else {
// handle single touch events
if (action == MotionEvent.ACTION_UP) {
// setFocusMode (previousFocusMode!!)
}
}
return true
}
private fun handleZoom(event: MotionEvent) {
if (cameraZoom == null) {
return
}
val maxZoom = cameraZoom?.maxZoom!!
var zoom = zoomProgress
val newDist = getFingerSpacing(event)
if (newDist > mDist) {
//zoom in
if (zoom < maxZoom)
zoom++
} else if (newDist < mDist) {
//zoom out
if (zoom > 0)
zoom--
}
if (zoom > maxZoom) {
zoom = maxZoom
}
if (zoom < 0) {
zoom = 0
}
mDist = newDist
setZoomProgress(zoom, cameraZoom!!)
//zoomProgress = cameraZoom?.zoomRatios!![zoom]
}
////////////////////////////////////////////////////////////////////////////////////////
//
// Permissions
//
////////////////////////////////////////////////////////////////////////////////////////
protected fun hasCameraPermission(): Boolean {
return ContextCompat.checkSelfPermission(context!!, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED
}
protected fun checkPermissions(permissions: ArrayList<String> = ArrayList()) {
//request permission
val hasPermissionCamera = ContextCompat.checkSelfPermission(context!!,
Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
if (!hasPermissionCamera && !permissions.contains(Manifest.permission.CAMERA)) {
permissions.add(Manifest.permission.CAMERA)
}
if (permissions.isNotEmpty()) {
requestPermissions(permissions.toArray(arrayOf<String>()),
REQUEST_PERMISSIONS)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>,
grantResults: IntArray) {
when (requestCode) {
REQUEST_PERMISSIONS -> {
val permissionsDenied = ArrayList<String>()
val permissionsGranted = ArrayList<String>()
permissions.forEachIndexed { index, element ->
if (grantResults[index] != PackageManager.PERMISSION_GRANTED) {
permissionsDenied.add(element)
} else {
permissionsGranted.add(element)
}
}
for (permission in permissionsDenied) {
when (permission) {
Manifest.permission.CAMERA -> {
showErrorCameraPermissionDenied()
}
}
}
for (permission in permissionsGranted) {
when (permission) {
Manifest.permission.CAMERA -> {
hasCameraPermission = true
fotoapparat?.start()
}
}
}
onRequestPermissionsResult(permissionsDenied, permissionsGranted)
}
else -> {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}
}
abstract fun onRequestPermissionsResult(permissionsDenied: ArrayList<String>, permissionsGranted: ArrayList<String>)
protected fun showErrorCameraPermissionDenied() {
ErrorDialog.newInstance("Camera permission is required to use this feature")
.show(childFragmentManager, FRAGMENT_DIALOG)
}
////////////////////////////////////////////////////////////////////////////////////////
//
// Dialogs UI
//
////////////////////////////////////////////////////////////////////////////////////////
/**
* Shows a [Toast] on the UI thread.
*
* @param text The message to show
*/
private fun showToast(text: String) {
val activity = activity
activity?.runOnUiThread { Toast.makeText(activity, text, Toast.LENGTH_SHORT).show() }
}
/**
* Shows an error message dialog.
*/
class ErrorDialog : androidx.fragment.app.DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val activity = activity
return AlertDialog.Builder(activity)
.setMessage(arguments!!.getString(ARG_MESSAGE))
.setPositiveButton("OK") { dialogInterface, i -> activity!!.finish() }
.create()
}
companion object {
private val ARG_MESSAGE = "message"
fun newInstance(message: String): ErrorDialog {
val dialog = ErrorDialog()
val args = Bundle()
args.putString(ARG_MESSAGE, message)
dialog.arguments = args
return dialog
}
}
}
fun getRotation(context: Context, lensPosition: LensPosition = LensPosition.Back): Int {
var facingCamera = 0
when (lensPosition) {
LensPosition.Front -> {
facingCamera = CameraCharacteristics.LENS_FACING_FRONT
}
LensPosition.Back -> {
facingCamera = CameraCharacteristics.LENS_FACING_BACK
}
LensPosition.External -> {
facingCamera = CameraCharacteristics.LENS_FACING_EXTERNAL
}
}
val manager = context.getSystemService(Context.CAMERA_SERVICE) as android.hardware.camera2.CameraManager
try {
for (cameraId in manager.getCameraIdList()) {
val characteristics = manager.getCameraCharacteristics(cameraId)
val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
if (facing != null && facing != facingCamera) {
continue
}
val mSensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)!!
val rotation = (context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.rotation
var degrees = 0
when (rotation) {
Surface.ROTATION_0 -> degrees = 0
Surface.ROTATION_90 -> degrees = 90
Surface.ROTATION_180 -> degrees = 180
Surface.ROTATION_270 -> degrees = 270
}
var result: Int
if (facing == CameraCharacteristics.LENS_FACING_FRONT) {
result = (mSensorOrientation + degrees - 360) % 360
result = (360 + result) % 360 // compensate the mirror
} else { // back-facing
result = (mSensorOrientation - degrees + 360) % 360
}
return result
}
} catch (e: Exception) {
}
return 0
}
companion object {
/**
* Tag for the [Log].
*/
private val TAG = CameraFragment::class.java.simpleName
private val KEY_CURRENT_ZOOM_PROGRESS = "KEY_CURRENT_ZOOM_PROGRESS"
private val REQUEST_PERMISSIONS = 410
private val FRAGMENT_DIALOG = TAG
}
}

View File

@@ -0,0 +1,420 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
/*
* Copyright 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.selfxyz.selfSDK.ui
import android.Manifest
import android.app.AlertDialog
import android.app.Dialog
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import com.google.mlkit.vision.text.Text
import org.jmrtd.lds.icao.MRZInfo
import com.selfxyz.selfSDK.R
import com.selfxyz.selfSDK.databinding.FragmentCameraMrzBinding
import com.selfxyz.selfSDK.mlkit.FrameMetadata
import com.selfxyz.selfSDK.mlkit.GraphicOverlay
import com.selfxyz.selfSDK.mlkit.OcrMrzDetectorProcessor
import com.selfxyz.selfSDK.mlkit.VisionProcessorBase
import com.selfxyz.selfSDK.ui.CameraFragment
import com.selfxyz.selfSDK.utils.MRZUtil
import com.selfxyz.selfSDK.utils.OcrUtils
import io.fotoapparat.preview.Frame
import io.fotoapparat.view.CameraView
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
class CameraMLKitFragment(cameraMLKitCallback: CameraMLKitCallback) : CameraFragment() {
// private lateinit var customView: CustomView
////////////////////////////////////////
private var cameraMLKitCallback: CameraMLKitCallback? = cameraMLKitCallback
private var frameProcessor: OcrMrzDetectorProcessor? = null
private val mHandler = Handler(Looper.getMainLooper())
var disposable = CompositeDisposable()
private var isDecoding = false
private var binding:FragmentCameraMrzBinding?=null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
binding = FragmentCameraMrzBinding.inflate(inflater, container, false)
return binding?.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
}
override fun onResume() {
MRZUtil.cleanStorage()
frameProcessor = textProcessor
super.onResume()
}
override fun onPause() {
frameProcessor?.stop()
frameProcessor = null
super.onPause()
}
override fun onDestroyView() {
if (!disposable.isDisposed()) {
disposable.dispose();
}
super.onDestroyView()
}
override fun onAttach(context: Context) {
super.onAttach(context)
// val activity = activity
// if (activity is CameraMLKitCallback) {
// cameraMLKitCallback = activity
// }
}
override fun onDetach() {
cameraMLKitCallback = null
super.onDetach()
}
////////////////////////////////////////////////////////////////////////////////////////
//
// Events from camera fragment
//
////////////////////////////////////////////////////////////////////////////////////////
override val callbackFrameProcessor: io.fotoapparat.preview.FrameProcessor
get() {
val callbackFrameProcessor2 = object : io.fotoapparat.preview.FrameProcessor {
override fun process(frame: Frame) {
try {
if (!isDecoding) {
isDecoding = true
if (frameProcessor != null) {
val subscribe = Single.fromCallable({
frameProcessor?.process(
frame = frame,
rotation = rotation,
graphicOverlay = null,
true,
listener = ocrListener
)
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ success ->
//Don't do anything
},{error->
isDecoding = false
Toast.makeText(requireContext(), "Error: "+error, Toast.LENGTH_SHORT).show()
})
disposable.add(subscribe)
}
}
}catch (e:Exception){
e.printStackTrace()
}
}
}
return callbackFrameProcessor2
}
////////////////////////////////////////////////////////////////////////////////////////
//
// Get camera preview
//
////////////////////////////////////////////////////////////////////////////////////////
override val cameraPreview: CameraView
get(){
return binding?.cameraPreview!!
}
////////////////////////////////////////////////////////////////////////////////////////
//
// Permission requested
//
////////////////////////////////////////////////////////////////////////////////////////
override val requestedPermissions: ArrayList<String>
get() {
//Nothing as we don't need any other permission than camera and that's managed in the parent fragment
return ArrayList<String>()
}
override fun onRequestPermissionsResult(permissionsDenied: ArrayList<String>, permissionsGranted: ArrayList<String>) {
}
////////////////////////////////////////////////////////////////////////////////////////
//
// Instantiate the text processor to perform OCR
//
////////////////////////////////////////////////////////////////////////////////////////
//OCR listener
val ocrListener = object : VisionProcessorBase.Listener<com.google.mlkit.vision.text.Text> {
override fun onSuccess(
results: Text,
frameMetadata: FrameMetadata?,
timeRequired: Long,
bitmap: Bitmap?,
graphicOverlay: GraphicOverlay?
) {
if (!isAdded) {
return
}
OcrUtils.processOcr(
results = results,
timeRequired = timeRequired,
callback = mrzListener
)
}
override fun onCanceled(timeRequired: Long) {
if (!isAdded) {
return
}
}
override fun onFailure(
e: Exception,
timeRequired: Long
) {
if (!isAdded) {
return
}
mrzListener.onFailure(e, timeRequired)
}
override fun onCompleted(timeRequired: Long) {
if (!isAdded) {
return
}
}
}
//MRZ Listener
var mrzListener = object : OcrUtils.MRZCallback {
override fun onMRZRead(mrzInfo: MRZInfo, timeRequired: Long) {
isDecoding = false
if(!isAdded){
return
}
mHandler.post {
try {
// binding?.statusViewTop?.text = getString(R.string.status_bar_ocr, mrzInfo.documentNumber, mrzInfo.dateOfBirth, mrzInfo.dateOfExpiry)
// binding?.statusViewBottom?.text = getString(R.string.status_bar_success, timeRequired)
binding?.statusViewBottom?.setTextColor(resources.getColor(R.color.status_text))
cameraMLKitCallback.onPassportRead(mrzInfo)
} catch (e: IllegalStateException) {
//The fragment is destroyed
}
}
}
override fun onMRZReadFailure(timeRequired: Long) {
isDecoding = false
if(!isAdded){
return
}
mHandler.post {
try {
// binding?.statusViewBottom?.text = getString(R.string.status_bar_failure, timeRequired)
// binding?.statusViewBottom?.setTextColor(Color.RED)
binding?.statusViewTop?.text = ""
} catch (e: IllegalStateException) {
//The fragment is destroyed
}
}
}
override fun onFailure(e: Exception, timeRequired: Long) {
isDecoding = false
if(!isAdded){
return
}
e.printStackTrace()
mHandler.post {
cameraMLKitCallback.onError(e)
}
}
}
protected val textProcessor: OcrMrzDetectorProcessor
get() = OcrMrzDetectorProcessor()
////////////////////////////////////////////////////////////////////////////////////////
//
// Permissions
//
////////////////////////////////////////////////////////////////////////////////////////
private fun requestCameraPermission() {
if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
ConfirmationDialog().show(childFragmentManager, FRAGMENT_DIALOG)
} else {
requestPermissions(arrayOf(Manifest.permission.CAMERA), REQUEST_CAMERA_PERMISSION)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>,
grantResults: IntArray) {
if (requestCode == REQUEST_CAMERA_PERMISSION) {
if (grantResults.size != 1 || grantResults[0] != PackageManager.PERMISSION_GRANTED) {
ErrorDialog.newInstance(getString(R.string.permission_camera_rationale))
.show(childFragmentManager, FRAGMENT_DIALOG)
}
} else {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}
////////////////////////////////////////////////////////////////////////////////////////
//
// Dialogs UI
//
////////////////////////////////////////////////////////////////////////////////////////
/**
* Shows a [Toast] on the UI thread.
*
* @param text The message to show
*/
private fun showToast(text: String) {
val activity = activity
activity?.runOnUiThread { Toast.makeText(activity, text, Toast.LENGTH_SHORT).show() }
}
/**
* Shows an error message dialog.
*/
class ErrorDialog : androidx.fragment.app.DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val activity = activity
return AlertDialog.Builder(activity)
.setMessage(requireArguments().getString(ARG_MESSAGE))
.setPositiveButton(android.R.string.ok) { dialogInterface, i -> activity!!.finish() }
.create()
}
companion object {
private val ARG_MESSAGE = "message"
fun newInstance(message: String): ErrorDialog {
val dialog = ErrorDialog()
val args = Bundle()
args.putString(ARG_MESSAGE, message)
dialog.arguments = args
return dialog
}
}
}
/**
* Shows OK/Cancel confirmation dialog about camera permission.
*/
class ConfirmationDialog : androidx.fragment.app.DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val parent = parentFragment
return AlertDialog.Builder(activity)
.setMessage(R.string.permission_camera_rationale)
.setPositiveButton(android.R.string.ok) { dialog, which ->
parent!!.requestPermissions(arrayOf(Manifest.permission.CAMERA),
REQUEST_CAMERA_PERMISSION
)
}
.setNegativeButton(android.R.string.cancel
) { dialog, which ->
val activity = parent!!.activity
activity?.finish()
}
.create()
}
}
////////////////////////////////////////////////////////////////////////////////////////
//
// Listener
//
////////////////////////////////////////////////////////////////////////////////////////
interface CameraMLKitCallback {
fun onPassportRead(mrzInfo: MRZInfo)
fun onError(e: Exception)
}
companion object {
/**
* Tag for the [Log].
*/
private val TAG = CameraMLKitFragment::class.java.simpleName
private val REQUEST_CAMERA_PERMISSION = 1
private val FRAGMENT_DIALOG = "CameraMLKitFragment"
}
}

View File

@@ -0,0 +1,161 @@
// SPDX-FileCopyrightText: 2025 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.selfSDK.utils
import android.content.Context
import android.graphics.*
import android.media.Image
import android.util.Log
import androidx.annotation.Nullable
import com.selfxyz.selfSDK.mlkit.FrameMetadata
import org.jnbis.internal.WsqDecoder
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.nio.ByteBuffer
import jj2000.j2k.decoder.Decoder
import jj2000.j2k.util.ParameterList
import org.jmrtd.lds.ImageInfo.WSQ_MIME_TYPE
import kotlin.experimental.and
object ImageUtil {
private val TAG = ImageUtil::class.java.simpleName
var JPEG_MIME_TYPE = "image/jpeg"
var JPEG2000_MIME_TYPE = "image/jp2"
var JPEG2000_ALT_MIME_TYPE = "image/jpeg2000"
var WSQ_MIME_TYPE = "image/x-wsq"
fun imageToByteArray(image: Image): ByteArray? {
var data: ByteArray? = null
if (image.format == ImageFormat.JPEG) {
val planes = image.planes
val buffer = planes[0].buffer
data = ByteArray(buffer.capacity())
buffer.get(data)
return data
} else if (image.format == ImageFormat.YUV_420_888) {
data = NV21toJPEG(
YUV_420_888toNV21(image),
image.width, image.height)
}
return data
}
fun YUV_420_888toNV21(image: Image): ByteArray {
val nv21: ByteArray
val yBuffer = image.planes[0].buffer
val uBuffer = image.planes[1].buffer
val vBuffer = image.planes[2].buffer
val ySize = yBuffer.remaining()
val uSize = uBuffer.remaining()
val vSize = vBuffer.remaining()
nv21 = ByteArray(ySize + uSize + vSize)
//U and V are swapped
yBuffer.get(nv21, 0, ySize)
vBuffer.get(nv21, ySize, vSize)
uBuffer.get(nv21, ySize + vSize, uSize)
return nv21
}
private fun NV21toJPEG(nv21: ByteArray, width: Int, height: Int): ByteArray {
val out = ByteArrayOutputStream()
val yuv = YuvImage(nv21, ImageFormat.NV21, width, height, null)
yuv.compressToJpeg(Rect(0, 0, width, height), 100, out)
return out.toByteArray()
}
/* IMAGE DECODIFICATION METHODS */
@Throws(IOException::class)
fun decodeImage(inputStream: InputStream, imageLength: Int, mimeType: String): Bitmap {
var inputStream = inputStream
/* DEBUG */
synchronized(inputStream) {
val dataIn = DataInputStream(inputStream)
val bytes = ByteArray(imageLength)
dataIn.readFully(bytes)
inputStream = ByteArrayInputStream(bytes)
}
/* END DEBUG */
if (JPEG2000_MIME_TYPE.equals(mimeType, ignoreCase = true) || JPEG2000_ALT_MIME_TYPE.equals(mimeType, ignoreCase = true)) {
val bitmap = org.jmrtd.jj2000.JJ2000Decoder.decode(inputStream)
return toAndroidBitmap(bitmap)
} else if (WSQ_MIME_TYPE.equals(mimeType, ignoreCase = true)) {
//org.jnbis.Bitmap bitmap = WSQDecoder.decode(inputStream);
val wsqDecoder = WsqDecoder()
val bitmap = wsqDecoder.decode(inputStream.readBytes())
val byteData = bitmap.pixels
val intData = IntArray(byteData.size)
for (j in byteData.indices) {
intData[j] = -0x1000000 or ((byteData[j].toInt() and 0xFF) shl 16) or ((byteData[j].toInt() and 0xFF) shl 8) or (byteData[j].toInt() and 0xFF)
}
return Bitmap.createBitmap(intData, 0, bitmap.width, bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
//return toAndroidBitmap(bitmap);
} else {
return BitmapFactory.decodeStream(inputStream)
}
}
fun rotateBitmap(source: Bitmap, angle: Float): Bitmap {
val matrix = Matrix()
matrix.postRotate(angle)
return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
}
// Convert NV21 format byte buffer to bitmap.
@Nullable
fun getBitmap(data: ByteBuffer, metadata: FrameMetadata): Bitmap? {
data.rewind()
val imageInBuffer = ByteArray(data.limit())
data.get(imageInBuffer, 0, imageInBuffer.size)
try {
val image = YuvImage(
imageInBuffer, ImageFormat.NV21, metadata.width, metadata.height, null
)
if (image != null) {
val stream = ByteArrayOutputStream()
image.compressToJpeg(Rect(0, 0, metadata.width, metadata.height), 80, stream)
val bmp = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size())
stream.close()
return rotateBitmap(bmp, metadata.rotation.toFloat())
}
} catch (e: Exception) {
Log.e("VisionProcessorBase", "Error: " + e.message)
}
return null
}
/* ONLY PRIVATE METHODS BELOW */
private fun toAndroidBitmap(bitmap: org.jmrtd.jj2000.Bitmap): Bitmap {
val intData = bitmap.pixels
return Bitmap.createBitmap(intData, 0, bitmap.width, bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
}
}

View File

@@ -0,0 +1,130 @@
// SPDX-FileCopyrightText: 2025 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.selfSDK.utils
import org.jmrtd.lds.icao.MRZInfo
import java.util.ArrayList
object MRZUtil {
val TAG = MRZUtil::class.java.simpleName
private val PASSPORT_LINE_1 = "[P]{1}[A-Z<]{1}[A-Z<]{3}[A-Z0-9<]{39}$"
private val PASSPORT_LINE_2 = "[A-Z0-9<]{9}[0-9]{1}[A-Z<]{3}[0-9]{6}[0-9]{1}[FM<]{1}[0-9]{6}[0-9]{1}[A-Z0-9<]{14}[0-9<]{1}[0-9]{1}$"
var mLines1 = ArrayList<String>()
var mLines2 = ArrayList<String>()
val mrzInfo: MRZInfo
@Throws(IllegalArgumentException::class)
get() {
val iteratorLine1 = mLines1.iterator()
while (iteratorLine1.hasNext()) {
val line1 = iteratorLine1.next()
val iteratorLine2 = mLines2.iterator()
while (iteratorLine2.hasNext()) {
val line2 = iteratorLine2.next()
try {
return MRZInfo(line1 + "\n" + line2)
} catch (e: Exception) {
}
}
}
throw IllegalArgumentException("Unable to find a combination of lines that pass MRZ checksum")
}
@Throws(IllegalArgumentException::class)
fun cleanString(mrz: String): String {
val lines = mrz.split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
if (lines.size > 2) {
return cleanLine1(lines[0]) + "\n" + cleanLine2(lines[1])
}
throw IllegalArgumentException("Not enough lines")
}
@Throws(IllegalArgumentException::class)
fun cleanLine1(line: String?): String {
if (line == null || line.length != 44) {
throw IllegalArgumentException("Line 1 doesnt have the right length")
}
val group1 = line.substring(0, 2)
var group2 = line.substring(2, 5)
val group3 = line.substring(5, line.length)
group2 = replaceNumberWithAlfa(group2)
return group1 + group2 + group3
}
@Throws(IllegalArgumentException::class)
fun cleanLine2(line: String?): String {
if (line == null || line.length != 44) {
throw IllegalArgumentException("Line 2 doesnt have the right length")
}
val group1 = line.substring(0, 9)
var group2 = line.substring(9, 10)
var group3 = line.substring(10, 13)
var group4 = line.substring(13, 19)
var group5 = line.substring(19, 20)
val group6 = line.substring(20, 21)
var group7 = line.substring(21, 27)
var group8 = line.substring(27, 28)
val group9 = line.substring(28, 42)
var group10 = line.substring(42, 43)
var group11 = line.substring(43, 44)
group2 = replaceAlfaWithNumber(group2)
group3 = replaceNumberWithAlfa(group3)
group4 = replaceAlfaWithNumber(group4)
group5 = replaceAlfaWithNumber(group5)
group7 = replaceAlfaWithNumber(group7)
group8 = replaceAlfaWithNumber(group8)
group10 = replaceAlfaWithNumber(group10)
group11 = replaceAlfaWithNumber(group11)
return group1 + group2 + group3 + group4 + group5 + group6 + group7 + group8 + group9 + group10 + group11
}
fun replaceNumberWithAlfa(str: String): String {
var str = str
str = str.replace("0".toRegex(), "O")
str = str.replace("1".toRegex(), "I")
str = str.replace("2".toRegex(), "Z")
str = str.replace("5".toRegex(), "S")
return str
}
fun replaceAlfaWithNumber(str: String): String {
var str = str
str = str.replace("O".toRegex(), "0")
str = str.replace("I".toRegex(), "1")
str = str.replace("Z".toRegex(), "2")
str = str.replace("S".toRegex(), "5")
return str
}
fun addLine1(line1: String) {
if (!mLines1.contains(line1)) {
mLines1.add(line1)
}
}
fun addLine2(line2: String) {
if (!mLines2.contains(line2)) {
mLines2.add(line2)
}
}
fun cleanStorage() {
mLines1.clear()
mLines2.clear()
}
}

View File

@@ -0,0 +1,238 @@
package com.selfxyz.selfSDK.utils
import android.util.Log
import com.google.mlkit.vision.text.Text
import net.sf.scuba.data.Gender
import org.jmrtd.lds.icao.MRZInfo
import java.util.regex.Pattern
object OcrUtils {
private val TAG = OcrUtils::class.java.simpleName
// TD3 (Passport) format patterns
private val REGEX_OLD_PASSPORT = "(?<documentNumber>[A-Z0-9<]{9})(?<checkDigitDocumentNumber>[0-9ILDSOG]{1})(?<nationality>[A-Z<]{3})(?<dateOfBirth>[0-9ILDSOG]{6})(?<checkDigitDateOfBirth>[0-9ILDSOG]{1})(?<sex>[FM<]){1}(?<expirationDate>[0-9ILDSOG]{6})(?<checkDigitExpiration>[0-9ILDSOG]{1})"
private val REGEX_OLD_PASSPORT_CLEAN = "(?<documentNumber>[A-Z0-9<]{9})(?<checkDigitDocumentNumber>[0-9]{1})(?<nationality>[A-Z<]{3})(?<dateOfBirth>[0-9]{6})(?<checkDigitDateOfBirth>[0-9]{1})(?<sex>[FM<]){1}(?<expirationDate>[0-9]{6})(?<checkDigitExpiration>[0-9]{1})"
private val REGEX_IP_PASSPORT_LINE_1 = "\\bIP[A-Z<]{3}[A-Z0-9<]{9}[0-9]{1}"
private val REGEX_IP_PASSPORT_LINE_2 = "[0-9]{6}[0-9]{1}[FM<]{1}[0-9]{6}[0-9]{1}[A-Z<]{3}"
// TD1 (ID Card) format patterns
private val REGEX_TD1_LINE1 = "(?<documentCode>[A-Z]{1}[A-Z0-9<]{1})(?<issuingState>[A-Z<]{3})(?<documentNumber>[A-Z0-9<]{9})(?<checkDigitDocumentNumber>[0-9]{1})(?<optionalData1>[A-Z0-9<]{15})"
private val REGEX_TD1_LINE2 = "(?<dateOfBirth>[0-9]{6})(?<checkDigitDateOfBirth>[0-9]{1})(?<sex>[FM<]{1})(?<expirationDate>[0-9]{6})(?<checkDigitExpiration>[0-9]{1})(?<nationality>[A-Z<]{3})(?<optionalData2>[A-Z0-9<]{7})"
private val REGEX_TD1_LINE3 ="(?<names>[A-Z<]{30})"
// TD1 (ID Card)
private val REGEX_ID_DOCUMENT_CODE = "(?<documentCode>[IP]{1}[DM<]{1})"
private val REGEX_ID_DOCUMENT_NUMBER = "(ID)(?<country>[A-Z<]{3})(?<documentNumber>[A-Z0-9<]{9})(?<checkDigitDocumentNumber>[0-9]{1})"
private val REGEX_ID_DATE_OF_BIRTH = "(?<dateOfBirth>[0-9]{6})(?<checkDigitDateOfBirth>[0-9]{1})(?<gender>[FM<]{1})"
private val patternDocumentNumber = Pattern.compile(REGEX_ID_DOCUMENT_NUMBER)
private val patternDateOfBirth = Pattern.compile(REGEX_ID_DATE_OF_BIRTH)
private val patternDocumentCode = Pattern.compile(REGEX_ID_DOCUMENT_CODE)
fun processOcr(
results: Text,
timeRequired: Long,
callback: MRZCallback
){
var fullRead = ""
val blocks = results.textBlocks
for (i in blocks.indices) {
var temp = ""
val lines = blocks[i].lines
for (j in lines.indices) {
//extract scanned text lines here
//temp+=lines.get(j).getText().trim()+"-";
temp += lines[j].text + "-"
}
temp = temp.replace("\r".toRegex(), "").replace("\n".toRegex(), "").replace("\t".toRegex(), "").replace(" ", "")
fullRead += "$temp-"
}
// fullRead = fullRead.toUpperCase()
fullRead = fullRead.uppercase()
// Log.d(TAG, "Read: $fullRead")
// We try with TD1 format first (ID Card)
val patternTD1Line1 = Pattern.compile(REGEX_TD1_LINE1)
val patternTD1Line2 = Pattern.compile(REGEX_TD1_LINE2)
val patternTD1Line3 = Pattern.compile(REGEX_TD1_LINE3)
val matcherTD1Line1 = patternTD1Line1.matcher(fullRead)
val matcherTD1Line2 = patternTD1Line2.matcher(fullRead)
val matcherTD1Line3 = patternTD1Line3.matcher(fullRead)
val matcherDocumentCode = patternDocumentCode.matcher(fullRead)
if (matcherDocumentCode.find() && matcherDocumentCode.group("documentCode") == "ID") {
Log.d(TAG, "ID card found")
val matcherDocumentNumber = patternDocumentNumber.matcher(fullRead)
val matcherDateOfBirth = patternDateOfBirth.matcher(fullRead)
val hasDocumentNumber = matcherDocumentNumber.find()
val hasDateOfBirth = matcherDateOfBirth.find()
val documentNumber = if (hasDocumentNumber) matcherDocumentNumber.group("documentNumber") else null
val checkDigitDocumentNumber = if (hasDocumentNumber) matcherDocumentNumber.group("checkDigitDocumentNumber")?.toIntOrNull() else null
val countryCode = if (hasDocumentNumber) matcherDocumentNumber.group("country") else null
val dateOfBirth = if (hasDateOfBirth) matcherDateOfBirth.group("dateOfBirth") else null
val checkDigitDateOfBirth = if (hasDateOfBirth) matcherDateOfBirth.group("checkDigitDateOfBirth")?.toIntOrNull() else null
val gender = if (hasDateOfBirth) matcherDateOfBirth.group("gender") else null
val expirationDate: String? = if (!countryCode.isNullOrEmpty()) {
val expirationDateRegex = "(?<expirationDate>[0-9]{6})(?<checkDigitExpiration>[0-9]{1})" + Pattern.quote(countryCode)
val patternExpirationDate = Pattern.compile(expirationDateRegex)
val matcherExpirationDate = patternExpirationDate.matcher(fullRead)
if (matcherExpirationDate.find()) matcherExpirationDate.group("expirationDate") else null
} else null
// Only proceed if all required fields are present and non-empty
if (!countryCode.isNullOrEmpty() && !documentNumber.isNullOrEmpty() && !dateOfBirth.isNullOrEmpty() && !expirationDate.isNullOrEmpty() && checkDigitDocumentNumber != null) {
val cleanDocumentNumber = cleanDocumentNumber(documentNumber, checkDigitDocumentNumber)
Log.d(TAG, "cleanDocumentNumber")
if (cleanDocumentNumber != null) {
val mrzInfo = createDummyMrz("ID", countryCode, cleanDocumentNumber, dateOfBirth, expirationDate)
// Log.d(TAG, "MRZ-TD1: $mrzInfo")
callback.onMRZRead(mrzInfo, timeRequired)
return
}
} else {
if (countryCode.isNullOrEmpty()) Log.d(TAG, "Missing or invalid countryCode")
if (documentNumber.isNullOrEmpty()) Log.d(TAG, "Missing or invalid documentNumber")
if (dateOfBirth.isNullOrEmpty()) Log.d(TAG, "Missing or invalid dateOfBirth")
if (expirationDate.isNullOrEmpty()) Log.d(TAG, "Missing or invalid expirationDate")
if (checkDigitDocumentNumber == null) Log.d(TAG, "Missing or invalid checkDigitDocumentNumber")
}
}
if (matcherTD1Line1.find() && matcherTD1Line2.find()) {
Log.d(TAG, "TD1Line1 and TD1Line2 found")
val documentNumber = matcherTD1Line1.group("documentNumber")
val checkDigitDocumentNumber = matcherTD1Line1.group("checkDigitDocumentNumber").toInt()
val dateOfBirth = matcherTD1Line2.group("dateOfBirth")
val expirationDate = matcherTD1Line2.group("expirationDate")
val documentType = matcherTD1Line1.group("documentCode")
val issuingState = matcherTD1Line1.group("issuingState")
val cleanDocumentNumber = cleanDocumentNumber(documentNumber, checkDigitDocumentNumber)
if (cleanDocumentNumber != null) {
val mrzInfo = createDummyMrz(documentType, issuingState, cleanDocumentNumber, dateOfBirth, expirationDate)
// Log.d(TAG, "cleanDocumentNumber")
callback.onMRZRead(mrzInfo, timeRequired)
return
}
}
// If not TD1 we try with TD3 (Passport) format
val patternLineOldPassportType = Pattern.compile(REGEX_OLD_PASSPORT)
val matcherLineOldPassportType = patternLineOldPassportType.matcher(fullRead)
if (matcherLineOldPassportType.find()) {
//Old passport format
val line2 = matcherLineOldPassportType.group(0)
var documentNumber = matcherLineOldPassportType.group(1)
val checkDigitDocumentNumber = cleanDate(matcherLineOldPassportType.group(2)).toInt()
val dateOfBirthDay = cleanDate(matcherLineOldPassportType.group(4))
val expirationDate = cleanDate(matcherLineOldPassportType.group(7))
val countryCode = matcherLineOldPassportType.group(3)
val cleanDocumentNumber = cleanDocumentNumber(documentNumber, checkDigitDocumentNumber)
if (cleanDocumentNumber!=null){
val mrzInfo = createDummyMrz("P", countryCode, cleanDocumentNumber, dateOfBirthDay, expirationDate)
// Log.d(TAG, "MRZ: $mrzInfo")
callback.onMRZRead(mrzInfo, timeRequired)
return
}
}
//Try with the new IP passport type
val patternLineIPassportTypeLine1 = Pattern.compile(REGEX_IP_PASSPORT_LINE_1)
val matcherLineIPassportTypeLine1 = patternLineIPassportTypeLine1.matcher(fullRead)
val patternLineIPassportTypeLine2 = Pattern.compile(REGEX_IP_PASSPORT_LINE_2)
val matcherLineIPassportTypeLine2 = patternLineIPassportTypeLine2.matcher(fullRead)
if (matcherLineIPassportTypeLine1.find() && matcherLineIPassportTypeLine2.find()) {
val line1 = matcherLineIPassportTypeLine1.group(0)
val line2 = matcherLineIPassportTypeLine2.group(0)
val documentNumber = line1.substring(5, 14)
val checkDigitDocumentNumber = line1.substring(14, 15).toInt()
val countryCode = line1.substring(2, 5)
val dateOfBirthDay = line2.substring(0, 6)
val expirationDate = line2.substring(8, 14)
val cleanDocumentNumber = cleanDocumentNumber(documentNumber, checkDigitDocumentNumber)
if (cleanDocumentNumber != null) {
val mrzInfo = createDummyMrz("P", countryCode, cleanDocumentNumber, dateOfBirthDay, expirationDate)
callback.onMRZRead(mrzInfo, timeRequired)
return
}
}
//No success with any format
callback.onMRZReadFailure(timeRequired)
}
private fun cleanDocumentNumber(documentNumber: String, checkDigit:Int):String?{
//first we replace all O per 0
var tempDcumentNumber = documentNumber.replace("O".toRegex(), "0")
//Calculate check digit of the document number
var checkDigitCalculated = MRZInfo.checkDigit(tempDcumentNumber).toString().toInt()
if (checkDigit == checkDigitCalculated) {
//If check digits match we return the document number
return tempDcumentNumber
}
//if no match, we try to replace once at a time the first 0 per O as the alpha part comes first, and check if the digits match
var indexOfZero = tempDcumentNumber.indexOf("0")
while (indexOfZero>-1) {
checkDigitCalculated = MRZInfo.checkDigit(tempDcumentNumber).toString().toInt()
if (checkDigit != checkDigitCalculated) {
//Some countries like Spain uses a letter O before the numeric part
indexOfZero = tempDcumentNumber.indexOf("0")
tempDcumentNumber = tempDcumentNumber.replaceFirst("0", "O")
}else{
return tempDcumentNumber
}
}
return null
}
private fun createDummyMrz(
documentType: String,
issuingState: String = "ESP",
documentNumber: String,
dateOfBirthDay: String,
expirationDate: String,
nationality: String = "ESP"
): MRZInfo {
return MRZInfo(
documentType,
issuingState,
"DUMMY",
"DUMMY",
documentNumber,
"ESP",
dateOfBirthDay,
Gender.MALE,
expirationDate,
""
)
}
private fun cleanDate(date:String):String{
var tempDate = date
tempDate = tempDate.replace("I".toRegex(), "1")
tempDate = tempDate.replace("L".toRegex(), "1")
tempDate = tempDate.replace("D".toRegex(), "0")
tempDate = tempDate.replace("O".toRegex(), "0")
tempDate = tempDate.replace("S".toRegex(), "5")
tempDate = tempDate.replace("G".toRegex(), "6")
return tempDate
}
interface MRZCallback {
fun onMRZRead(mrzInfo: MRZInfo, timeRequired: Long)
fun onMRZReadFailure(timeRequired: Long)
fun onFailure(e: Exception, timeRequired: Long)
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?><!--
Copyright 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:mContext="com.selfxyz.selfSDK.ui.CameraActivity" />

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?><!--
Copyright 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment_decoder_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000">
<io.fotoapparat.view.CameraView
android:id="@+id/camera_preview"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<TextView android:id="@+id/status_view_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_above="@+id/status_view_bottom"
android:layout_margin="14dp"
android:background="#0000"
android:text=""
android:textColor="@color/status_text"
android:textSize="14sp"
android:autoLink="web"
android:clickable="true" />
<TextView android:id="@+id/status_view_bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_margin="14dp"
android:background="#0000"
android:text=""
android:textColor="@color/status_text"
android:textSize="14sp"
android:autoLink="web"
android:clickable="true" />
</RelativeLayout>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color>
<color name="white">#ffffff</color>
<color name="black">#D9000000</color>
<color name="grey_mid">#616365</color>
<color name="colorTextOnSecondary">#000000</color>
<color name="colorTextOnPrimary">#ffffff</color>
<color name="colorTextOnTertiary">#413392</color>
<color name="grey_2">#1f000000</color>
<color name="blue_light">#e6e8ee</color>
<color name="toogle_text_color">#2A3764</color>
<color name="viewfinder_corners">#ffffffff</color> <!-- four corner elements for viewfinder -->
<color name="viewfinder_frame">#ffd6d6d6</color> <!-- viewfinder rectangle -->
<color name="viewfinder_mask">#60000000</color> <!-- viewfinder exterior darkened area -->
<color name="status_text">#ffffffff</color> <!-- status_view_top/status_view_bottom text color -->
</resources>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="card_margin">10dp</dimen>
<dimen name="viewport_icon_height">36dp</dimen>
<dimen name="margin_tiny">4dp</dimen>
<dimen name="margin_small">8dp</dimen>
<dimen name="margin_medium_small">12dp</dimen>
<dimen name="margin_medium">22dp</dimen>
<dimen name="text_size_medium">16sp</dimen>
<dimen name="text_size_small">13sp</dimen>
<dimen name="text_size_default">14sp</dimen>
<dimen name="margin_none">0dp</dimen>
<dimen name="item_photo_width">112dp</dimen>
<dimen name="item_photo_height">112dp</dimen>
<dimen name="toggle_height">48dp</dimen>
<dimen name="toggle_padding">12dp</dimen>
<dimen name="tiny_margin">1dp</dimen>
<dimen name="input_label_padding_bottom">4dp</dimen>
<dimen name="input_label_vertical_spacing">8dp</dimen>
<dimen name="input_label_horizontal_spacing">4dp</dimen>
</resources>

View File

@@ -0,0 +1,119 @@
<resources>
<string name="app_name">Passport Reader</string>
<string name="camera_error">This device doesn\'t support Camera2 API.</string>
<string-array name="ocrenginemodes">
<item>Tesseract</item>
<item>Cube</item>
<item>Both</item>
</string-array>
<string name="permission_camera_rationale">Access to the camera is needed for detection</string>
<string name="no_camera_permission">This application cannot run because it does not have the camera permission. The application will now exit.</string>
<string name="warning_ocr_install">Installing &amp; Starting OCR</string>
<string name="warning_error_ocr_install">Error Installing &amp; Starting OCR</string>
<string name="warning_ocr_install_progress">Installation OCR: %1d%%</string>
<string name="nfc_title">Put your phone over your passport and don\'t move it</string>
<string name="label_document_number">Document Number</string>
<string name="label_document_expiry">Expiration Date</string>
<string name="label_document_issued">Issuing Date</string>
<string name="label_document_issuing_state">Issuing State</string>
<string name="label_document_nationality">Nationality</string>
<string name="label_passport">Passport</string>
<string name="label_not_available">NA</string>
<string name="name">%1s %2s</string>
<string name="doc_number">Doc Number: %1s</string>
<string name="doc_dob">Date of Birth: %1s</string>
<string name="doc_expiry">Expiry Date: %1s</string>
<string name="selection_title">Data Entry</string>
<string name="selection_manual">Manual</string>
<string name="selection_automatic">Automatic</string>
<string name="prompt_document_number">Document Number</string>
<string name="prompt_document_expiration">Document Expiration (yymmdd)</string>
<string name="prompt_document_date_of_birth">Date of Birth (yymmdd)</string>
<string name="read_nfc">READ NFC</string>
<string name="download_csca">Download Spanish CSCA Master List</string>
<string name="delete_csca">Delete CSCA Master List</string>
<string name="error_validation_date">Date format is not valid</string>
<string name="error_validation_document_number">Document number is not valid</string>
<string name="warning_no_nfc">There is no NFC available</string>
<string name="warning_enable_nfc">You need to enable NFC</string>
<string name="label_additional_information">Additional person information</string>
<string name="label_additional_information_custody">Custody</string>
<string name="label_additional_information_dob">Date of birth</string>
<string name="label_additional_information_other_names">Other names</string>
<string name="label_additional_information_other_valid_td_numbers">Other Td numbers</string>
<string name="label_additional_information_permanent_address">Permanent address</string>
<string name="label_additional_information_personal_number">Personal number</string>
<string name="label_additional_information_personal_summary">Personal summary</string>
<string name="label_additional_information_place_of_birth">Place of birth</string>
<string name="label_additional_information_profession">Profession</string>
<string name="label_additional_information_telephone">Telephone</string>
<string name="label_additional_information_title">Title</string>
<string name="label_authentication">Authentication</string>
<string name="label_authentication_bac">BAC</string>
<string name="label_authentication_pace">PACE</string>
<string name="label_authentication_chip">Chip</string>
<string name="label_authentication_passive">Passive</string>
<string name="label_authentication_active">Active</string>
<string name="label_authentication_eac">EAC</string>
<string name="label_authentication_document_signing">Document Signing</string>
<string name="label_authentication_country_signing">CSCA</string>
<string name="label_additional_document_information">Additional document information</string>
<string name="label_additional_document_information_endorsements_and_observations">Observations</string>
<string name="label_additional_document_information_date_and_time_of_personalization">Date personalization</string>
<string name="label_additional_document_information_date_of_issue">Date issue</string>
<string name="label_additional_document_information_image_front">Image front</string>
<string name="label_additional_document_information_image_rear">Image rear</string>
<string name="label_additional_document_information_issuing_authority">Issuing authority</string>
<string name="label_additional_document_information_names_of_other_persons">Names of other persons</string>
<string name="label_additional_document_information_personalization_system_serial_number">System serial number</string>
<string name="label_additional_document_information_tax_or_exit_requirements">Tax or exit requirements</string>
<string name="label_document_signing_certificate">Document Signing Certificate</string>
<string name="label_country_signing_certificate">Country Signing Certificate</string>
<string name="label_certificate_serial_number">Serial number</string>
<string name="label_certificate_public_key_algorithm">Public key algorithm</string>
<string name="label_certificate_signature_algorithm">Signature algorithm</string>
<string name="label_certificate_thumbprint">Certificate thumbprint</string>
<string name="label_certificate_issuer">Issuer</string>
<string name="label_certificate_subject">Subject</string>
<string name="label_certificate_valid_from">Valid from</string>
<string name="label_certificate_valid_to">Valid to</string>
<string name="warning_authentication_failed">Authentication has failed! Please try to scan the document again or introduce the data manually.</string>
<string name="warning_cla_not_supported">Impossible to read the document. Passport doesn\'t support CLA .</string>
<string name="document_valid_passport">Valid passport</string>
<string name="document_invalid_passport">Invalid Passport</string>
<string name="document_unknown_passport_title">Unknown Passport</string>
<string name="document_chip_content_success">The passport chip and content are valid</string>
<string name="document_content_success">The passport content is valid</string>
<string name="document_chip_failure">The passport chip is invalid</string>
<string name="document_document_failure">The passport document information is invalid</string>
<string name="document_csca_failure">The CSCA information is invalid</string>
<string name="document_unknown_passport_message">Unable to authenticate the passport</string>
<string name="keystore_not_empty_title">Keystore</string>
<string name="keystore_not_empty_message">Do you want to replace the current keystore?</string>
</resources>

View File

@@ -0,0 +1,95 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="Theme.AppCompat.Light.NoActionBar.FullScreen" parent="@style/Theme.AppCompat.Light.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item>
</style>
<style name="Text">
</style>
<style name="Text.SubHead">
<item name="android:textSize">@dimen/text_size_medium</item>
<item name="android:fontFamily">@font/regular</item>
<item name="android:textColor">@color/black</item>
</style>
<style name="Text.SubHead.Light" parent="Text.SubHead">
<item name="android:textColor">@color/white</item>
<item name="android:fontFamily">@font/bold</item>
</style>
<style name="Text.Body3">
<item name="android:fontFamily">@font/regular</item>
<item name="android:textSize">@dimen/text_size_small</item>
<item name="android:textColor">@color/grey_mid</item>
<item name="android:lineSpacingExtra">0sp</item>
</style>
<style name="Text.Body3.Light" parent="Text.Body3">
<item name="android:textColor">@color/white</item>
</style>
<style name="Text.Body3.Light.Bold" parent="Text.Body3.Light">
<item name="android:fontFamily">@font/bold</item>
</style>
<style name="CardViewStyle">
<item name="cardBackgroundColor">@color/white</item>
<item name="cardCornerRadius">0dp</item>
<item name="android:layout_marginLeft">@dimen/margin_none</item>
<item name="android:layout_marginRight">@dimen/margin_none</item>
<item name="android:elevation">2dp</item>
</style>
<style name="ToggleButtonText">
<item name="android:textSize">@dimen/text_size_default</item>
<item name="android:fontFamily">@font/medium</item>
</style>
<style name="ToogleButton" parent="@android:style/Widget.CompoundButton.RadioButton">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">match_parent</item>
<item name="android:padding">@dimen/toggle_padding</item>
<item name="android:layout_marginTop">@dimen/tiny_margin</item>
<item name="android:layout_marginBottom">@dimen/tiny_margin</item>
<item name="android:textAppearance">@style/ToggleButtonText</item>
<item name="android:textAllCaps">true</item>
<item name="android:textColor">@color/colorPrimary</item>
<item name="android:button">@null</item>
<item name="android:gravity">center</item>
<item name="android:layout_weight">1</item>
</style>
<style name="ToogleButton.Left">
<item name="android:background">@drawable/toggle_background_left</item>
</style>
<style name="ToogleButton.Right">
<item name="android:background">@drawable/toggle_background_right</item>
</style>
<style name="ToogleGroup">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">@dimen/toggle_height</item>
<item name="android:background">@drawable/toggle_background_border</item>
</style>
<style name="InputLabel" parent="TextAppearance.AppCompat.Small">
<item name="android:paddingBottom">@dimen/input_label_padding_bottom</item>
<item name="android:paddingLeft">@dimen/input_label_horizontal_spacing</item>
<item name="android:paddingRight">@dimen/input_label_horizontal_spacing</item>
</style>
</resources>

View File

@@ -0,0 +1,201 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { randomBytes, sha256, sha512, computeHmac, pbkdf2 } from '../src/utils/ethers';
describe('Crypto Polyfills', () => {
describe('randomBytes', () => {
it('should generate random bytes of specified length', () => {
const bytes = randomBytes(32);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(32);
});
it('should generate different random bytes on each call', () => {
const bytes1 = randomBytes(16);
const bytes2 = randomBytes(16);
expect(bytes1).not.toEqual(bytes2);
});
it('should handle different lengths', () => {
const bytes8 = randomBytes(8);
const bytes64 = randomBytes(64);
expect(bytes8.length).toBe(8);
expect(bytes64.length).toBe(64);
});
});
describe('sha256', () => {
it('should hash data correctly', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash = sha256(data);
expect(hash).toBeInstanceOf(Uint8Array);
expect(hash.length).toBe(32); // SHA-256 produces 32 bytes
});
it('should produce consistent hashes for same input', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash1 = sha256(data);
const hash2 = sha256(data);
expect(hash1).toEqual(hash2);
});
it('should produce different hashes for different inputs', () => {
const data1 = new Uint8Array([1, 2, 3, 4, 5]);
const data2 = new Uint8Array([1, 2, 3, 4, 6]);
const hash1 = sha256(data1);
const hash2 = sha256(data2);
expect(hash1).not.toEqual(hash2);
});
});
describe('sha512', () => {
it('should hash data correctly', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash = sha512(data);
expect(hash).toBeInstanceOf(Uint8Array);
expect(hash.length).toBe(64); // SHA-512 produces 64 bytes
});
it('should produce consistent hashes for same input', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const hash1 = sha512(data);
const hash2 = sha512(data);
expect(hash1).toEqual(hash2);
});
it('should produce different hashes for different inputs', () => {
const data1 = new Uint8Array([1, 2, 3, 4, 5]);
const data2 = new Uint8Array([1, 2, 3, 4, 6]);
const hash1 = sha512(data1);
const hash2 = sha512(data2);
expect(hash1).not.toEqual(hash2);
});
});
describe('computeHmac', () => {
it('should compute HMAC-SHA256 correctly', () => {
const key = new Uint8Array([1, 2, 3, 4]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac = computeHmac('sha256', key, data);
expect(hmac).toBeInstanceOf(Uint8Array);
expect(hmac.length).toBe(32); // HMAC-SHA256 produces 32 bytes
});
it('should compute HMAC-SHA512 correctly', () => {
const key = new Uint8Array([1, 2, 3, 4]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac = computeHmac('sha512', key, data);
expect(hmac).toBeInstanceOf(Uint8Array);
expect(hmac.length).toBe(64); // HMAC-SHA512 produces 64 bytes
});
it('should produce consistent HMAC for same inputs', () => {
const key = new Uint8Array([1, 2, 3, 4]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac1 = computeHmac('sha256', key, data);
const hmac2 = computeHmac('sha256', key, data);
expect(hmac1).toEqual(hmac2);
});
it('should produce different HMAC for different keys', () => {
const key1 = new Uint8Array([1, 2, 3, 4]);
const key2 = new Uint8Array([1, 2, 3, 5]);
const data = new Uint8Array([5, 6, 7, 8]);
const hmac1 = computeHmac('sha256', key1, data);
const hmac2 = computeHmac('sha256', key2, data);
expect(hmac1).not.toEqual(hmac2);
});
});
describe('pbkdf2', () => {
it('should derive key using PBKDF2-SHA256', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key = pbkdf2(password, salt, 1000, 32, 'sha256');
expect(key).toBeInstanceOf(Uint8Array);
expect(key.length).toBe(32);
});
it('should derive key using PBKDF2-SHA512', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key = pbkdf2(password, salt, 1000, 64, 'sha512');
expect(key).toBeInstanceOf(Uint8Array);
expect(key.length).toBe(64);
});
it('should produce consistent keys for same inputs', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key1 = pbkdf2(password, salt, 1000, 32, 'sha256');
const key2 = pbkdf2(password, salt, 1000, 32, 'sha256');
expect(key1).toEqual(key2);
});
it('should produce different keys for different salts', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt1 = new Uint8Array([5, 6, 7, 8]);
const salt2 = new Uint8Array([5, 6, 7, 9]);
const key1 = pbkdf2(password, salt1, 1000, 32, 'sha256');
const key2 = pbkdf2(password, salt2, 1000, 32, 'sha256');
expect(key1).not.toEqual(key2);
});
it('should handle different iteration counts', () => {
const password = new Uint8Array([1, 2, 3, 4]);
const salt = new Uint8Array([5, 6, 7, 8]);
const key1 = pbkdf2(password, salt, 1000, 32, 'sha256');
const key2 = pbkdf2(password, salt, 2000, 32, 'sha256');
expect(key1).not.toEqual(key2);
});
});
describe('ethers integration', () => {
it('should have ethers.randomBytes registered', () => {
// This test verifies that ethers.js is using our polyfill
const { ethers } = require('ethers');
expect(typeof ethers.randomBytes).toBe('function');
const bytes = ethers.randomBytes(16);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(16);
});
it('should have ethers.sha256 registered', () => {
const { ethers } = require('ethers');
expect(typeof ethers.sha256).toBe('function');
const data = new Uint8Array([1, 2, 3, 4]);
const hash = ethers.sha256(data);
expect(typeof hash).toBe('string');
expect(hash).toMatch(/^0x[a-f0-9]{64}$/); // 32 bytes = 64 hex chars
});
it('should have ethers.sha512 registered', () => {
const { ethers } = require('ethers');
expect(typeof ethers.sha512).toBe('function');
const data = new Uint8Array([1, 2, 3, 4]);
const hash = ethers.sha512(data);
expect(typeof hash).toBe('string');
expect(hash).toMatch(/^0x[a-f0-9]{128}$/); // 64 bytes = 128 hex chars
});
});
});

View File

@@ -2,9 +2,23 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/**
* @format
*/
// CRITICAL: Import crypto polyfill FIRST, before any modules that use crypto/uuid
// eslint-disable-next-line simple-import-sort/imports
import 'react-native-get-random-values';
import { Buffer } from 'buffer';
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
import './src/utils/ethers';
// Set global Buffer before any other imports
global.Buffer = Buffer;
AppRegistry.registerComponent(appName, () => App);

View File

@@ -17,6 +17,11 @@ const config = {
// Pin React and React Native to monorepo root
react: path.resolve(__dirname, '../../../node_modules/react'),
'react-native': path.resolve(__dirname, '../../../node_modules/react-native'),
// Crypto polyfills
stream: require.resolve('stream-browserify'),
buffer: require.resolve('buffer'),
util: require.resolve('util'),
assert: require.resolve('assert'),
},
nodeModulesPaths: [path.resolve(__dirname, 'node_modules'), path.resolve(__dirname, '../../../node_modules')],
},

View File

@@ -12,9 +12,16 @@
},
"dependencies": {
"@babel/runtime": "^7.28.3",
"@noble/hashes": "^1.5.0",
"@react-native/gradle-plugin": "0.76.9",
"assert": "^2.1.0",
"buffer": "^6.0.3",
"ethers": "^6.11.0",
"react": "^18.3.1",
"react-native": "0.76.9"
"react-native": "0.76.9",
"react-native-get-random-values": "^1.11.0",
"stream-browserify": "^3.0.0",
"util": "^0.12.5"
},
"devDependencies": {
"@babel/core": "^7.28.3",

View File

@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
// https://docs.ethers.org/v6/cookbook/react-native/
import { ethers } from 'ethers';
import { hmac } from '@noble/hashes/hmac';
import { pbkdf2 as noblePbkdf2 } from '@noble/hashes/pbkdf2';
import { sha256 as nobleSha256 } from '@noble/hashes/sha256';
import { sha512 as nobleSha512 } from '@noble/hashes/sha512';
function randomBytes(length: number): Uint8Array {
if (typeof globalThis.crypto?.getRandomValues !== 'function') {
throw new Error('globalThis.crypto.getRandomValues is not available');
}
return globalThis.crypto.getRandomValues(new Uint8Array(length));
}
function computeHmac(algo: 'sha256' | 'sha512', key: Uint8Array, data: Uint8Array): Uint8Array {
const hash = algo === 'sha256' ? nobleSha256 : nobleSha512;
return hmac(hash, key, data);
}
function pbkdf2(
password: Uint8Array,
salt: Uint8Array,
iterations: number,
keylen: number,
algo: 'sha256' | 'sha512',
): Uint8Array {
const hash = algo === 'sha256' ? nobleSha256 : nobleSha512;
return noblePbkdf2(hash, password, salt, { c: iterations, dkLen: keylen });
}
function sha256(data: Uint8Array): Uint8Array {
return nobleSha256.create().update(data).digest();
}
function sha512(data: Uint8Array): Uint8Array {
return nobleSha512.create().update(data).digest();
}
ethers.randomBytes.register(randomBytes);
ethers.computeHmac.register(computeHmac);
ethers.pbkdf2.register(pbkdf2);
ethers.sha256.register(sha256);
ethers.sha512.register(sha512);
export { computeHmac, pbkdf2, randomBytes, sha256, sha512 };

View File

@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2025 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(PassportReader, NSObject)
RCT_EXTERN_METHOD(scanPassport:(NSString *)passportNumber
dateOfBirth:(NSString *)dateOfBirth
dateOfExpiry:(NSString *)dateOfExpiry
canNumber:(NSString *)canNumber
useCan:(NSNumber * _Nonnull)useCan
skipPACE:(NSNumber * _Nonnull)skipPACE
skipCA:(NSNumber * _Nonnull)skipCA
extendedMode:(NSNumber * _Nonnull)extendedMode
usePacePolling:(NSNumber * _Nonnull)usePacePolling
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
@end

View File

@@ -0,0 +1,460 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
//
// PassportReader.swift
// OpenPassport
//
// Created by Y E on 27/07/2023.
//
import Foundation
import React
#if !E2E_TESTING
import NFCPassportReader
#endif
import Security
#if !E2E_TESTING
@available(iOS 13, macOS 10.15, *)
extension CertificateType {
func stringValue() -> String {
switch self {
case .documentSigningCertificate:
return "documentSigningCertificate"
case .issuerSigningCertificate:
return "issuerSigningCertificate"
}
}
}
#endif
// Helper function to map the keys of a dictionary
extension Dictionary {
func mapKeys<T: Hashable>(_ transform: (Key) -> T) -> Dictionary<T, Value> {
Dictionary<T, Value>(uniqueKeysWithValues: map { (transform($0.key), $0.value) })
}
}
#if !E2E_TESTING
@available(iOS 15, *)
@objc(PassportReader)
class PassportReader: NSObject {
private var passportReader: NFCPassportReader.PassportReader
override init() {
self.passportReader = NFCPassportReader.PassportReader()
super.init()
}
@objc(configure:enableDebugLogs:)
func configure(token: String, enableDebugLogs: Bool) {
self.passportReader = NFCPassportReader.PassportReader()
}
func getMRZKey(passportNumber: String, dateOfBirth: String, dateOfExpiry: String ) -> String {
// Pad fields if necessary
let pptNr = pad( passportNumber, fieldLength:9)
let dob = pad( dateOfBirth, fieldLength:6)
let exp = pad( dateOfExpiry, fieldLength:6)
// Calculate checksums
let passportNrChksum = calcCheckSum(pptNr)
let dateOfBirthChksum = calcCheckSum(dob)
let expiryDateChksum = calcCheckSum(exp)
let mrzKey = "\(pptNr)\(passportNrChksum)\(dob)\(dateOfBirthChksum)\(exp)\(expiryDateChksum)"
return mrzKey
}
func pad( _ value : String, fieldLength:Int ) -> String {
// Pad out field lengths with < if they are too short
let paddedValue = (value + String(repeating: "<", count: fieldLength)).prefix(fieldLength)
return String(paddedValue)
}
func calcCheckSum( _ checkString : String ) -> Int {
let characterDict = ["0" : "0", "1" : "1", "2" : "2", "3" : "3", "4" : "4", "5" : "5", "6" : "6", "7" : "7", "8" : "8", "9" : "9", "<" : "0", " " : "0", "A" : "10", "B" : "11", "C" : "12", "D" : "13", "E" : "14", "F" : "15", "G" : "16", "H" : "17", "I" : "18", "J" : "19", "K" : "20", "L" : "21", "M" : "22", "N" : "23", "O" : "24", "P" : "25", "Q" : "26", "R" : "27", "S" : "28","T" : "29", "U" : "30", "V" : "31", "W" : "32", "X" : "33", "Y" : "34", "Z" : "35"]
var sum = 0
var m = 0
let multipliers : [Int] = [7, 3, 1]
for c in checkString {
guard let lookup = characterDict["\(c)"],
let number = Int(lookup) else { return 0 }
let product = number * multipliers[m]
sum += product
m = (m+1) % 3
}
return (sum % 10)
}
@objc(scanPassport:dateOfBirth:dateOfExpiry:canNumber:useCan:skipPACE:skipCA:extendedMode:usePacePolling:resolve:reject:)
func scanPassport(
_ passportNumber: String,
dateOfBirth: String,
dateOfExpiry: String,
canNumber: String,
useCan: NSNumber,
skipPACE: NSNumber,
skipCA: NSNumber,
extendedMode: NSNumber,
usePacePolling: NSNumber,
resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
let useCANBool = useCan.boolValue
let skipPACEBool = skipPACE.boolValue
let skipCABool = skipCA.boolValue
let extendedModeBool = extendedMode.boolValue
let usePacePollingBool = usePacePolling.boolValue
let customMessageHandler : (NFCViewDisplayMessage)->String? = { (displayMessage) in
switch displayMessage {
case .requestPresentPassport:
return "Hold your iPhone against an NFC enabled passport."
default:
// Return nil for all other messages so we use the provided default
return nil
}
}
Task { [weak self] in
guard let self = self else {
return
}
do {
let password: String
var passwordType:PACEPasswordType
if useCANBool {
if canNumber.count != 6 {
reject("E_PASSPORT_READ", "CAN number must be 6 digits", nil)
return
}
password = canNumber
passwordType = PACEPasswordType.can
} else {
password = getMRZKey( passportNumber: passportNumber, dateOfBirth: dateOfBirth, dateOfExpiry: dateOfExpiry)
passwordType = PACEPasswordType.mrz
}
// let masterListURL = Bundle.main.url(forResource: "masterList", withExtension: ".pem")
// passportReader.setMasterListURL( masterListURL! )
let passport = try await self.passportReader.readPassport(
password: password,
type: passwordType,
tags: [.COM, .DG1, .SOD],
skipCA: skipCABool,
skipPACE: skipPACEBool,
useExtendedMode: extendedModeBool,
usePacePolling: usePacePollingBool,
customDisplayMessage: customMessageHandler
)
var ret = [String:String]()
//print("documentType", passport.documentType)
ret["documentType"] = passport.documentType
ret["documentSubType"] = passport.documentSubType
ret["documentNumber"] = passport.documentNumber
ret["issuingAuthority"] = passport.issuingAuthority
ret["documentExpiryDate"] = passport.documentExpiryDate
ret["dateOfBirth"] = passport.dateOfBirth
ret["gender"] = passport.gender
ret["nationality"] = passport.nationality
ret["lastName"] = passport.lastName
ret["firstName"] = passport.firstName
ret["passportMRZ"] = passport.passportMRZ
ret["placeOfBirth"] = passport.placeOfBirth
ret["residenceAddress"] = passport.residenceAddress
ret["phoneNumber"] = passport.phoneNumber
ret["personalNumber"] = passport.personalNumber
// let passportPhotoData = passport.passportPhoto // [UInt8]
// if let passportPhotoData = passport.passportPhoto {
// let data = Data(passportPhotoData)
// let base64String = data.base64EncodedString()
// ret["passportPhoto"] = base64String
// }
// documentSigningCertificate
// countrySigningCertificate
if let serializedDocumentSigningCertificate = serializeX509Wrapper(passport.documentSigningCertificate) {
ret["documentSigningCertificate"] = serializedDocumentSigningCertificate
}
if let serializedCountrySigningCertificate = serializeX509Wrapper(passport.countrySigningCertificate) {
ret["countrySigningCertificate"] = serializedCountrySigningCertificate
}
//print("passport.documentSigningCertificate", passport.documentSigningCertificate)
//print("passport.countrySigningCertificate", passport.countrySigningCertificate)
ret["LDSVersion"] = passport.LDSVersion
ret["dataGroupsPresent"] = passport.dataGroupsPresent.joined(separator: ", ")
//print("passport.LDSVersion", passport.LDSVersion)
// ret["dataGroupsAvailable"] = passport.dataGroupsAvailable.map(dataGroupIdToString)
//print("passport.dataGroupsAvailable", passport.dataGroupsAvailable)
//print("passport.dataGroupsRead", passport.dataGroupsRead)
//print("passport.dataGroupHashes", passport.dataGroupHashes)
// do {
// let dataGroupsReadData = try JSONSerialization.data(withJSONObject: passport.dataGroupsRead.mapValues { self.convertDataGroupToSerializableFormat($0) }, options: [])
// let dataGroupsReadJsonString = String(data: dataGroupsReadData, encoding: .utf8) ?? ""
// ret["dataGroupsRead"] = dataGroupsReadJsonString
// } catch {
// //print("Error serializing dataGroupsRead: \(error)")
// }
// ret["dataGroupsRead"] = passport.dataGroupsRead.mapValues { convertDataGroupToSerializableFormat($0) }
do {
let dataGroupHashesDict = passport.dataGroupHashes.mapKeys { "\($0)" }
let serializableDataGroupHashes = dataGroupHashesDict.mapValues { convertDataGroupHashToSerializableFormat($0) }
let dataGroupHashesData = try JSONSerialization.data(withJSONObject: serializableDataGroupHashes, options: [])
let dataGroupHashesJsonString = String(data: dataGroupHashesData, encoding: .utf8) ?? ""
ret["dataGroupHashes"] = dataGroupHashesJsonString
} catch {
//print("Error serializing dataGroupHashes: \(error)")
}
// cardAccess
// BACStatus
// PACEStatus
// chipAuthenticationStatus
ret["passportCorrectlySigned"] = String(passport.passportCorrectlySigned)
ret["documentSigningCertificateVerified"] = String(passport.documentSigningCertificateVerified)
ret["passportDataNotTampered"] = String(passport.passportDataNotTampered)
ret["activeAuthenticationPassed"] = String(passport.activeAuthenticationPassed)
ret["activeAuthenticationChallenge"] = encodeByteArrayToHexString(passport.activeAuthenticationChallenge)
ret["activeAuthenticationSignature"] = encodeByteArrayToHexString(passport.activeAuthenticationSignature)
ret["verificationErrors"] = encodeErrors(passport.verificationErrors).joined(separator: ", ")
ret["isPACESupported"] = String(passport.isPACESupported)
ret["isChipAuthenticationSupported"] = String(passport.isChipAuthenticationSupported)
// passportImage
// signatureImage
// activeAuthenticationSupported
//print("passport.certificateSigningGroups", passport.certificateSigningGroups)
// ret["certificateSigningGroups"] = passport.certificateSigningGroups.mapKeys(certificateTypeToString).mapValues(encodeX509WrapperToJsonString)
// if let passportDataElements = passport.passportDataElements {
// ret["passportDataElements"] = passportDataElements
// } else {
// ret["passportDataElements"] = [:]
// }
do {
// although this line won't be reached if there is an error, Its better to handle it here instead of crashing the app
if let sod = try passport.getDataGroup(DataGroupId.SOD) as? SOD {
// ret["concatenatedDataHashes"] = try sod.getEncapsulatedContent().base64EncodedString() // this is what we call concatenatedDataHashes, not the true eContent
ret["eContentBase64"] = try sod.getEncapsulatedContent().base64EncodedString() // this is what we call concatenatedDataHashes, not the true eContent
ret["signatureAlgorithm"] = try sod.getSignatureAlgorithm()
ret["encapsulatedContentDigestAlgorithm"] = try sod.getEncapsulatedContentDigestAlgorithm()
let messageDigestFromSignedAttributes = try sod.getMessageDigestFromSignedAttributes()
let signedAttributes = try sod.getSignedAttributes()
//print("messageDigestFromSignedAttributes", messageDigestFromSignedAttributes)
ret["signedAttributes"] = signedAttributes.base64EncodedString()
// if let pubKey = convertOpaquePointerToSecKey(opaquePointer: sod.pubKey),
// let serializedPublicKey = serializePublicKey(pubKey) {
// ret["publicKeyBase64"] = serializedPublicKey
// } else {
// // Handle the case where pubKey is nil
// }
if let serializedSignature = serializeSignature(from: sod) {
ret["signatureBase64"] = serializedSignature
}
} else {
print("SOD not found or could not be cast to SOD")
reject("E_PASSPORT_READ", "SODNotFound : SOD not found or could not be cast to SOD", nil)
return
}
} catch {
//print("Error serializing SOD data: \(error)")
reject("E_PASSPORT_READ", error.localizedDescription, error)
}
let stringified = String(data: try JSONEncoder().encode(ret), encoding: .utf8)
resolve(stringified)
} catch {
reject("E_PASSPORT_READ", error.localizedDescription, error)
}
}
}
// mrz
// dataHashes
// eContentBytes
// pubkey
// signature
// func convertOpaquePointerToSecKey(opaquePointer: OpaquePointer?) -> SecKey? {
// guard let opaquePointer = opaquePointer else { return nil }
// // Assuming the key is in DER format
// // Replace with actual code to convert OpaquePointer to Data
// let keyData = Data(bytes: opaquePointer, count: keyLength) // Replace `keyLength` with actual length of key data
// let attributes: [String: Any] = [
// kSecAttrKeyType as String: kSecAttrKeyTypeRSA, // or kSecAttrKeyTypeECSECPrimeRandom for ECDSA
// kSecAttrKeyClass as String: kSecAttrKeyClassPublic
// ]
// var error: Unmanaged<CFError>?
// let secKey = SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, &error)
// if let error = error {
// //print("Error creating SecKey: \(error.takeRetainedValue())")
// return nil
// }
// return secKey
// }
func serializePublicKey(_ publicKey: SecKey) -> String? {
var error: Unmanaged<CFError>?
guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else {
//print("Error serializing public key: \(error!.takeRetainedValue() as Error)")
return nil
}
return publicKeyData.base64EncodedString()
}
func serializeSignature(from sod: SOD) -> String? {
do {
let signature = try sod.getSignature()
return signature.base64EncodedString()
} catch {
//print("Error extracting signature: \(error)")
return nil
}
}
func serializeX509Wrapper(_ certificate: X509Wrapper?) -> String? {
guard let certificate = certificate else { return nil }
let itemsDict = certificate.getItemsAsDict()
var certInfoStringKeys = [String: String]()
// Convert CertificateItem keys to String keys
for (key, value) in itemsDict {
certInfoStringKeys[key.rawValue] = value
}
// Add PEM representation
let certPEM = certificate.certToPEM()
certInfoStringKeys["PEM"] = certPEM
do {
let jsonData = try JSONSerialization.data(withJSONObject: certInfoStringKeys, options: [])
return String(data: jsonData, encoding: .utf8)
} catch {
//print("Error serializing X509Wrapper: \(error)")
return nil
}
}
func encodeX509WrapperToJsonString(_ certificate: X509Wrapper?) -> String? {
guard let certificate = certificate else { return nil }
let certificateItems = certificate.getItemsAsDict()
// Convert certificate items to JSON
do {
let jsonData = try JSONSerialization.data(withJSONObject: certificateItems, options: [])
return String(data: jsonData, encoding: .utf8)
} catch {
//print("Error serializing certificate items to JSON: \(error)")
return nil
}
}
func encodeByteArrayToHexString(_ byteArray: [UInt8]) -> String {
return byteArray.map { String(format: "%02x", $0) }.joined()
}
func encodeErrors(_ errors: [Error]) -> [String] {
return errors.map { $0.localizedDescription }
}
func convertDataGroupHashToSerializableFormat(_ dataGroupHash: DataGroupHash) -> [String: Any] {
return [
"id": dataGroupHash.id,
"sodHash": dataGroupHash.sodHash,
"computedHash": dataGroupHash.computedHash,
"match": dataGroupHash.match
]
}
func dataGroupIdToString(_ id: DataGroupId) -> String {
return String(id.rawValue) // or any other method to get a string representation
}
func certificateTypeToString(_ type: CertificateType) -> String {
return type.stringValue()
}
func convertDataGroupToSerializableFormat(_ dataGroup: DataGroup) -> [String: Any] {
return [
"datagroupType": dataGroupIdToString(dataGroup.datagroupType),
"body": encodeByteArrayToHexString(dataGroup.body),
"data": encodeByteArrayToHexString(dataGroup.data)
]
}
@objc
static func requiresMainQueueSetup() -> Bool {
return true
}
}
#else
// E2E Testing stub implementation
@available(iOS 15, *)
@objc(PassportReader)
class PassportReader: NSObject {
override init() {
super.init()
}
@objc(configure:enableDebugLogs:)
func configure(token: String, enableDebugLogs: Bool) {
// No-op for E2E testing
}
@objc(scanPassport:dateOfBirth:dateOfExpiry:canNumber:useCan:skipPACE:skipCA:extendedMode:usePacePolling:resolve:reject:)
func scanPassport(
_ passportNumber: String,
dateOfBirth: String,
dateOfExpiry: String,
canNumber: String,
useCan: NSNumber,
skipPACE: NSNumber,
skipCA: NSNumber,
extendedMode: NSNumber,
usePacePolling: NSNumber,
resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
reject("E2E_TESTING", "NFC scanning not available in E2E testing mode", nil)
}
@objc
static func requiresMainQueueSetup() -> Bool {
return true
}
}
#endif

View File

@@ -0,0 +1,165 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
// CameraView.swift
// SwiftUI camera preview with frame capture callback
import UIKit
import SwiftUI
import AVFoundation
struct SelfCameraView: UIViewControllerRepresentable {
var frameHandler: (UIImage, CGRect) -> Void
var captureInterval: TimeInterval = 0.5 // seconds
var showOverlay: Bool = true // For debug purposes. Set this value in LiveMRZScannerView.swift
func makeUIViewController(context: Context) -> SelfCameraViewController {
let controller = SelfCameraViewController()
controller.frameHandler = frameHandler
controller.captureInterval = captureInterval
controller.showOverlay = showOverlay
return controller
}
func updateUIViewController(_ uiViewController: SelfCameraViewController, context: Context) {
uiViewController.showOverlay = showOverlay
}
}
class SelfCameraViewController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate {
var frameHandler: ((UIImage, CGRect) -> Void)?
var captureInterval: TimeInterval = 0.5
var showOverlay: Bool = false
private let session = AVCaptureSession()
private let videoOutput = AVCaptureVideoDataOutput()
private var lastCaptureTime = Date(timeIntervalSince1970: 0)
private var previewLayer: AVCaptureVideoPreviewLayer?
private var roiOverlay: UIView? = nil
override func viewDidLoad() {
super.viewDidLoad()
setupCamera()
}
private func setupCamera() {
session.beginConfiguration()
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device) else { return }
if session.canAddInput(input) { session.addInput(input) }
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "camera.frame.queue"))
if session.canAddOutput(videoOutput) { session.addOutput(videoOutput) }
session.commitConfiguration()
previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer?.videoGravity = .resizeAspectFill
previewLayer?.frame = view.bounds
if let previewLayer = previewLayer {
view.layer.addSublayer(previewLayer)
}
// ROI overlay - for debugging
if showOverlay && roiOverlay == nil {
let overlay = UIView()
overlay.layer.borderColor = UIColor.green.cgColor
overlay.layer.borderWidth = 2.0
overlay.backgroundColor = UIColor.clear
overlay.isUserInteractionEnabled = false
view.addSubview(overlay)
roiOverlay = overlay
}
session.startRunning()
}
private func calculateGreenBoxFrame() -> CGRect {
guard let previewLayer = previewLayer else { return .zero }
let videoRect = previewLayer.layerRectConverted(fromMetadataOutputRect: CGRect(x: 0, y: 0, width: 1, height: 1))
let visibleRect = videoRect.intersection(view.bounds)
//Lottie animation frame
let lottieWidth = visibleRect.width * 1.3 // 130% of width
let lottieHeight = visibleRect.height * 1.3 // 130% of height
//bottom 25% of the Lottie animation
let boxHeight = lottieHeight * 0.25
// Center the box horizontally and ensure it's within bounds
let boxX = max(0, (visibleRect.width - lottieWidth) / 2)
let boxWidth = min(lottieWidth, visibleRect.width)
//Vertical offset to move the ROI a bit up. 15% in this case
let verticalOffset = visibleRect.height * 0.15
//GreenBox should stay within the visible area
let maxY = visibleRect.maxY - verticalOffset
let minY = visibleRect.minY
let boxY = max(minY, min(maxY - boxHeight, maxY - boxHeight))
// let boxY = visibleRect.maxY - boxHeight
return CGRect(x: boxX, y: boxY, width: boxWidth, height: boxHeight)
}
var roiInImageCoordinates: CGRect {
guard let previewLayer = previewLayer else { return .zero }
let videoRect = previewLayer.layerRectConverted(fromMetadataOutputRect: CGRect(x: 0, y: 0, width: 1, height: 1))
let greenBox = calculateGreenBoxFrame()
// map greenBox to normalized coordinates within videoRect
let normX = (greenBox.minX - videoRect.minX) / videoRect.width
let normY = (greenBox.minY - videoRect.minY) / videoRect.height
let normWidth = greenBox.width / videoRect.width
let normHeight = greenBox.height / videoRect.height
// Ensure normalized coordinates are within [0,1] bounds as vision's max ROI is (0,0) to (1,1)
let clampedX = max(0, min(1, normX))
let clampedY = max(0, min(1, normY))
let clampedWidth = max(0, min(1 - clampedX, normWidth))
let clampedHeight = max(0, min(1 - clampedY, normHeight))
// Vision expects (0,0) at bottom-left, so flip Y
let roiYVision = 1.0 - clampedY - clampedHeight
let roi = CGRect(x: clampedX, y: roiYVision, width: clampedWidth, height: clampedHeight)
// print("[CameraViewController] FINAL ROI for Vision (flipped Y, visible only): \(roi)")
return roi
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Ensure previewLayer matches the visible area
previewLayer?.frame = view.bounds
print("[CameraViewController] view.bounds: \(view.bounds)")
if let overlay = roiOverlay {
overlay.isHidden = !showOverlay
overlay.frame = calculateGreenBoxFrame()
}
}
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
let now = Date()
guard now.timeIntervalSince(lastCaptureTime) >= captureInterval else { return }
lastCaptureTime = now
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let ciImage = CIImage(cvPixelBuffer: imageBuffer)
let context = CIContext()
if let cgImage = context.createCGImage(ciImage, from: ciImage.extent) {
let originalImage = UIImage(cgImage: cgImage, scale: UIScreen.main.scale, orientation: .right)
let uprightImage = originalImage.fixedOrientation()
// print("[CameraViewController] cgImage size: \(cgImage.width)x\(cgImage.height), preview size: \(view.bounds.size), orientation: \(uprightImage.imageOrientation.rawValue)")
let roi = roiInImageCoordinates
DispatchQueue.main.async { [weak self] in
self?.frameHandler?(uprightImage, roi)
}
}
}
}
extension UIImage {
func fixedOrientation() -> UIImage {
if imageOrientation == .up { return self }
UIGraphicsBeginImageContextWithOptions(size, false, scale)
draw(in: CGRect(origin: .zero, size: size))
let normalizedImage = UIGraphicsGetImageFromCurrentImageContext() ?? self
UIGraphicsEndImageContext()
return normalizedImage
}
}

View File

@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
// LiveMRZScannerView.swift
import SwiftUI
import QKMRZParser
struct SelfLiveMRZScannerView: View {
@State private var recognizedText: String = ""
@State private var lastMRZDetection: Date = Date()
@State private var parsedMRZ: QKMRZResult? = nil
@State private var scanComplete: Bool = false
var onScanComplete: ((QKMRZResult) -> Void)? = nil
var onScanResultAsDict: (([String: Any]) -> Void)? = nil
func singleCorrectDocumentNumberInMRZ(result: String, docNumber: String, parser: QKMRZParser) -> QKMRZResult? {
let replacements: [Character: [Character]] = [
// "0": ["O", "D"],
// "1": ["I"],
"O": ["0"],
"D": ["0"],
"I": ["1"],
"L": ["1"],
"S": ["5"],
"G": ["6"],
// "2": ["Z"], "Z": ["2"],
// "8": ["B"], "B": ["8"]
]
let lines = result.components(separatedBy: "\n")
guard lines.count >= 2 else { return nil }
for (i, char) in docNumber.enumerated() {
if let subs = replacements[char] {
for sub in subs {
var chars = Array(docNumber)
chars[i] = sub
let candidate = String(chars)
if let range = lines[1].range(of: docNumber) {
var newLine = lines[1]
let start = newLine.distance(from: newLine.startIndex, to: range.lowerBound)
var lineChars = Array(newLine)
let docNumChars = Array(candidate)
for j in 0..<min(docNumber.count, docNumChars.count) {
lineChars[start + j] = docNumChars[j]
}
newLine = String(lineChars)
var newLines = lines
newLines[1] = newLine
let correctedMRZ = newLines.joined(separator: "\n")
// print("Trying candidate: \(candidate), correctedMRZ: \(correctedMRZ)")
if let correctedResult = parser.parse(mrzString: correctedMRZ) {
if correctedResult.isDocumentNumberValid {
return correctedResult
}
}
}
}
}
}
return nil
}
private func mapVisionResultToDictionary(_ result: QKMRZResult) -> [String: Any] {
return [
"documentType": result.documentType,
"countryCode": result.countryCode,
"surnames": result.surnames,
"givenNames": result.givenNames,
"documentNumber": result.documentNumber,
"nationalityCountryCode": result.nationalityCountryCode,
"dateOfBirth": result.birthdate?.description ?? "",
"sex": result.sex ?? "",
"expiryDate": result.expiryDate?.description ?? "",
"personalNumber": result.personalNumber,
"personalNumber2": result.personalNumber2 ?? "",
"isDocumentNumberValid": result.isDocumentNumberValid,
"isBirthdateValid": result.isBirthdateValid,
"isExpiryDateValid": result.isExpiryDateValid,
"isPersonalNumberValid": result.isPersonalNumberValid ?? false,
"allCheckDigitsValid": result.allCheckDigitsValid
]
}
var body: some View {
ZStack(alignment: .bottom) {
SelfCameraView(
frameHandler: { image, roi in
if scanComplete { return }
SelfMRZScanner.scan(image: image, roi: roi) { result, boxes in
recognizedText = result
lastMRZDetection = Date()
// print("[LiveMRZScannerView] result: \(result)")
let parser = QKMRZParser(ocrCorrection: false)
if let mrzResult = parser.parse(mrzString: result) {
let doc = mrzResult;
// print("[LiveMRZScannerView] doc: \(doc)")
if doc.allCheckDigitsValid == true && !scanComplete {
parsedMRZ = mrzResult
scanComplete = true
onScanComplete?(mrzResult)
onScanResultAsDict?(mapVisionResultToDictionary(mrzResult))
} else if doc.isDocumentNumberValid == false && !scanComplete {
if let correctedResult = singleCorrectDocumentNumberInMRZ(result: result, docNumber: doc.documentNumber, parser: parser) {
let correctedDoc = correctedResult
// print("[LiveMRZScannerView] correctedDoc: \(correctedDoc)")
if correctedDoc.allCheckDigitsValid == true {
parsedMRZ = correctedResult
scanComplete = true
onScanComplete?(correctedResult)
onScanResultAsDict?(mapVisionResultToDictionary(correctedResult))
}
}
}
} else {
if !scanComplete {
parsedMRZ = nil
}
}
}
},
showOverlay: false
)
VStack {
if !scanComplete {
Text("Align the animation with the MRZ on the passport.")
.font(.footnote)
.padding()
.background(Color.black.opacity(0.7))
.foregroundColor(.white)
.cornerRadius(8)
.padding(.bottom, 40)
}
}
}
}
}

View File

@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
//
// MRZScanner.swift
import Vision
import UIKit
struct SelfMRZScanner {
static func scan(image: UIImage, roi: CGRect? = nil, completion: @escaping (String, [CGRect]) -> Void) {
guard let cgImage = image.cgImage else {
DispatchQueue.main.async {
completion("Image not valid", [])
}
return
}
let request = VNRecognizeTextRequest { (request, error) in
if let error = error {
print("Vision error: \(error)")
}
guard let observations = request.results as? [VNRecognizedTextObservation] else {
print("No text observations found")
DispatchQueue.main.async {
completion("No text found", [])
}
return
}
// print("Found \(observations.count) text observations")
var mrzLines: [String] = []
var boxes: [CGRect] = []
// Sort lines from top to bottom
let sortedObservations = observations.sorted { $0.boundingBox.minY > $1.boundingBox.minY }
for (index, obs) in sortedObservations.enumerated() {
if let candidate = obs.topCandidates(1).first {
let text = candidate.string
let confidence = candidate.confidence
// print("Line \(index): '\(text)' (confidence: \(confidence), position: \(obs.boundingBox))")
// Check if this looks like an MRZ line (either contains "<" or matches MRZ pattern)
// TD1 format (ID cards): 30 chars, TD3 format (passports): 44 chars
if text.contains("<") ||
text.matches(pattern: "^[A-Z0-9<]{30}$") || //TD1 //case where there's no '<' in MRZ
text.matches(pattern: "^[A-Z0-9<]{44}$") //TD3
{
// print("Matched MRZ pattern: \(text)")
mrzLines.append(text)
boxes.append(obs.boundingBox)
// Check if we have a complete MRZ
if (mrzLines.count == 2 && mrzLines.allSatisfy { $0.count == 44 }) || // TD3 - passport
(mrzLines.count == 3 && mrzLines.allSatisfy { $0.count == 30 }) { // TD1 - ID card
break
}
} else {
print("Did not match MRZ pattern: \(text)")
}
}
}
DispatchQueue.main.async {
if mrzLines.isEmpty {
print("No MRZ lines found")
completion("", [])
} else {
print("Found \(mrzLines.count) MRZ lines")
completion(mrzLines.joined(separator: "\n"), boxes)
}
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = false
request.recognitionLanguages = ["en"]
// Use provided ROI. If not use as bottom 20%
if let roi = roi {
// print("[MRZScanner] Using provided ROI: \(roi) (image size: \(cgImage.width)x\(cgImage.height))")
request.regionOfInterest = roi
} else {
let imageHeight = CGFloat(cgImage.height)
let roiHeight = imageHeight * 0.2 // Bottom 20%
let defaultRoi = CGRect(x: 0, y: 0, width: 1.0, height: roiHeight / imageHeight)
// print("[MRZScanner] Using default ROI: \(defaultRoi) (image size: \(cgImage.width)x\(cgImage.height), roi height: \(roiHeight))")
request.regionOfInterest = defaultRoi
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
DispatchQueue.global(qos: .userInitiated).async {
do {
try handler.perform([request])
} catch {
print("Failed to perform recognition: \(error)")
DispatchQueue.main.async {
completion("Failed to perform recognition: \(error)", [])
}
}
}
}
}
extension String {
func matches(pattern: String) -> Bool {
return range(of: pattern, options: .regularExpression) != nil
}
}

View File

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

View File

@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
//
// SelfMRZScannerModule.swift
// SelfSDK
//
import Foundation
import React
import SwiftUI
import UIKit
@objc(SelfMRZScannerModule)
class SelfMRZScannerModule: NSObject, RCTBridgeModule {
static func moduleName() -> String! {
return "SelfMRZScannerModule"
}
static func requiresMainQueueSetup() -> Bool {
return true
}
@objc func startScanning(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
DispatchQueue.main.async {
guard let rootViewController = UIApplication.shared.keyWindow?.rootViewController else {
reject("error", "Unable to find root view controller", nil)
return
}
var hostingController: UIHostingController<SelfLiveMRZScannerView>? = nil
var scannerView = SelfLiveMRZScannerView()
scannerView.onScanResultAsDict = { resultDict in
// Format dates to YYMMDD format
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyMMdd"
let birthDate = resultDict["dateOfBirth"] as? String ?? ""
let expiryDate = resultDict["expiryDate"] as? String ?? ""
let resultDict: [String: Any] = [
"data": [
"documentNumber": resultDict["documentNumber"] as? String ?? "",
"expiryDate": expiryDate,
"birthDate": birthDate,
"documentType": resultDict["documentType"] as? String ?? "",
"countryCode": resultDict["countryCode"] as? String ?? ""
]
]
resolve(resultDict)
// Dismiss the hosting controller after scanning
hostingController?.dismiss(animated: true, completion: nil)
}
hostingController = UIHostingController(rootView: scannerView)
rootViewController.present(hostingController!, animated: true, completion: nil)
}
}
@objc func stopScanning(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
// Logic to stop scanning
resolve("Scanning stopped")
}
}

View File

@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
#import <React/RCTBridgeModule.h>
#import <React/RCTViewManager.h>
@interface RCT_EXTERN_MODULE(SelfMRZScannerViewManager, RCTViewManager)
RCT_EXPORT_VIEW_PROPERTY(onPassportRead, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onError, RCTDirectEventBlock)
@end

View File

@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import Foundation
import React
import SwiftUI
import UIKit
@objc(SelfMRZScannerViewManager)
class SelfMRZScannerViewManager: RCTViewManager {
override static func requiresMainQueueSetup() -> Bool {
return true
}
override func view() -> UIView! {
return SelfMRZScannerView()
}
override static func moduleName() -> String! {
return "SelfMRZScannerView"
}
}
class SelfMRZScannerView: UIView {
@objc var onPassportRead: RCTDirectEventBlock?
@objc var onError: RCTDirectEventBlock?
private var hostingController: UIHostingController<SelfLiveMRZScannerView>?
override init(frame: CGRect) {
super.init(frame: frame)
initializeScanner()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
initializeScanner()
}
private func initializeScanner() {
let scannerView = SelfLiveMRZScannerView(
onScanResultAsDict: { [weak self] resultDict in
self?.onPassportRead?([
"data": [
"documentNumber": resultDict["documentNumber"] as? String ?? "",
"expiryDate": resultDict["expiryDate"] as? String ?? "",
"birthDate": resultDict["dateOfBirth"] as? String ?? "",
"documentType": resultDict["documentType"] as? String ?? "",
"countryCode": resultDict["countryCode"] as? String ?? ""
]])
}
)
let hostingController = UIHostingController(rootView: scannerView)
hostingController.view.backgroundColor = .clear
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
addSubview(hostingController.view)
self.hostingController = hostingController
}
override func layoutSubviews() {
super.layoutSubviews()
hostingController?.view.frame = bounds
}
}

View File

@@ -0,0 +1,477 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
BF7273CE2E53412C002FE485 /* PassportReader.m in Sources */ = {isa = PBXBuildFile; fileRef = BF7273CC2E53412C002FE485 /* PassportReader.m */; };
BF7273CF2E53412C002FE485 /* PassportReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7273CD2E53412C002FE485 /* PassportReader.swift */; };
BF7273D22E534854002FE485 /* NFCPassportReader in Frameworks */ = {isa = PBXBuildFile; productRef = BF7273D12E534854002FE485 /* NFCPassportReader */; };
BFE3DFC62E4F4E7300195298 /* QKMRZParser in Frameworks */ = {isa = PBXBuildFile; productRef = BFE3DFC52E4F4E7300195298 /* QKMRZParser */; };
BFE3DFC92E4F4E7A00195298 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFE3DFC82E4F4E7A00195298 /* AVFoundation.framework */; };
BFE3DFCB2E4F4E8000195298 /* Vision.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFE3DFCA2E4F4E8000195298 /* Vision.framework */; };
BFE3DFD42E4F4EC400195298 /* SelfCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE3DFCC2E4F4EC400195298 /* SelfCameraView.swift */; };
BFE3DFD52E4F4EC400195298 /* SelfMRZScannerModule.m in Sources */ = {isa = PBXBuildFile; fileRef = BFE3DFCF2E4F4EC400195298 /* SelfMRZScannerModule.m */; };
BFE3DFD62E4F4EC400195298 /* SelfMRZScannerViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE3DFD22E4F4EC400195298 /* SelfMRZScannerViewManager.swift */; };
BFE3DFD72E4F4EC400195298 /* SelfMRZScannerModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE3DFD02E4F4EC400195298 /* SelfMRZScannerModule.swift */; };
BFE3DFD82E4F4EC400195298 /* SelfLiveMRZScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE3DFCD2E4F4EC400195298 /* SelfLiveMRZScannerView.swift */; };
BFE3DFD92E4F4EC400195298 /* SelfMRZScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE3DFCE2E4F4EC400195298 /* SelfMRZScanner.swift */; };
BFE3DFDA2E4F4EC400195298 /* SelfMRZScannerViewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = BFE3DFD12E4F4EC400195298 /* SelfMRZScannerViewManager.m */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
BF7273CC2E53412C002FE485 /* PassportReader.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PassportReader.m; sourceTree = "<group>"; };
BF7273CD2E53412C002FE485 /* PassportReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportReader.swift; sourceTree = "<group>"; };
BFE3DFB82E4F4E5C00195298 /* SelfSDK.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SelfSDK.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BFE3DFC82E4F4E7A00195298 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk/System/Library/Frameworks/AVFoundation.framework; sourceTree = DEVELOPER_DIR; };
BFE3DFCA2E4F4E8000195298 /* Vision.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Vision.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.5.sdk/System/Library/Frameworks/Vision.framework; sourceTree = DEVELOPER_DIR; };
BFE3DFCC2E4F4EC400195298 /* SelfCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfCameraView.swift; sourceTree = "<group>"; };
BFE3DFCD2E4F4EC400195298 /* SelfLiveMRZScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfLiveMRZScannerView.swift; sourceTree = "<group>"; };
BFE3DFCE2E4F4EC400195298 /* SelfMRZScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfMRZScanner.swift; sourceTree = "<group>"; };
BFE3DFCF2E4F4EC400195298 /* SelfMRZScannerModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SelfMRZScannerModule.m; sourceTree = "<group>"; };
BFE3DFD02E4F4EC400195298 /* SelfMRZScannerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfMRZScannerModule.swift; sourceTree = "<group>"; };
BFE3DFD12E4F4EC400195298 /* SelfMRZScannerViewManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SelfMRZScannerViewManager.m; sourceTree = "<group>"; };
BFE3DFD22E4F4EC400195298 /* SelfMRZScannerViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfMRZScannerViewManager.swift; sourceTree = "<group>"; };
BFE3DFD32E4F4EC400195298 /* SelfSDK-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SelfSDK-Bridging-Header.h"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
BFE3DFBA2E4F4E5C00195298 /* SelfSDK */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SelfSDK;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
BFE3DFB52E4F4E5C00195298 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
BFE3DFCB2E4F4E8000195298 /* Vision.framework in Frameworks */,
BFE3DFC92E4F4E7A00195298 /* AVFoundation.framework in Frameworks */,
BFE3DFC62E4F4E7300195298 /* QKMRZParser in Frameworks */,
BF7273D22E534854002FE485 /* NFCPassportReader in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
BFE3DFAE2E4F4E5C00195298 = {
isa = PBXGroup;
children = (
BF7273CC2E53412C002FE485 /* PassportReader.m */,
BF7273CD2E53412C002FE485 /* PassportReader.swift */,
BFE3DFCC2E4F4EC400195298 /* SelfCameraView.swift */,
BFE3DFCD2E4F4EC400195298 /* SelfLiveMRZScannerView.swift */,
BFE3DFCE2E4F4EC400195298 /* SelfMRZScanner.swift */,
BFE3DFCF2E4F4EC400195298 /* SelfMRZScannerModule.m */,
BFE3DFD02E4F4EC400195298 /* SelfMRZScannerModule.swift */,
BFE3DFD12E4F4EC400195298 /* SelfMRZScannerViewManager.m */,
BFE3DFD22E4F4EC400195298 /* SelfMRZScannerViewManager.swift */,
BFE3DFD32E4F4EC400195298 /* SelfSDK-Bridging-Header.h */,
BFE3DFBA2E4F4E5C00195298 /* SelfSDK */,
BFE3DFC72E4F4E7A00195298 /* Frameworks */,
BFE3DFB92E4F4E5C00195298 /* Products */,
);
sourceTree = "<group>";
};
BFE3DFB92E4F4E5C00195298 /* Products */ = {
isa = PBXGroup;
children = (
BFE3DFB82E4F4E5C00195298 /* SelfSDK.framework */,
);
name = Products;
sourceTree = "<group>";
};
BFE3DFC72E4F4E7A00195298 /* Frameworks */ = {
isa = PBXGroup;
children = (
BFE3DFCA2E4F4E8000195298 /* Vision.framework */,
BFE3DFC82E4F4E7A00195298 /* AVFoundation.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
BFE3DFB32E4F4E5C00195298 /* Headers */ = {
isa = PBXHeadersBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXHeadersBuildPhase section */
/* Begin PBXNativeTarget section */
BFE3DFB72E4F4E5C00195298 /* SelfSDK */ = {
isa = PBXNativeTarget;
buildConfigurationList = BFE3DFC12E4F4E5C00195298 /* Build configuration list for PBXNativeTarget "SelfSDK" */;
buildPhases = (
BFE3DFB32E4F4E5C00195298 /* Headers */,
BFE3DFB42E4F4E5C00195298 /* Sources */,
BFE3DFB52E4F4E5C00195298 /* Frameworks */,
BFE3DFB62E4F4E5C00195298 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
BFE3DFBA2E4F4E5C00195298 /* SelfSDK */,
);
name = SelfSDK;
packageProductDependencies = (
BFE3DFC52E4F4E7300195298 /* QKMRZParser */,
BF7273D12E534854002FE485 /* NFCPassportReader */,
);
productName = SelfSDK;
productReference = BFE3DFB82E4F4E5C00195298 /* SelfSDK.framework */;
productType = "com.apple.product-type.framework";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
BFE3DFAF2E4F4E5C00195298 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1640;
LastUpgradeCheck = 1640;
TargetAttributes = {
BFE3DFB72E4F4E5C00195298 = {
CreatedOnToolsVersion = 16.4;
LastSwiftMigration = 1640;
};
};
};
buildConfigurationList = BFE3DFB22E4F4E5C00195298 /* Build configuration list for PBXProject "SelfSDK" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = BFE3DFAE2E4F4E5C00195298;
minimizedProjectReferenceProxies = 1;
packageReferences = (
BFE3DFC42E4F4E7300195298 /* XCRemoteSwiftPackageReference "QKMRZParser" */,
BF7273D02E534854002FE485 /* XCRemoteSwiftPackageReference "NFCPassportReader" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = BFE3DFB92E4F4E5C00195298 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
BFE3DFB72E4F4E5C00195298 /* SelfSDK */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
BFE3DFB62E4F4E5C00195298 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
BFE3DFB42E4F4E5C00195298 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BFE3DFD42E4F4EC400195298 /* SelfCameraView.swift in Sources */,
BFE3DFD52E4F4EC400195298 /* SelfMRZScannerModule.m in Sources */,
BFE3DFD62E4F4EC400195298 /* SelfMRZScannerViewManager.swift in Sources */,
BFE3DFD72E4F4EC400195298 /* SelfMRZScannerModule.swift in Sources */,
BFE3DFD82E4F4EC400195298 /* SelfLiveMRZScannerView.swift in Sources */,
BF7273CE2E53412C002FE485 /* PassportReader.m in Sources */,
BF7273CF2E53412C002FE485 /* PassportReader.swift in Sources */,
BFE3DFD92E4F4EC400195298 /* SelfMRZScanner.swift in Sources */,
BFE3DFDA2E4F4EC400195298 /* SelfMRZScannerViewManager.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
BFE3DFBF2E4F4E5C00195298 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 5B29R5LYHQ;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
VERSIONING_SYSTEM = "apple-generic";
VERSION_INFO_PREFIX = "";
};
name = Debug;
};
BFE3DFC02E4F4E5C00195298 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 5B29R5LYHQ;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
VERSIONING_SYSTEM = "apple-generic";
VERSION_INFO_PREFIX = "";
};
name = Release;
};
BFE3DFC22E4F4E5C00195298 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5B29R5LYHQ;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
ENABLE_MODULE_VERIFIER = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(PODS_ROOT)/Headers/Public/React-Core",
"$(PODS_ROOT)/Headers/Public/React",
);
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = (
"$(PODS_ROOT)/Headers/Public/React-Core",
"$(PODS_ROOT)/Headers/Public/React",
);
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++";
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20";
PRODUCT_BUNDLE_IDENTIFIER = xyz.self.SelfSDK;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_INSTALL_MODULE = YES;
SWIFT_INSTALL_OBJC_HEADER = NO;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
BFE3DFC32E4F4E5C00195298 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5B29R5LYHQ;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
ENABLE_MODULE_VERIFIER = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(PODS_ROOT)/Headers/Public/React-Core",
"$(PODS_ROOT)/Headers/Public/React",
);
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = (
"$(PODS_ROOT)/Headers/Public/React-Core",
"$(PODS_ROOT)/Headers/Public/React",
);
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++";
MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20";
PRODUCT_BUNDLE_IDENTIFIER = xyz.self.SelfSDK;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_INSTALL_MODULE = YES;
SWIFT_INSTALL_OBJC_HEADER = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
BFE3DFB22E4F4E5C00195298 /* Build configuration list for PBXProject "SelfSDK" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BFE3DFBF2E4F4E5C00195298 /* Debug */,
BFE3DFC02E4F4E5C00195298 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
BFE3DFC12E4F4E5C00195298 /* Build configuration list for PBXNativeTarget "SelfSDK" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BFE3DFC22E4F4E5C00195298 /* Debug */,
BFE3DFC32E4F4E5C00195298 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
BF7273D02E534854002FE485 /* XCRemoteSwiftPackageReference "NFCPassportReader" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/seshanthS/NFCPassportReader";
requirement = {
kind = revision;
revision = 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b;
};
};
BFE3DFC42E4F4E7300195298 /* XCRemoteSwiftPackageReference "QKMRZParser" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Mattijah/QKMRZParser.git";
requirement = {
branch = master;
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
BF7273D12E534854002FE485 /* NFCPassportReader */ = {
isa = XCSwiftPackageProductDependency;
package = BF7273D02E534854002FE485 /* XCRemoteSwiftPackageReference "NFCPassportReader" */;
productName = NFCPassportReader;
};
BFE3DFC52E4F4E7300195298 /* QKMRZParser */ = {
isa = XCSwiftPackageProductDependency;
package = BFE3DFC42E4F4E7300195298 /* XCRemoteSwiftPackageReference "QKMRZParser" */;
productName = QKMRZParser;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = BFE3DFAF2E4F4E5C00195298 /* Project object */;
}

View File

@@ -0,0 +1,41 @@
{
"originHash" : "641baa15781d19846454d9bf0c82bbd4020f7c0ca0a706e8dfce02af0ac4b003",
"pins" : [
{
"identity" : "mixpanel-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mixpanel/mixpanel-swift.git",
"state" : {
"revision" : "e2619282894502f9378abe04c7f7d92fc523484a",
"version" : "5.0.0"
}
},
{
"identity" : "nfcpassportreader",
"kind" : "remoteSourceControl",
"location" : "https://github.com/seshanthS/NFCPassportReader",
"state" : {
"revision" : "9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b"
}
},
{
"identity" : "openssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/krzyzanowskim/OpenSSL.git",
"state" : {
"revision" : "8cb1d641ab5ebce2cd7cf31c93baef07bed672d4",
"version" : "1.1.2301"
}
},
{
"identity" : "qkmrzparser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Mattijah/QKMRZParser.git",
"state" : {
"branch" : "master",
"revision" : "571cf290364911b8d109a54ff189d50c10b56abd"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,13 @@
# `SelfSDK`
<!--@START_MENU_TOKEN@-->Summary<!--@END_MENU_TOKEN@-->
## Overview
<!--@START_MENU_TOKEN@-->Text<!--@END_MENU_TOKEN@-->
## Topics
### <!--@START_MENU_TOKEN@-->Group<!--@END_MENU_TOKEN@-->
- <!--@START_MENU_TOKEN@-->``Symbol``<!--@END_MENU_TOKEN@-->

View File

@@ -0,0 +1,15 @@
//
// SelfSDK.m
// SelfSDK
//
// Created by Seshanth on 15/08/25.
//
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(SelfSDK, NSObject)
RCT_EXTERN_METHOD(registerViewManagers:(RCTBridge *)bridge)
@end

View File

@@ -0,0 +1,23 @@
//
// SelfSDK.swift
// SelfSDK
//
import Foundation
import React
@objc(SelfSDK)
class SelfSDK: NSObject, RCTBridgeModule {
static func moduleName() -> String! {
return "SelfSDK"
}
static func requiresMainQueueSetup() -> Bool {
return true
}
@objc func registerViewManagers(_ bridge: RCTBridge) {
// This method is required by the Objective-C interface
// but we don't need to manually register view managers
}
}

View File

@@ -0,0 +1,41 @@
require 'json'
# Handle both local development and published package scenarios
package_json_path = File.join(__dir__, '..', 'package.json')
if File.exist?(package_json_path)
package = JSON.parse(File.read(package_json_path))
else
# Fallback for when package.json is not found
package = {
'version' => '0.1.0',
'description' => 'Self Mobile SDK Alpha'
}
end
Pod::Spec.new do |s|
s.name = "mobile-sdk-alpha"
s.version = package['version']
s.summary = package['description']
s.homepage = "https://github.com/selfxyz/self"
s.license = "BUSL-1.1"
s.author = { "Self" => "team@self.xyz" }
s.platform = :ios, "13.0"
s.source = { :path => "." }
s.source_files = "ios/**/*.{h,m,mm,swift}"
s.public_header_files = "ios/**/*.h"
s.dependency "React-Core"
s.dependency "QKMRZParser"
s.dependency "NFCPassportReader"
s.pod_target_xcconfig = {
'HEADER_SEARCH_PATHS' => '"$(PODS_ROOT)/Headers/Public/React-Core"',
'DEFINES_MODULE' => 'YES',
'SWIFT_INCLUDE_PATHS' => '$(PODS_ROOT)/mobile-sdk-alpha/ios'
}
# Ensure iOS files are properly linked
s.platform = :ios, "13.0"
s.requires_arc = true
end

View File

@@ -34,7 +34,11 @@
"module": "./dist/esm/index.js",
"types": "./dist/esm/index.d.ts",
"files": [
"dist"
"dist",
"android",
"ios",
"mobile-sdk-alpha.podspec",
"react-native.config.cjs"
],
"scripts": {
"build": "rm -rf dist && tsup && yarn postbuild",
@@ -60,10 +64,10 @@
"validate:pkg": "node ./scripts/verify-conditions.mjs"
},
"dependencies": {
"@selfxyz/common": "workspace:*",
"tslib": "^2.6.2"
},
"devDependencies": {
"@selfxyz/common": "workspace:^",
"@testing-library/react": "^14.1.2",
"@types/react": "^18.3.4",
"@typescript-eslint/eslint-plugin": "^8.0.0",

View File

@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
module.exports = {
dependencies: {
'@selfxyz/mobile-sdk-alpha': {
platforms: {
ios: {
sourceDir: './ios',
podspecPath: './mobile-sdk-alpha.podspec',
},
android: {
sourceDir: './android',
manifestPath: 'src/main/AndroidManifest.xml',
packageImportPath: 'import com.selfxyz.selfSDK.RNSelfPassportReaderPackage;',
packageInstance: 'new RNSelfPassportReaderPackage()',
},
},
},
},
project: {
ios: {
sourceDir: './ios',
},
android: {
sourceDir: './android',
},
},
};

View File

@@ -0,0 +1,217 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { Buffer } from 'buffer';
import { NativeModules, Platform } from 'react-native';
import type { PassportData, ScannerAdapter, ScanOpts, ScanResult } from '../../types/public';
export const reactNativeScannerAdapter: ScannerAdapter = {
async scan(opts: ScanOpts): Promise<ScanResult> {
if (Platform.OS === 'ios') {
return await scanIOS(opts);
} else if (Platform.OS === 'android') {
return await scanAndroid(opts);
} else if (opts.mode === 'qr') {
return { mode: 'qr', data: 'self://stub-qr' };
}
throw new Error(`Platform ${Platform.OS} not supported`);
},
};
async function scanIOS(opts: ScanOpts): Promise<ScanResult> {
const { SelfMRZScannerModule, PassportReader } = NativeModules;
switch (opts.mode) {
case 'mrz':
if (!SelfMRZScannerModule) {
throw new Error('SelfMRZScannerModule not found, check if its linked correctly');
}
try {
const result = await SelfMRZScannerModule.startScanning();
const documentType = result.data.documentType.startsWith('P') ? 'passport' : 'id_card';
return {
mode: 'mrz',
mrzInfo: {
documentNumber: result.data.documentNumber,
dateOfBirth: result.data.birthDate,
dateOfExpiry: result.data.expiryDate,
issuingCountry: result.data.countryCode,
documentType: documentType,
},
};
} catch (error) {
throw new Error(`MRZ scanning failed: ${error}`);
}
case 'nfc':
if (!PassportReader) {
throw new Error('PassportReader not found, check if its linked correctly');
}
try {
const { passportNumber, dateOfBirth, dateOfExpiry, canNumber, skipPACE, skipCA, extendedMode, usePacePolling } =
opts;
if (!passportNumber || !dateOfBirth || !dateOfExpiry) {
throw new Error('NFC scanning requires passportNumber, dateOfBirth, and dateOfExpiry');
}
const result = await PassportReader.scanPassport(
passportNumber,
dateOfBirth,
dateOfExpiry,
canNumber || '',
!!canNumber || false,
skipPACE || false,
skipCA || false,
extendedMode || false,
usePacePolling || false,
);
const parsed = JSON.parse(String(result));
const dgHashesObj = JSON.parse(parsed?.dataGroupHashes);
const dg1HashString = dgHashesObj?.DG1?.sodHash;
const dg1Hash = Array.from(Buffer.from(dg1HashString, 'hex'));
const dg2HashString = dgHashesObj?.DG2?.sodHash;
const dg2Hash = Array.from(Buffer.from(dg2HashString, 'hex'));
const eContentBase64 = parsed?.eContentBase64;
const signedAttributes = parsed?.signedAttributes;
const mrz = parsed?.passportMRZ;
const signatureBase64 = parsed?.signatureBase64;
const documentSigningCertificate = parsed?.documentSigningCertificate;
const pem = JSON.parse(documentSigningCertificate).PEM.replace(/\n/g, '');
const eContentArray = Array.from(Buffer.from(signedAttributes, 'base64'));
const signedEContentArray = eContentArray.map(byte => (byte > 127 ? byte - 256 : byte));
const concatenatedDataHashesArray = Array.from(Buffer.from(eContentBase64, 'base64'));
const concatenatedDataHashesArraySigned = concatenatedDataHashesArray.map(byte =>
byte > 127 ? byte - 256 : byte,
);
const encryptedDigestArray = Array.from(Buffer.from(signatureBase64, 'base64')).map(byte =>
byte > 127 ? byte - 256 : byte,
);
const document_type = mrz.length === 88 ? 'passport' : 'id_card';
return {
mode: 'nfc',
passportData: {
mrz: mrz,
eContent: concatenatedDataHashesArraySigned,
signedAttr: signedEContentArray,
encryptedDigest: encryptedDigestArray,
documentType: document_type,
dsc: pem,
dg2Hash: dg2Hash,
dg1Hash: dg1Hash,
dgPresents: parsed?.dataGroupsPresent,
parsed: false,
mock: false,
documentCategory: document_type,
} as PassportData,
};
} catch (error) {
throw new Error(`NFC scanning failed: ${error}`);
}
case 'qr':
throw new Error('QR scanning not implemented yet');
default:
throw new Error(`Unsupported scan mode`);
}
}
async function scanAndroid(opts: ScanOpts): Promise<ScanResult> {
const { SelfPassportReader: PassportReader, SelfMRZScannerModule } = NativeModules;
if (opts.mode === 'nfc' && !PassportReader) {
throw new Error('PassportReader not found, check if its linked correctly');
}
if (opts.mode === 'mrz' && !SelfMRZScannerModule) {
throw new Error('SelfMRZScannerModule not found, check if its linked correctly');
}
switch (opts.mode) {
case 'mrz':
try {
const result = await SelfMRZScannerModule.startScanning();
const documentType = result.data.documentType.startsWith('P') ? 'passport' : 'id_card';
return {
mode: 'mrz',
mrzInfo: {
documentNumber: result.data.documentNumber,
dateOfBirth: result.data.birthDate,
dateOfExpiry: result.data.expiryDate,
issuingCountry: result.data.countryCode,
documentType: documentType,
},
};
} catch (error) {
throw new Error(`MRZ scanning failed: ${error}`);
}
case 'nfc':
try {
const { passportNumber, dateOfBirth, dateOfExpiry, canNumber } = opts;
if (!passportNumber || !dateOfBirth || !dateOfExpiry) {
throw new Error('NFC scanning requires passportNumber, dateOfBirth, and dateOfExpiry');
}
const scanOptions = {
documentNumber: passportNumber,
dateOfBirth: dateOfBirth,
dateOfExpiry: dateOfExpiry,
canNumber: canNumber || '',
useCan: !!canNumber,
};
const result = await PassportReader.scan(scanOptions);
const dgHashesObj = JSON.parse(result.dataGroupHashes);
const dg1HashString = dgHashesObj['1'];
const dg1Hash = Array.from(Buffer.from(dg1HashString, 'hex'));
const dg2Hash = dgHashesObj['2'];
const pem = '-----BEGIN CERTIFICATE-----' + result.documentSigningCertificate + '-----END CERTIFICATE-----';
const dgPresents = Object.keys(dgHashesObj)
.map(key => parseInt(key, 10))
.filter(num => !isNaN(num))
.sort((a, b) => a - b);
const mrz_clean = result.mrz.replace(/\n/g, '');
const document_type = mrz_clean.length === 88 ? 'passport' : 'id_card';
return {
mode: 'nfc',
passportData: {
mrz: mrz_clean,
dsc: pem,
dg2Hash: dg2Hash,
dg1Hash: dg1Hash,
dgPresents: dgPresents,
eContent: JSON.parse(result.encapContent),
signedAttr: JSON.parse(result.eContent),
encryptedDigest: JSON.parse(result.encryptedDigest),
documentType: document_type,
documentCategory: document_type,
parsed: false,
mock: false,
} as PassportData,
};
} catch (error) {
throw new Error(`NFC scanning failed: ${error}`);
}
case 'qr':
throw new Error('QR scanning not implemented yet');
default:
throw new Error(`Unsupported scan mode`);
}
}

View File

@@ -63,6 +63,8 @@ export { mergeConfig } from './config/merge';
export { parseNFCResponse, scanNFC } from './nfc';
export { reactNativeScannerAdapter } from './adapters/react-native/scanner';
export { scanQRProof } from './qr';
export { webScannerShim } from './adapters/web/shims';

View File

@@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback, useRef } from 'react';
import type { DimensionValue, NativeSyntheticEvent, ViewProps, ViewStyle } from 'react-native';
import { NativeModules, PixelRatio, Platform, requireNativeComponent, StyleSheet, View } from 'react-native';
import { extractMRZInfo, formatDateToYYMMDD } from '../mrz';
import { MRZInfo } from '../types/public';
import { RCTFragment } from './RCTFragment';
interface SelfMRZScannerViewProps extends ViewProps {
onPassportRead?: (
event: NativeSyntheticEvent<{
data:
| string
| {
documentNumber: string;
expiryDate: string;
birthDate: string;
documentType: string;
countryCode: string;
};
}>,
) => void;
onError?: (
event: NativeSyntheticEvent<{
error: string;
errorMessage: string;
stackTrace: string;
}>,
) => void;
width?: number;
height?: number;
}
const NativeMRZScannerView = requireNativeComponent<SelfMRZScannerViewProps>(
Platform.select({
ios: 'SelfMRZScannerView',
android: 'SelfOCRViewManager',
})!,
);
interface MRZScannerViewProps {
style?: ViewStyle;
height?: DimensionValue;
width?: DimensionValue;
aspectRatio?: number;
onMRZDetected?: (data: MRZInfo) => void;
onError?: (error: string) => void;
}
export const MRZScannerView: React.FC<MRZScannerViewProps> = ({
style,
height,
width,
aspectRatio,
onMRZDetected,
onError,
}) => {
const viewRef = useRef<any>(null);
const handleMRZDetected = useCallback(
(event: any) => {
const data = event.nativeEvent.data;
if (Platform.OS === 'ios') {
const formattedBirthDate = formatDateToYYMMDD(data.birthDate);
const formattedExpiryDate = formatDateToYYMMDD(data.expiryDate);
onMRZDetected?.({
documentNumber: data.documentNumber,
dateOfBirth: formattedBirthDate,
dateOfExpiry: formattedExpiryDate,
issuingCountry: data.countryCode,
documentType: data.documentType,
});
} else if (Platform.OS === 'android') {
const extractedData = extractMRZInfo(data);
onMRZDetected?.({
documentNumber: extractedData.documentNumber,
dateOfBirth: extractedData.dateOfBirth,
dateOfExpiry: extractedData.dateOfExpiry,
issuingCountry: extractedData.issuingCountry,
documentType: extractedData.documentType,
});
} else {
throw new Error('Unsupported platform');
}
},
[onMRZDetected],
);
const handleError = useCallback(
(event: any) => {
const { error } = event.nativeEvent;
onError?.(error);
},
[onError],
);
const containerStyle = [
styles.container,
height !== undefined && { height },
width !== undefined && { width },
aspectRatio !== undefined && { aspectRatio },
style,
];
if (Platform.OS === 'ios') {
return (
<View style={containerStyle}>
<NativeMRZScannerView
ref={viewRef}
style={{
width: '100%',
height: '100%',
}}
onPassportRead={handleMRZDetected}
onError={handleError}
/>
</View>
);
} else {
return (
<View style={containerStyle}>
<RCTFragment
RCTFragmentViewManager={NativeMRZScannerView as any}
fragmentComponentName="PassportOCRViewManager"
isMounted={true}
style={{
height: PixelRatio.getPixelSizeForLayoutSize(800),
width: PixelRatio.getPixelSizeForLayoutSize(800),
}}
onError={handleError}
onPassportRead={handleMRZDetected}
/>
</View>
);
}
};
// TODO Check this
const styles = StyleSheet.create({
container: {
width: '100%',
minHeight: 200,
aspectRatio: 1,
},
scanner: {
flex: 1,
width: '100%',
height: '100%',
},
});
export const SelfMRZScannerModule = NativeModules.SelfMRZScannerModule;

View File

@@ -0,0 +1,68 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { useEffect, useRef } from 'react';
import type { NativeSyntheticEvent, requireNativeComponent } from 'react-native';
import { findNodeHandle, UIManager } from 'react-native';
export interface FragmentProps {
isMounted: boolean;
}
export interface RCTFragmentViewManagerProps {
RCTFragmentViewManager: ReturnType<typeof requireNativeComponent>;
fragmentComponentName: string;
isMounted: boolean;
style: {
width: number;
height: number;
};
onError: (
event: NativeSyntheticEvent<{
error: string;
errorMessage: string;
stackTrace: string;
}>,
) => void;
onPassportRead?: (event: any) => void;
}
function dispatchCommand(fragmentComponentName: string, viewId: number, command: 'create' | 'destroy') {
try {
UIManager.dispatchViewManagerCommand(
viewId,
UIManager.getViewManagerConfig(fragmentComponentName).Commands[command].toString(),
[viewId],
);
} catch (e) {
// Error creating the fragment
// TODO: assert this only happens in dev mode when the fragment is already mounted
console.warn(e);
if (command === 'create') {
dispatchCommand(fragmentComponentName, viewId, 'destroy');
}
}
}
export const RCTFragment = ({
RCTFragmentViewManager,
fragmentComponentName,
isMounted,
...props
}: RCTFragmentViewManagerProps) => {
const ref = useRef(null);
useEffect(() => {
const viewId = findNodeHandle(ref.current);
if (!viewId) {
return;
}
if (isMounted) {
dispatchCommand(fragmentComponentName, viewId, 'create');
} else {
dispatchCommand(fragmentComponentName, viewId, 'destroy');
}
}, [ref, fragmentComponentName, isMounted]);
return <RCTFragmentViewManager ref={ref} {...props} />;
};

View File

@@ -0,0 +1,5 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export { MRZScannerView } from './MRZScannerView';

View File

@@ -5,16 +5,31 @@
import { useCallback } from 'react';
import { Button, Text, YStack } from 'tamagui';
import { getSKIPEM } from '@selfxyz/common/utils/csca';
import { initPassportDataParsing } from '@selfxyz/common/utils/passports';
import { useSelfClient } from '../../context';
import { MRZInfo, ScanResultNFC } from '../../types/public';
import type { ScreenProps } from '../../types/ui';
export const NFCScannerScreen = ({ onSuccess, onFailure }: ScreenProps) => {
//TODO:question - Should we pass mrzData through internal state (from PassportCameraScreen) or take it from the user?
export const NFCScannerScreen = ({ onSuccess, onFailure, mrzData }: ScreenProps & { mrzData: MRZInfo }) => {
const client = useSelfClient();
const onNFCScan = useCallback(
async (_nfcData: any) => {
try {
// scan the document
const scanResult = await client.scanDocument({
mode: 'nfc',
passportNumber: mrzData.documentNumber,
dateOfBirth: mrzData.dateOfBirth,
dateOfExpiry: mrzData.dateOfExpiry,
});
const skiPem = await getSKIPEM('production');
const _parsedPassportData = initPassportDataParsing((scanResult as ScanResultNFC).passportData, skiPem);
// register the document
onSuccess();
} catch (error) {

View File

@@ -5,6 +5,7 @@
import { Button, Text, YStack } from 'tamagui';
import type { PassportCameraProps } from '../../types/ui';
import { MRZScannerView } from '../MRZScannerView';
// Simple placeholder component - this would be replaced with actual camera UI
export const PassportCameraScreen = ({ onMRZDetected }: PassportCameraProps) => (
@@ -12,10 +13,12 @@ export const PassportCameraScreen = ({ onMRZDetected }: PassportCameraProps) =>
<Text fontSize="$6" fontWeight="bold">
Passport Camera
</Text>
<MRZScannerView onMRZDetected={onMRZDetected} />
<Button
onPress={() =>
onMRZDetected({
passportNumber: 'L898902C3',
documentNumber: 'L898902C3',
dateOfBirth: '740812',
dateOfExpiry: '120415',
issuingCountry: 'UTO',

View File

@@ -99,6 +99,8 @@ export { mergeConfig } from './config/merge';
// Document validation
export { parseNFCResponse, scanNFC } from './nfc';
export { reactNativeScannerAdapter } from './adapters/react-native/scanner';
export { scanQRProof } from './qr';
// Error handling

View File

@@ -62,6 +62,8 @@ function validateTD3Format(lines: string[]): boolean {
}
function validateTD1Format(lines: string[]): boolean {
console.log('validateTD1Format', lines);
const concatenatedLines = lines[0] + lines[1];
const TD1_REGEX =
/^(?<documentType>[A-Z0-9<]{2})(?<issuingCountry>[A-Z<]{3})(?<documentNumber>[A-Z0-9<]{9})(?<checkDigitDocumentNumber>[0-9]{1})(?<optionalData1>[A-Z0-9<]{15})(?<dateOfBirth>[0-9]{6})(?<checkDigitDateOfBirth>[0-9]{1})(?<sex>[MF<]{1})(?<dateOfExpiry>[0-9]{6})(?<checkDigitDateOfExpiry>[0-9]{1})(?<nationality>[A-Z<]{3})(?<optionalData2>[A-Z0-9<]{7})/;
@@ -86,7 +88,7 @@ function extractTD3Info(lines: string[]): Omit<MRZInfo, 'validation'> {
.replace(/[^A-Z]/g, '');
// Line 2: PASSPORT(9)CHECK(1)NATIONALITY(3)DOB(6)DOBCHECK(1)SEX(1)EXPIRY(6)EXPIRYCHECK(1)OPTIONAL(7)FINALCHECK(1)
const passportNumber = line2.slice(0, 9).replace(/</g, '');
const documentNumber = line2.slice(0, 9).replace(/</g, '');
// Robust nationality extraction: scan 4-character window for three contiguous A-Z letters
const rawNat = line2.slice(10, 14);
@@ -111,7 +113,7 @@ function extractTD3Info(lines: string[]): Omit<MRZInfo, 'validation'> {
return {
documentType,
issuingCountry,
passportNumber,
documentNumber,
dateOfBirth,
dateOfExpiry,
};
@@ -126,7 +128,7 @@ function extractTD1Info(lines: string[]): Omit<MRZInfo, 'validation'> {
return {
documentType: concatenatedLines.slice(0, 2),
issuingCountry: concatenatedLines.slice(2, 5),
passportNumber: concatenatedLines.slice(5, 14).replace(/</g, '').trim(),
documentNumber: concatenatedLines.slice(5, 14).replace(/</g, '').trim(),
dateOfBirth: concatenatedLines.slice(30, 36),
dateOfExpiry: concatenatedLines.slice(38, 44),
};

View File

@@ -2,9 +2,10 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { DocumentCatalog, PassportData } from '@selfxyz/common/utils/types';
import type { DocumentCatalog, PassportData } from '@selfxyz/common/utils/types';
export type { PassportValidationCallbacks } from '../validation/document';
export type { DocumentCatalog, PassportData };
export interface Config {
endpoints?: { api?: string; teeWs?: string; artifactsCdn?: string };
timeouts?: {
@@ -25,12 +26,12 @@ export interface HttpAdapter {
fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;
}
export interface MRZInfo {
passportNumber: string;
documentNumber: string;
dateOfBirth: string;
dateOfExpiry: string;
issuingCountry: string;
documentType: string;
validation: MRZValidation;
validation?: MRZValidation; //TODO - not available in IOS currentlt
}
/** * Generic reasons:
@@ -141,21 +142,39 @@ export interface SDKEventMap {
export type SDKEvent = keyof SDKEventMap;
export type ScanMode = 'mrz' | 'nfc' | 'qr';
export interface ScanOpts {
mode: ScanMode;
}
export type ScanResult =
export type ScanOpts =
| { mode: 'mrz' }
| {
mode: 'mrz';
mode: 'nfc';
passportNumber: string;
dateOfBirth: string;
dateOfExpiry: string;
issuingCountry?: string;
// Extended MRZ data when available
mrzInfo?: MRZInfo;
canNumber?: string;
skipPACE?: boolean;
skipCA?: boolean;
extendedMode?: boolean;
usePacePolling?: boolean;
}
| { mode: 'nfc'; raw: unknown }
| { mode: 'qr'; data: string };
| { mode: 'qr' };
export type ScanResultNFC = {
mode: 'nfc';
passportData: PassportData;
};
export type ScanResultMRZ = {
mode: 'mrz';
mrzInfo: MRZInfo;
};
export type ScanResultQR = {
mode: 'qr';
data: string;
};
export type ScanResult = ScanResultMRZ | ScanResultNFC | ScanResultQR;
export interface ScannerAdapter {
scan(opts: ScanOpts & { signal?: AbortSignal }): Promise<ScanResult>;
}

View File

@@ -20,8 +20,8 @@ describe('createSelfClient API', () => {
const client = createSelfClient({ config: {}, adapters: mockAdapters });
const info = client.extractMRZInfo(sampleMRZ);
expect(info.passportNumber).toBe(expectedMRZResult.passportNumber);
expect(info.validation.overall).toBe(expectedMRZResult.validation.overall);
expect(info.documentNumber).toBe(expectedMRZResult.documentNumber);
expect(info.validation?.overall).toBe(expectedMRZResult.validation.overall);
});
it('accepts different adapter configurations', () => {
@@ -42,6 +42,6 @@ describe('createSelfClient API', () => {
it('flags invalid check digits', () => {
const client = createSelfClient({ config: {}, adapters: mockAdapters });
const info = client.extractMRZInfo(badCheckDigitsMRZ);
expect(info.validation.overall).toBe(false);
expect(info.validation?.overall).toBe(false);
});
});

View File

@@ -107,8 +107,8 @@ describe('createSelfClient', () => {
const client = createSelfClient({ config: {}, adapters: { scanner, network, crypto, documents, auth } });
const sample = `P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<\nL898902C36UTO7408122F1204159ZE184226B<<<<<10`;
const info = client.extractMRZInfo(sample);
expect(info.passportNumber).toBe('L898902C3');
expect(info.validation.overall).toBe(true);
expect(info.documentNumber).toBe('L898902C3');
expect(info.validation?.overall).toBe(true);
});
it('returns stub registration status', async () => {

View File

@@ -33,7 +33,16 @@ const createMockSelfClientWithDocumentsAdapter = (documentsAdapter: DocumentsAda
},
},
scanner: {
scan: async () => ({ mode: 'mrz', passportNumber: '', dateOfBirth: '', dateOfExpiry: '' }),
scan: async () => ({
mode: 'mrz',
mrzInfo: {
documentNumber: '',
dateOfBirth: '',
dateOfExpiry: '',
issuingCountry: '',
documentType: 'passport',
},
}),
},
storage: {
get: async () => null,

View File

@@ -14,7 +14,7 @@ import { render, screen } from '@testing-library/react';
function Consumer() {
const client = useSelfClient();
const info = client.extractMRZInfo(sampleMRZ);
return <span>{info.passportNumber}</span>;
return <span>{info.documentNumber}</span>;
}
describe('SelfMobileSdk Entry Component', () => {
@@ -25,7 +25,7 @@ describe('SelfMobileSdk Entry Component', () => {
</SelfMobileSdk>,
);
expect(screen.getByText(expectedMRZResult.passportNumber)).toBeTruthy();
expect(screen.getByText(expectedMRZResult.documentNumber)).toBeTruthy();
});
it('renders children correctly', () => {

View File

@@ -15,17 +15,19 @@ const sampleTD1 = `IDFRAX4RTBPFW46<<<<<<<<<<<<<<<9007138M3002119ESP6DUMMY<<DUMMY
describe('extractMRZInfo', () => {
it('parses valid TD3 MRZ', () => {
const info = extractMRZInfo(sample);
expect(info.passportNumber).toBe('L898902C3');
expect(info.validation.overall).toBe(true);
expect(info.documentNumber).toBe('L898902C3');
expect(info.validation).toBeDefined();
expect(info.validation?.overall).toBe(true);
});
it('parses valid TD1 MRZ', () => {
const info = extractMRZInfo(sampleTD1);
expect(info.passportNumber).toBe('X4RTBPFW4');
expect(info.documentNumber).toBe('X4RTBPFW4');
expect(info.issuingCountry).toBe('FRA');
expect(info.dateOfBirth).toBe('900713');
expect(info.dateOfExpiry).toBe('300211');
expect(info.validation.overall).toBe(true);
expect(info.validation).toBeDefined();
expect(info.validation?.overall).toBe(true);
});
it('rejects invalid TD1 MRZ', () => {
@@ -36,7 +38,28 @@ describe('extractMRZInfo', () => {
it('Fails overall validation for invalid TD1 MRZ', () => {
const invalid = `IDFRAX4RTBPFW46`;
const info = extractMRZInfo(invalid);
expect(info.validation.overall).toBe(false);
expect(info.validation).toBeDefined();
expect(info.validation?.overall).toBe(false);
});
it('parses valid TD1 MRZ', () => {
const info = extractMRZInfo(sampleTD1);
expect(info.documentNumber).toBe('X4RTBPFW4');
expect(info.issuingCountry).toBe('FRA');
expect(info.dateOfBirth).toBe('900713');
expect(info.dateOfExpiry).toBe('300211');
expect(info.validation?.overall).toBe(true);
});
it('rejects invalid TD1 MRZ', () => {
const invalid = `FRAX4RTBPFW46`;
expect(() => extractMRZInfo(invalid)).toThrow();
});
it('Fails overall validation for invalid TD1 MRZ', () => {
const invalid = `IDFRAX4RTBPFW46`;
const info = extractMRZInfo(invalid);
expect(info.validation?.overall).toBe(false);
});
it('rejects malformed MRZ', () => {
@@ -48,7 +71,8 @@ describe('extractMRZInfo', () => {
const bad = `P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<11`;
const info = extractMRZInfo(bad);
expect(info.validation.overall).toBe(false);
expect(info.validation).toBeDefined();
expect(info.validation?.overall).toBe(false);
});
});

View File

@@ -23,8 +23,9 @@ describe('SelfClientProvider Context', () => {
const { result } = renderHook(() => useSelfClient(), { wrapper });
const info = result.current.extractMRZInfo(sampleMRZ);
expect(info.passportNumber).toBe(expectedMRZResult.passportNumber);
expect(info.validation.overall).toBe(expectedMRZResult.validation.overall);
expect(info.documentNumber).toBe(expectedMRZResult.documentNumber);
expect(info.validation).toBeDefined();
expect(info.validation?.overall).toBe(expectedMRZResult.validation.overall);
});
it('throws error when used outside provider', () => {

View File

@@ -4,7 +4,7 @@
/**
* Vitest setup file for mobile-sdk-alpha tests
* Reduces console noise during testing
* Reduces console noise during testing and mocks React Native modules
*/
const originalConsole = {
@@ -31,6 +31,128 @@ if (typeof global !== 'undefined') {
};
}
// Mock React Native modules
vi.mock('react-native', () => ({
Platform: {
OS: 'web',
select: (obj: any) => obj.web || obj.default,
},
NativeModules: {
SelfMRZScannerModule: {
startScanning: vi.fn(),
},
PassportReader: {
scanPassport: vi.fn(),
},
},
requireNativeComponent: vi.fn(() => 'div'),
StyleSheet: {
create: vi.fn(styles => styles),
},
Image: 'div',
Text: 'span',
View: 'div',
TouchableOpacity: 'button',
ScrollView: 'div',
FlatList: 'div',
TextInput: 'input',
Switch: 'input',
Modal: 'div',
Alert: {
alert: vi.fn(),
},
Linking: {
openURL: vi.fn(),
},
Dimensions: {
get: vi.fn(() => ({ width: 375, height: 667 })),
},
StatusBar: {
setBarStyle: vi.fn(),
},
BackHandler: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
AppState: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
PermissionsAndroid: {
request: vi.fn(),
PERMISSIONS: {},
RESULTS: {},
},
Vibration: {
vibrate: vi.fn(),
},
Clipboard: {
setString: vi.fn(),
getString: vi.fn(),
},
Share: {
share: vi.fn(),
},
ToastAndroid: {
show: vi.fn(),
},
Keyboard: {
dismiss: vi.fn(),
},
InteractionManager: {
runAfterInteractions: vi.fn(callback => callback()),
},
LayoutAnimation: {
configureNext: vi.fn(),
},
UIManager: {
measure: vi.fn(),
},
findNodeHandle: vi.fn(),
createRef: vi.fn(),
forwardRef: vi.fn(),
useRef: vi.fn(),
useState: vi.fn(),
useEffect: vi.fn(),
useCallback: vi.fn(),
useMemo: vi.fn(),
useImperativeHandle: vi.fn(),
useLayoutEffect: vi.fn(),
useReducer: vi.fn(),
useContext: vi.fn(),
useDebugValue: vi.fn(),
useId: vi.fn(),
useSyncExternalStore: vi.fn(),
useTransition: vi.fn(),
useDeferredValue: vi.fn(),
useInsertionEffect: vi.fn(),
Children: {
map: vi.fn(),
forEach: vi.fn(),
count: vi.fn(),
toArray: vi.fn(),
only: vi.fn(),
},
cloneElement: vi.fn(),
isValidElement: vi.fn(),
createElement: vi.fn(),
Fragment: 'div',
StrictMode: 'div',
Suspense: 'div',
createContext: vi.fn(),
lazy: vi.fn(),
memo: vi.fn(),
startTransition: vi.fn(),
use: vi.fn(),
cache: vi.fn(),
experimental_use: vi.fn(),
experimental_useOptimistic: vi.fn(),
experimental_useActionState: vi.fn(),
experimental_useFormStatus: vi.fn(),
experimental_useFormState: vi.fn(),
experimental_useCacheRefresh: vi.fn(),
}));
// Mock window.matchMedia for Tamagui components
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'matchMedia', {

View File

@@ -13,7 +13,16 @@ export const badCheckDigitsMRZ = sampleMRZ.slice(0, -1) + '1';
// Shared mock adapters
export const mockScanner: ScannerAdapter = {
scan: async () => ({ mode: 'mrz', passportNumber: '', dateOfBirth: '', dateOfExpiry: '' }),
scan: async () => ({
mode: 'mrz',
mrzInfo: {
documentNumber: '',
dateOfBirth: '',
dateOfExpiry: '',
issuingCountry: '',
documentType: 'passport',
},
}),
};
export const mockNetwork: NetworkAdapter = {
@@ -64,6 +73,6 @@ export const mockAdapters = {
// Shared test expectations
export const expectedMRZResult = {
passportNumber: 'L898902C3',
documentNumber: 'L898902C3',
validation: { overall: true },
};

View File

@@ -23,8 +23,18 @@ export default defineConfig([
outDir: 'dist/esm',
tsconfig: './tsconfig.json',
target: 'es2020',
// preserve license header in output bundles
esbuildOptions: options => {
external: ['react', 'react-native', '@selfxyz/common'],
esbuildOptions(options) {
options.supported = {
...options.supported,
'import-assertions': true,
'import-attributes': true,
};
// Handle React Native's import typeof syntax
options.loader = {
...options.loader,
'.js': 'jsx',
};
// keep comments with SPDX in the final file
options.legalComments = 'eof';
},
@@ -42,14 +52,19 @@ export default defineConfig([
outDir: 'dist/cjs',
tsconfig: './tsconfig.cjs.json',
target: 'es2020',
external: ['react', 'react-native', '@selfxyz/common'],
outExtension: ({ format }) => ({ js: format === 'cjs' ? '.cjs' : '.js' }),
// preserve license header in output bundles
esbuildOptions: options => {
// keep comments with SPDX in the final file
options.legalComments = 'eof';
},
banner: {
js: banner,
esbuildOptions(options) {
options.supported = {
...options.supported,
'import-assertions': true,
'import-attributes': true,
};
// Handle React Native's import typeof syntax
options.loader = {
...options.loader,
'.js': 'jsx',
};
},
},
]);

View File

@@ -11,4 +11,10 @@ export default defineConfig({
setupFiles: ['./tests/setup.ts'],
exclude: ['demo-app/**'],
},
resolve: {
alias: {
// Mock React Native modules for testing
'react-native': 'react-native-web',
},
},
});