mirror of
https://github.com/selfxyz/self.git
synced 2026-01-10 15:18:18 -05:00
[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:
98
packages/mobile-sdk-alpha/.gitignore
vendored
Normal file
98
packages/mobile-sdk-alpha/.gitignore
vendored
Normal 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
|
||||
158
packages/mobile-sdk-alpha/android/build.gradle
Normal file
158
packages/mobile-sdk-alpha/android/build.gradle
Normal 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'
|
||||
}
|
||||
54
packages/mobile-sdk-alpha/android/gradle.properties
Normal file
54
packages/mobile-sdk-alpha/android/gradle.properties
Normal 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
|
||||
BIN
packages/mobile-sdk-alpha/android/libs/jj2000_imageutil.jar
Executable file
BIN
packages/mobile-sdk-alpha/android/libs/jj2000_imageutil.jar
Executable file
Binary file not shown.
55
packages/mobile-sdk-alpha/android/proguard-rules.pro
vendored
Normal file
55
packages/mobile-sdk-alpha/android/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||
@@ -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>
|
||||
BIN
packages/mobile-sdk-alpha/android/src/main/assets/masterList
Normal file
BIN
packages/mobile-sdk-alpha/android/src/main/assets/masterList
Normal file
Binary file not shown.
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
21
packages/mobile-sdk-alpha/android/src/main/res/layout/activity_camera.xml
Executable file
21
packages/mobile-sdk-alpha/android/src/main/res/layout/activity_camera.xml
Executable 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" />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & Starting OCR</string>
|
||||
<string name="warning_error_ocr_install">Error Installing & 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
BIN
packages/mobile-sdk-alpha/demo-app/android/app/debug.keystore
Normal file
BIN
packages/mobile-sdk-alpha/demo-app/android/app/debug.keystore
Normal file
Binary file not shown.
@@ -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);
|
||||
|
||||
@@ -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')],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
53
packages/mobile-sdk-alpha/demo-app/src/utils/ethers.ts
Normal file
53
packages/mobile-sdk-alpha/demo-app/src/utils/ethers.ts
Normal 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 };
|
||||
23
packages/mobile-sdk-alpha/ios/SelfSDK/PassportReader.m
Normal file
23
packages/mobile-sdk-alpha/ios/SelfSDK/PassportReader.m
Normal 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
|
||||
460
packages/mobile-sdk-alpha/ios/SelfSDK/PassportReader.swift
Normal file
460
packages/mobile-sdk-alpha/ios/SelfSDK/PassportReader.swift
Normal 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
|
||||
165
packages/mobile-sdk-alpha/ios/SelfSDK/SelfCameraView.swift
Normal file
165
packages/mobile-sdk-alpha/ios/SelfSDK/SelfCameraView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScanner.swift
Normal file
113
packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScanner.swift
Normal 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
|
||||
}
|
||||
}
|
||||
19
packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerModule.m
Normal file
19
packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerModule.m
Normal 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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 */;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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@-->
|
||||
15
packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.m
Normal file
15
packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.m
Normal 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
|
||||
23
packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.swift
Normal file
23
packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.swift
Normal 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
|
||||
}
|
||||
}
|
||||
41
packages/mobile-sdk-alpha/mobile-sdk-alpha.podspec
Normal file
41
packages/mobile-sdk-alpha/mobile-sdk-alpha.podspec
Normal 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
|
||||
@@ -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",
|
||||
|
||||
30
packages/mobile-sdk-alpha/react-native.config.cjs
Normal file
30
packages/mobile-sdk-alpha/react-native.config.cjs
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
217
packages/mobile-sdk-alpha/src/adapters/react-native/scanner.ts
Normal file
217
packages/mobile-sdk-alpha/src/adapters/react-native/scanner.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
156
packages/mobile-sdk-alpha/src/components/MRZScannerView.tsx
Normal file
156
packages/mobile-sdk-alpha/src/components/MRZScannerView.tsx
Normal 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;
|
||||
68
packages/mobile-sdk-alpha/src/components/RCTFragment.tsx
Normal file
68
packages/mobile-sdk-alpha/src/components/RCTFragment.tsx
Normal 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} />;
|
||||
};
|
||||
5
packages/mobile-sdk-alpha/src/components/index.ts
Normal file
5
packages/mobile-sdk-alpha/src/components/index.ts
Normal 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';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user