diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index 0a29272fb..801e6664c 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -434,8 +434,6 @@ jobs: common/dist packages/mobile-sdk-alpha/dist key: built-deps-${{ env.GH_CACHE_VERSION }}-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('common/**/*', 'packages/mobile-sdk-alpha/**/*', '!common/dist/**', '!packages/mobile-sdk-alpha/dist/**') }} - - name: Build Android - run: | - cd android - ./gradlew assembleDebug + - name: Build Android (with AAPT2 symlink fix) + run: yarn android:ci working-directory: ./app diff --git a/.gitignore b/.gitignore index a70286880..5cfc10170 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,7 @@ output/* *.tsbuildinfo .yarnrc.yml -# PR Action Items - prevent accidental commits -PR-*-ACTION*.md +# CI-generated tarballs (don't commit these!) +mobile-sdk-alpha-ci.tgz +**/mobile-sdk-alpha-*.tgz +/tmp/mobile-sdk-alpha*.tgz diff --git a/app/Gemfile.lock b/app/Gemfile.lock index ce8be091a..73c629152 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -315,4 +315,4 @@ RUBY VERSION ruby 3.2.7p253 BUNDLED WITH - 2.6.9 + 2.4.19 diff --git a/app/android/android-passport-reader/app/src/main/java/example/jllarraz/com/passportreader/ui/fragments/CameraFragment.kt b/app/android/android-passport-reader/app/src/main/java/example/jllarraz/com/passportreader/ui/fragments/CameraFragment.kt index cf1aca864..73146f1e5 100644 --- a/app/android/android-passport-reader/app/src/main/java/example/jllarraz/com/passportreader/ui/fragments/CameraFragment.kt +++ b/app/android/android-passport-reader/app/src/main/java/example/jllarraz/com/passportreader/ui/fragments/CameraFragment.kt @@ -108,39 +108,6 @@ abstract class CameraFragment : androidx.fragment.app.Fragment(), ActivityCompat } - 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() @@ -178,6 +145,37 @@ abstract class CameraFragment : androidx.fragment.app.Fragment(), ActivityCompat } + 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) + } + + 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() + } //////////////////////////////////////////////////////////////////////////////////////// // @@ -246,24 +244,6 @@ abstract class CameraFragment : androidx.fragment.app.Fragment(), ActivityCompat // //////////////////////////////////////////////////////////////////////////////////////// - protected fun hasCameraPermission(): Boolean { - return ContextCompat.checkSelfPermission(context!!, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED - } - - protected fun checkPermissions(permissions: ArrayList = 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()), - REQUEST_PERMISSIONS) - } - } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { @@ -310,6 +290,24 @@ abstract class CameraFragment : androidx.fragment.app.Fragment(), ActivityCompat .show(childFragmentManager, FRAGMENT_DIALOG) } + protected fun hasCameraPermission(): Boolean { + return ContextCompat.checkSelfPermission(context!!, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED + } + + protected fun checkPermissions(permissions: ArrayList = 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()), + REQUEST_PERMISSIONS) + } + } + //////////////////////////////////////////////////////////////////////////////////////// // @@ -317,16 +315,6 @@ abstract class CameraFragment : androidx.fragment.app.Fragment(), ActivityCompat // //////////////////////////////////////////////////////////////////////////////////////// - /** - * 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. */ @@ -402,6 +390,16 @@ abstract class CameraFragment : androidx.fragment.app.Fragment(), ActivityCompat return 0 } + /** + * 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() } + } + companion object { @@ -415,4 +413,4 @@ abstract class CameraFragment : androidx.fragment.app.Fragment(), ActivityCompat private val FRAGMENT_DIALOG = TAG } -} \ No newline at end of file +} diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 1a0e7abb9..11b8e8a17 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -190,7 +190,7 @@ dependencies { exclude group: 'edu.ucar', module: 'jj2000' } implementation project(':passportreader') - implementation 'org.jmrtd:jmrtd:0.7.18' + implementation 'org.jmrtd:jmrtd:0.7.35' implementation 'com.github.blikoon:QRCodeScanner:0.1.2' diff --git a/app/android/app/src/main/java/com/proofofpassportapp/MainActivity.kt b/app/android/app/src/main/java/com/proofofpassportapp/MainActivity.kt index 7af0e9cad..caa9f7736 100644 --- a/app/android/app/src/main/java/com/proofofpassportapp/MainActivity.kt +++ b/app/android/app/src/main/java/com/proofofpassportapp/MainActivity.kt @@ -12,6 +12,7 @@ import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultReactActivityDelegate import io.tradle.nfc.RNPassportReaderModule +import com.selfxyz.selfSDK.RNSelfPassportReaderModule class MainActivity : ReactActivity() { /** diff --git a/app/android/app/src/main/java/com/proofofpassportapp/MainApplication.kt b/app/android/app/src/main/java/com/proofofpassportapp/MainApplication.kt index f30b294d0..25803e540 100644 --- a/app/android/app/src/main/java/com/proofofpassportapp/MainApplication.kt +++ b/app/android/app/src/main/java/com/proofofpassportapp/MainApplication.kt @@ -15,6 +15,7 @@ import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint import com.facebook.react.defaults.DefaultReactNativeHost import com.facebook.soloader.SoLoader import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.selfxyz.selfSDK.RNSelfPassportReaderPackage class MainApplication : Application(), ReactApplication { @@ -25,6 +26,7 @@ class MainApplication : Application(), ReactApplication { add(CameraActivityPackage()) add(QRCodeScannerPackage()) add(BackupPackage()) + // add(RNSelfPassportReaderPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/app/android/app/src/main/java/com/proofofpassportapp/NativeLoggerBridgeModule.kt b/app/android/app/src/main/java/com/proofofpassportapp/NativeLoggerBridgeModule.kt new file mode 100644 index 000000000..55dadb193 --- /dev/null +++ b/app/android/app/src/main/java/com/proofofpassportapp/NativeLoggerBridgeModule.kt @@ -0,0 +1,59 @@ +// 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 + +package com.proofofpassportapp + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.Arguments +import com.facebook.react.modules.core.DeviceEventManagerModule + +class NativeLoggerBridgeModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String { + return "NativeLoggerBridge" + } + + companion object { + private fun sendLogEvent(context: ReactApplicationContext, level: String, category: String, message: String, data: Map? = null) { + val params: WritableMap = Arguments.createMap().apply { + putString("level", level) + putString("category", category) + putString("message", message) + if (data != null && data.isNotEmpty()) { + val dataMap = Arguments.createMap() + data.forEach { (key, value) -> + when (value) { + is String -> dataMap.putString(key, value) + is Int -> dataMap.putInt(key, value) + is Double -> dataMap.putDouble(key, value) + is Boolean -> dataMap.putBoolean(key, value) + else -> dataMap.putString(key, value.toString()) + } + } + putMap("data", dataMap) + } + } + + context + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("logEvent", params) + } + + fun logDebug(context: ReactApplicationContext, category: String, message: String, data: Map? = null) { + sendLogEvent(context, "debug", category, message, data) + } + + fun logInfo(context: ReactApplicationContext, category: String, message: String, data: Map? = null) { + sendLogEvent(context, "info", category, message, data) + } + + fun logWarn(context: ReactApplicationContext, category: String, message: String, data: Map? = null) { + sendLogEvent(context, "warn", category, message, data) + } + + fun logError(context: ReactApplicationContext, category: String, message: String, data: Map? = null) { + sendLogEvent(context, "error", category, message, data) + } + } +} \ No newline at end of file diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 0baf089ff..a664acfb3 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -173,6 +173,10 @@ PODS: - Mixpanel-swift (5.0.0): - Mixpanel-swift/Complete (= 5.0.0) - Mixpanel-swift/Complete (5.0.0) + - mobile-sdk-alpha (0.1.0): + - NFCPassportReader + - QKMRZParser + - React-Core - nanopb (2.30910.0): - nanopb/decode (= 2.30910.0) - nanopb/encode (= 2.30910.0) @@ -2115,6 +2119,7 @@ DEPENDENCIES: - lottie-ios - lottie-react-native (from `../../node_modules/lottie-react-native`) - Mixpanel-swift (~> 5.0.0) + - "mobile-sdk-alpha (from `../../node_modules/@selfxyz/mobile-sdk-alpha`)" - NFCPassportReader (from `https://github.com/seshanthS/NFCPassportReader`, commit `9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b`) - QKMRZScanner - RCT-Folly (from `../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) @@ -2247,6 +2252,8 @@ EXTERNAL SOURCES: :tag: hermes-2024-11-12-RNv0.76.2-5b4aa20c719830dcf5684832b89a6edb95ac3d64 lottie-react-native: :path: "../../node_modules/lottie-react-native" + mobile-sdk-alpha: + :path: "../../node_modules/@selfxyz/mobile-sdk-alpha" NFCPassportReader: :commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b :git: https://github.com/seshanthS/NFCPassportReader @@ -2447,6 +2454,7 @@ SPEC CHECKSUMS: lottie-ios: a881093fab623c467d3bce374367755c272bdd59 lottie-react-native: 7bb65bc88d3f9996ea2f646a96694285405df2f9 Mixpanel-swift: e9bef28a9648faff384d5ba6f48ecc2787eb24c0 + mobile-sdk-alpha: 96949ad8c8b61a9fa6b918a4202f9cebb9c678cc nanopb: 438bc412db1928dac798aa6fd75726007be04262 NFCPassportReader: 48873f856f91215dbfa1eaaec20eae639672862e OpenSSL-Universal: 84efb8a29841f2764ac5403e0c4119a28b713346 diff --git a/app/jest.config.cjs b/app/jest.config.cjs index 56f1d5bd3..974e4060a 100644 --- a/app/jest.config.cjs +++ b/app/jest.config.cjs @@ -6,7 +6,7 @@ module.exports = { preset: 'react-native', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], transformIgnorePatterns: [ - 'node_modules/(?!(react-native|@react-native|@react-navigation|@react-native-community|@segment/analytics-react-native|@openpassport|react-native-keychain|react-native-check-version|react-native-nfc-manager|react-native-passport-reader|react-native-gesture-handler|uuid|@stablelib|@react-native-google-signin|react-native-cloud-storage|@react-native-clipboard|@react-native-firebase)/)', + 'node_modules/(?!(react-native|@react-native|@react-navigation|@react-native-community|@segment/analytics-react-native|@openpassport|react-native-keychain|react-native-check-version|react-native-nfc-manager|react-native-passport-reader|react-native-gesture-handler|uuid|@stablelib|@react-native-google-signin|react-native-cloud-storage|@react-native-clipboard|@react-native-firebase|@selfxyz)/)', ], setupFiles: ['/jest.setup.js'], testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$', diff --git a/app/package.json b/app/package.json index 8ecd9a68b..ae1220292 100644 --- a/app/package.json +++ b/app/package.json @@ -9,6 +9,7 @@ "analyze:tree-shaking": "node ./scripts/analyze-tree-shaking.cjs imports", "analyze:tree-shaking:web": "yarn web:build && node ./scripts/analyze-tree-shaking.cjs web", "android": "yarn build:deps && react-native run-android", + "android:ci": "./scripts/mobile-ci-build-android.sh", "build:deps": "yarn workspaces foreach --from @selfxyz/mobile-app --topological --recursive run build", "bump-version:major": "npm version major && yarn sync-versions", "bump-version:minor": "npm version minor && yarn sync-versions", @@ -57,7 +58,7 @@ "test:build": "yarn build:deps && yarn types && node ./scripts/bundle-analyze-ci.cjs ios && yarn test", "test:coverage": "jest --coverage --passWithNoTests", "test:coverage:ci": "jest --coverage --passWithNoTests --ci --coverageReporters=lcov --coverageReporters=text --coverageReporters=json", - "test:e2e:android": "cd android && ./gradlew assembleDebug && cd .. && maestro test tests/e2e/launch.android.flow.yaml", + "test:e2e:android": "./scripts/mobile-ci-build-android.sh && maestro test tests/e2e/launch.android.flow.yaml", "test:e2e:ios": "xcodebuild -workspace ios/OpenPassport.xcworkspace -scheme OpenPassport -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build && maestro test tests/e2e/launch.ios.flow.yaml", "test:fastlane": "bundle exec ruby -Itest fastlane/test/helpers_test.rb", "test:tree-shaking": "node ./scripts/test-tree-shaking.cjs", diff --git a/app/scripts/mobile-ci-build-android.sh b/app/scripts/mobile-ci-build-android.sh new file mode 100755 index 000000000..68458676b --- /dev/null +++ b/app/scripts/mobile-ci-build-android.sh @@ -0,0 +1,207 @@ +#!/bin/bash +# Mobile CI Build Android Script +# Fixes AAPT2 symlink issue by installing SDK as tarball, then builds Android +# Includes CI-worthy error handling and environment detection + +set -e + +# Detect CI environment (similar to run-patch-package.cjs) +is_ci() { + [[ "${CI:-}" == "true" ]] || \ + [[ "${GITHUB_ACTIONS:-}" == "true" ]] || \ + [[ "${CIRCLECI:-}" == "true" ]] || \ + [[ "${TRAVIS:-}" == "true" ]] || \ + [[ "${BUILDKITE:-}" == "true" ]] || \ + [[ "${GITLAB_CI:-}" == "true" ]] || \ + [[ -n "${JENKINS_URL:-}" ]] +} + +# Logging function +log() { + if is_ci; then + echo "mobile-ci-build-android: $1 (CI mode)" + else + echo "🤖 $1" + fi +} + +# Error handling with cleanup +handle_error() { + local exit_code=$? + log "ERROR: Command failed with exit code $exit_code" + + # Attempt cleanup on error + if [[ -f "/tmp/mobile-sdk-alpha-ci.tgz" ]]; then + log "Cleaning up tarball on error..." + rm -f "/tmp/mobile-sdk-alpha-ci.tgz" + fi + + # Clean up lock file + rm -f "/tmp/mobile-ci-build-android.lock" 2>/dev/null || true + + # Attempt to restore backup package files if they exist + if [[ -f "package.json.backup" ]] && [[ -f "../yarn.lock.backup" ]]; then + log "Restoring backup package files on error..." + mv package.json.backup package.json 2>/dev/null || true + mv ../yarn.lock.backup ../yarn.lock 2>/dev/null || true + elif [[ -f "package.json" ]] && grep -q "mobile-sdk-alpha.*file:/tmp" package.json 2>/dev/null; then + log "WARNING: Package files modified but no backup found - manual fix required" + log "Please run 'yarn add @selfxyz/mobile-sdk-alpha@workspace:^' to restore" + fi + + if is_ci; then + log "Build failed during Android CI setup" + fi + exit $exit_code +} + +trap handle_error ERR + +log "Starting Mobile CI Build Android - Fixing AAPT2 symlink issue..." + +# Early exit if not in expected directory structure +if [[ ! -d "$(dirname "$0")/../../packages/mobile-sdk-alpha" ]]; then + log "ERROR: mobile-sdk-alpha package not found in expected location" + exit 1 +fi + +# Check for and clean up any existing backup files (from previous failed runs) +if [[ -f "app/package.json.backup" ]] || [[ -f "yarn.lock.backup" ]]; then + log "WARNING: Found existing backup files from previous run - cleaning up..." + rm -f app/package.json.backup yarn.lock.backup +fi + +# Check if another instance is running +LOCK_FILE="/tmp/mobile-ci-build-android.lock" +if [[ -f "$LOCK_FILE" ]]; then + log "ERROR: Another instance of this script is already running (lock file exists)" + log "If you're sure no other instance is running, remove: $LOCK_FILE" + exit 1 +fi +echo $$ > "$LOCK_FILE" +trap 'rm -f "$LOCK_FILE"' EXIT + +# Go to project root +PROJECT_ROOT="$(dirname "$0")/../.." +cd "$PROJECT_ROOT" + +log "Working directory: $(pwd)" + +# Build and package the SDK with timeout +log "Building SDK..." +if is_ci; then + timeout 300 yarn workspace @selfxyz/mobile-sdk-alpha build || { + log "SDK build timed out after 5 minutes" + exit 1 + } +else + yarn workspace @selfxyz/mobile-sdk-alpha build +fi + +log "Creating SDK tarball..." +TARBALL_PATH="/tmp/mobile-sdk-alpha-ci.tgz" +if is_ci; then + timeout 60 yarn workspace @selfxyz/mobile-sdk-alpha pack --out "$TARBALL_PATH" || { + log "SDK packaging timed out after 1 minute" + exit 1 + } +else + yarn workspace @selfxyz/mobile-sdk-alpha pack --out "$TARBALL_PATH" +fi + +# Verify tarball was created +if [[ ! -f "$TARBALL_PATH" ]]; then + log "ERROR: SDK tarball was not created at $TARBALL_PATH" + exit 1 +fi + +# Backup package.json and yarn.lock before modification +log "Backing up package files..." +cd app + +# Ensure we can create backups +if [[ ! -f "package.json" ]]; then + log "ERROR: package.json not found in app directory" + exit 1 +fi +if [[ ! -f "../yarn.lock" ]]; then + log "ERROR: yarn.lock not found in project root" + exit 1 +fi + +# Create backups with error checking +cp package.json package.json.backup || { + log "ERROR: Failed to backup package.json" + exit 1 +} +cp ../yarn.lock ../yarn.lock.backup || { + log "ERROR: Failed to backup yarn.lock" + exit 1 +} +log "✅ Package files backed up successfully" + +# Install SDK from tarball in app with timeout +log "Installing SDK as real files..." +if is_ci; then + timeout 180 yarn add "@selfxyz/mobile-sdk-alpha@file:$TARBALL_PATH" || { + log "SDK installation timed out after 3 minutes" + exit 1 + } +else + yarn add "@selfxyz/mobile-sdk-alpha@file:$TARBALL_PATH" +fi + +# Verify installation (check both local and hoisted locations) +SDK_ANDROID_PATH="" +if [[ -d "node_modules/@selfxyz/mobile-sdk-alpha/android/src/main/res" ]]; then + SDK_ANDROID_PATH="node_modules/@selfxyz/mobile-sdk-alpha/android/src/main/res" +elif [[ -d "../node_modules/@selfxyz/mobile-sdk-alpha/android/src/main/res" ]]; then + SDK_ANDROID_PATH="../node_modules/@selfxyz/mobile-sdk-alpha/android/src/main/res" +else + log "ERROR: SDK Android resources not found after installation" + log "Checked: node_modules/@selfxyz/mobile-sdk-alpha/android/src/main/res" + log "Checked: ../node_modules/@selfxyz/mobile-sdk-alpha/android/src/main/res" + exit 1 +fi + +log "SDK Android resources found at: $SDK_ANDROID_PATH" + +# Build Android APK (don't install to device) +log "Building Android APK..." +if is_ci; then + # Build APK only for CI (no device installation) + timeout 1800 ./android/gradlew assembleDebug -p android || { + log "Android APK build timed out after 30 minutes" + exit 1 + } +else + # For local development, build APK only + ./android/gradlew assembleDebug -p android || { + log "Android APK build failed" + exit 1 + } +fi + +# Cleanup tarball and restore workspace dependency +log "Cleaning up..." + +# Remove temporary tarball +if [[ -f "$TARBALL_PATH" ]]; then + rm -f "$TARBALL_PATH" + log "Cleaned up temporary tarball" +fi + +# Restore original package files +log "Restoring original package files..." +if [[ -f "package.json.backup" ]] && [[ -f "../yarn.lock.backup" ]]; then + mv package.json.backup package.json + mv ../yarn.lock.backup ../yarn.lock + log "✅ Package files restored successfully" +else + log "WARNING: Backup files not found - package.json may still reference tarball" + log "Please run 'yarn add @selfxyz/mobile-sdk-alpha@workspace:^' manually" +fi + +log "Mobile CI Build Android completed successfully!" + +exit 0 diff --git a/app/src/components/native/PassportCamera.tsx b/app/src/components/native/PassportCamera.tsx index 476a347f8..400fc9d04 100644 --- a/app/src/components/native/PassportCamera.tsx +++ b/app/src/components/native/PassportCamera.tsx @@ -99,7 +99,7 @@ export const PassportCamera: React.FC = ({ onPassportRead(null, selfClient.extractMRZInfo(event.nativeEvent.data)); } else { onPassportRead(null, { - passportNumber: event.nativeEvent.data.documentNumber, + documentNumber: event.nativeEvent.data.documentNumber, dateOfBirth: event.nativeEvent.data.birthDate, dateOfExpiry: event.nativeEvent.data.expiryDate, documentType: event.nativeEvent.data.documentType, diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index 13294094f..e856df603 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -3,14 +3,16 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import { type PropsWithChildren, useMemo } from 'react'; +import { Platform } from 'react-native'; import { Adapters, + reactNativeScannerAdapter, SelfClientProvider as SDKSelfClientProvider, + type TrackEventParams, webScannerShim, type WsConn, } from '@selfxyz/mobile-sdk-alpha'; -import { TrackEventParams } from '@selfxyz/mobile-sdk-alpha'; import { unsafe_getPrivateKey } from '@/providers/authProvider'; import { selfClientDocumentsAdapter } from '@/providers/passportDataProvider'; @@ -30,7 +32,8 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { const config = useMemo(() => ({}), []); const adapters: Adapters = useMemo( () => ({ - scanner: webScannerShim, + scanner: + Platform.OS === 'web' ? webScannerShim : reactNativeScannerAdapter, network: { http: { fetch: (input: RequestInfo, init?: RequestInit) => fetch(input, init), diff --git a/app/src/screens/document/DocumentCameraScreen.tsx b/app/src/screens/document/DocumentCameraScreen.tsx index d151b2bdb..521917152 100644 --- a/app/src/screens/document/DocumentCameraScreen.tsx +++ b/app/src/screens/document/DocumentCameraScreen.tsx @@ -72,7 +72,7 @@ const DocumentCameraScreen: React.FC = () => { } const { - passportNumber, + documentNumber, dateOfBirth, dateOfExpiry, documentType, @@ -86,14 +86,14 @@ const DocumentCameraScreen: React.FC = () => { if ( !checkScannedInfo( - passportNumber, + documentNumber, formattedDateOfBirth, formattedDateOfExpiry, ) ) { trackEvent(PassportEvents.CAMERA_SCAN_FAILED, { reason: 'invalid_format', - passportNumberLength: passportNumber.length, + passportNumberLength: documentNumber.length, dateOfBirthLength: formattedDateOfBirth.length, dateOfExpiryLength: formattedDateOfExpiry.length, duration_seconds: parseFloat(scanDurationSeconds), @@ -103,7 +103,7 @@ const DocumentCameraScreen: React.FC = () => { } store.update({ - passportNumber, + passportNumber: documentNumber, dateOfBirth: formattedDateOfBirth, dateOfExpiry: formattedDateOfExpiry, documentType: documentType?.trim() || '', diff --git a/app/tests/src/components/PassportCamera.test.tsx b/app/tests/src/components/PassportCamera.test.tsx index 1808ee13b..3ac062b69 100644 --- a/app/tests/src/components/PassportCamera.test.tsx +++ b/app/tests/src/components/PassportCamera.test.tsx @@ -35,7 +35,7 @@ describe('PassportCamera components', () => { render(); const mrz = `P { const obj = { documentNumber: '123456789', - expiryDate: '240101', birthDate: '900101', - documentType: 'P', + expiryDate: '240101', countryCode: 'UTO', + documentType: 'P', }; nativeProps.onPassportRead({ nativeEvent: { data: obj } }); @@ -64,7 +64,7 @@ describe('PassportCamera components', () => { expect(onPassportRead).toHaveBeenCalledWith( null, expect.objectContaining({ - passportNumber: '123456789', + documentNumber: '123456789', dateOfExpiry: '240101', dateOfBirth: '900101', documentType: 'P', diff --git a/app/tests/src/utils/proving/validateDocument.test.ts b/app/tests/src/utils/proving/validateDocument.test.ts index 125b883c6..d5d438991 100644 --- a/app/tests/src/utils/proving/validateDocument.test.ts +++ b/app/tests/src/utils/proving/validateDocument.test.ts @@ -183,8 +183,9 @@ describe('validateDocument - Real mobile-sdk-alpha Integration (PII-safe)', () = it('parses a valid MRZ string', () => { const client = createTestClient(); const info = client.extractMRZInfo(validMrz); - 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('throws on malformed MRZ input', () => { diff --git a/app/tests/web-build-render.test.ts b/app/tests/web-build-render.test.ts index 3f0b2d1f8..28dc4f274 100644 --- a/app/tests/web-build-render.test.ts +++ b/app/tests/web-build-render.test.ts @@ -6,9 +6,23 @@ * @jest-environment node */ +// Override global error handling to prevent circular references import { execSync, spawn } from 'child_process'; import { afterAll, beforeAll, describe, expect, test } from '@jest/globals'; +const originalError = global.Error; +global.Error = class SafeError extends originalError { + constructor(...args: any[]) { + super(...args); + // Ensure no circular references are added to error objects + Object.defineProperty(this, 'error', { + value: undefined, + writable: false, + enumerable: false, + }); + } +}; + // Ensure fetch is available (Node.js 18+ has built-in fetch) if (typeof fetch === 'undefined') { throw new Error( @@ -90,7 +104,10 @@ describe('Web Build and Render', () => { previewProcess?.on('error', error => { clearTimeout(timeout); - reject(new Error(`Preview server process error: ${error.message}`)); + // Avoid circular references by only using the error message string + const errorMessage = + error?.message || error?.toString() || 'Unknown error'; + reject(new Error(`Preview server process error: ${errorMessage}`)); }); previewProcess?.on('exit', (code, _signal) => { @@ -119,7 +136,10 @@ describe('Web Build and Render', () => { previewProcess.kill('SIGKILL'); } } catch (error) { - console.error('Error killing preview process:', error); + // Safely log error without circular references + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error('Error killing preview process:', errorMessage); } } }); diff --git a/circuits/tests/dsc/test_cases.ts b/circuits/tests/dsc/test_cases.ts index 4c88537b0..cb1fcea3a 100644 --- a/circuits/tests/dsc/test_cases.ts +++ b/circuits/tests/dsc/test_cases.ts @@ -1,9 +1,9 @@ export const sigAlgs = [ - // { sigAlg: 'rsa', hashFunction: 'sha1', domainParameter: '65537', keyLength: '2048' }, // sha1_rsa_65537_4096 - { sigAlg: 'rsa', hashFunction: 'sha256', domainParameter: '130689', keyLength: '4096' }, // sha256_rsa_130689_4096 - { sigAlg: 'rsa', hashFunction: 'sha256', domainParameter: '122125', keyLength: '4096' }, // sha256_rsa_122125_4096 - { sigAlg: 'rsa', hashFunction: 'sha256', domainParameter: '107903', keyLength: '4096' }, // sha256_rsa_107903_4096 - { sigAlg: 'rsa', hashFunction: 'sha256', domainParameter: '56611', keyLength: '4096' }, // sha256_rsa_56611_4096 + { sigAlg: 'rsa', hashFunction: 'sha1', domainParameter: '65537', keyLength: '2048' }, // sha1_rsa_65537_4096 + // { sigAlg: 'rsa', hashFunction: 'sha256', domainParameter: '130689', keyLength: '4096' }, // sha256_rsa_130689_4096 + // { sigAlg: 'rsa', hashFunction: 'sha256', domainParameter: '122125', keyLength: '4096' }, // sha256_rsa_122125_4096 + // { sigAlg: 'rsa', hashFunction: 'sha256', domainParameter: '107903', keyLength: '4096' }, // sha256_rsa_107903_4096 + // { sigAlg: 'rsa', hashFunction: 'sha256', domainParameter: '56611', keyLength: '4096' }, // sha256_rsa_56611_4096 ]; export const fullSigAlgs = [ diff --git a/common/src/mock_certificates/README.md b/common/src/mock_certificates/README.md index 04fa9285c..d037b7118 100644 --- a/common/src/mock_certificates/README.md +++ b/common/src/mock_certificates/README.md @@ -5,7 +5,6 @@ This guide explains how to generate and set up mock certificates for testing. ## Steps to Generate Certificates 1. Add your certificate configuration in `genCertificates.sh` - - You can create cross-signed certificates using the `--signer` flag to specify which CSCA should sign your DSC 2. From the `/common` directory, run: diff --git a/contracts/README.md b/contracts/README.md index 43c9ddb44..a32653aa0 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -42,9 +42,7 @@ If you want to integrate SelfVerificationRoot.sol into your contract, you should ```solidity import { SelfVerificationRoot } from "@selfxyz/contracts/contracts/abstract/SelfVerificationRoot.sol"; -import { - IVcAndDiscloseCircuitVerifier -} from "@selfxyz/contracts/contracts/interfaces/IVcAndDiscloseCircuitVerifier.sol"; +import { IVcAndDiscloseCircuitVerifier } from "@selfxyz/contracts/contracts/interfaces/IVcAndDiscloseCircuitVerifier.sol"; import { IIdentityVerificationHubV1 } from "@selfxyz/contracts/contracts/interfaces/IIdentityVerificationHubV1.sol"; diff --git a/contracts/contracts/libraries/CircuitAttributeHandler.sol b/contracts/contracts/libraries/CircuitAttributeHandler.sol index 6f1429b3f..4560deb17 100644 --- a/contracts/contracts/libraries/CircuitAttributeHandler.sol +++ b/contracts/contracts/libraries/CircuitAttributeHandler.sol @@ -113,8 +113,7 @@ library CircuitAttributeHandler { */ function getOlderThan(bytes memory charcodes) internal pure returns (uint256) { return - Formatter.numAsciiToUint(uint8(charcodes[OLDER_THAN_START])) * - 10 + + Formatter.numAsciiToUint(uint8(charcodes[OLDER_THAN_START])) * 10 + Formatter.numAsciiToUint(uint8(charcodes[OLDER_THAN_START + 1])); } diff --git a/contracts/contracts/libraries/CircuitAttributeHandlerV2.sol b/contracts/contracts/libraries/CircuitAttributeHandlerV2.sol index 5531f0b36..6fdd40320 100644 --- a/contracts/contracts/libraries/CircuitAttributeHandlerV2.sol +++ b/contracts/contracts/libraries/CircuitAttributeHandlerV2.sol @@ -185,8 +185,7 @@ library CircuitAttributeHandlerV2 { function getOlderThan(bytes32 attestationId, bytes memory charcodes) internal pure returns (uint256) { FieldPositions memory positions = getFieldPositions(attestationId); return - Formatter.numAsciiToUint(uint8(charcodes[positions.olderThanStart])) * - 10 + + Formatter.numAsciiToUint(uint8(charcodes[positions.olderThanStart])) * 10 + Formatter.numAsciiToUint(uint8(charcodes[positions.olderThanStart + 1])); } diff --git a/contracts/contracts/libraries/CustomVerifier.sol b/contracts/contracts/libraries/CustomVerifier.sol index e94d829eb..2e5506ff3 100644 --- a/contracts/contracts/libraries/CustomVerifier.sol +++ b/contracts/contracts/libraries/CustomVerifier.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; +pragma solidity ^0.8.4; import {CircuitAttributeHandlerV2} from "./CircuitAttributeHandlerV2.sol"; import {AttestationId} from "../constants/AttestationId.sol"; import {SelfStructs} from "./SelfStructs.sol"; diff --git a/contracts/contracts/libraries/IdCardAttributeHandler.sol b/contracts/contracts/libraries/IdCardAttributeHandler.sol index 03c587a87..ef36ad047 100644 --- a/contracts/contracts/libraries/IdCardAttributeHandler.sol +++ b/contracts/contracts/libraries/IdCardAttributeHandler.sol @@ -114,8 +114,7 @@ library IdCardAttributeHandler { */ function getOlderThan(bytes memory charcodes) internal pure returns (uint256) { return - Formatter.numAsciiToUint(uint8(charcodes[OLDER_THAN_START])) * - 10 + + Formatter.numAsciiToUint(uint8(charcodes[OLDER_THAN_START])) * 10 + Formatter.numAsciiToUint(uint8(charcodes[OLDER_THAN_START + 1])); } diff --git a/packages/mobile-sdk-alpha/.gitignore b/packages/mobile-sdk-alpha/.gitignore new file mode 100644 index 000000000..562aba32e --- /dev/null +++ b/packages/mobile-sdk-alpha/.gitignore @@ -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 diff --git a/packages/mobile-sdk-alpha/android/build.gradle b/packages/mobile-sdk-alpha/android/build.gradle new file mode 100644 index 000000000..d4694e228 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/build.gradle @@ -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' +} diff --git a/packages/mobile-sdk-alpha/android/gradle.properties b/packages/mobile-sdk-alpha/android/gradle.properties new file mode 100644 index 000000000..af0a10037 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/gradle.properties @@ -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 -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 diff --git a/packages/mobile-sdk-alpha/android/libs/jj2000_imageutil.jar b/packages/mobile-sdk-alpha/android/libs/jj2000_imageutil.jar new file mode 100755 index 000000000..9a359f1e5 Binary files /dev/null and b/packages/mobile-sdk-alpha/android/libs/jj2000_imageutil.jar differ diff --git a/packages/mobile-sdk-alpha/android/proguard-rules.pro b/packages/mobile-sdk-alpha/android/proguard-rules.pro new file mode 100644 index 000000000..839fda094 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/proguard-rules.pro @@ -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 ; +} + +# 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.** { *; } diff --git a/packages/mobile-sdk-alpha/android/src/main/AndroidManifest.xml b/packages/mobile-sdk-alpha/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cd40df7f6 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile-sdk-alpha/android/src/main/assets/masterList b/packages/mobile-sdk-alpha/android/src/main/assets/masterList new file mode 100644 index 000000000..0f7e93153 Binary files /dev/null and b/packages/mobile-sdk-alpha/android/src/main/assets/masterList differ diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/RNSelfPassportReaderModule.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/RNSelfPassportReaderModule.kt new file mode 100644 index 000000000..f5bf115a4 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/RNSelfPassportReaderModule.kt @@ -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() { + + 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 = 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 = 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") + } + } +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/RNSelfPassportReaderPackage.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/RNSelfPassportReaderPackage.kt new file mode 100644 index 000000000..b34874562 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/RNSelfPassportReaderPackage.kt @@ -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 { + return listOf( + RNSelfPassportReaderModule(reactContext), + SelfMRZScannerModule(reactContext) + ) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return listOf(SelfOCRViewManager(reactContext)) + } +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/SelfMRZScannerModule.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/SelfMRZScannerModule.kt new file mode 100644 index 000000000..13f5a0b38 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/SelfMRZScannerModule.kt @@ -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 + } +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/SelfOCRViewManager.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/SelfOCRViewManager.kt new file mode 100644 index 000000000..87e5d701a --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/SelfOCRViewManager.kt @@ -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(), 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(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(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 { + 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" + } + +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/FrameMetadata.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/FrameMetadata.kt new file mode 100755 index 000000000..c90d0d3c2 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/FrameMetadata.kt @@ -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) + } + } +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/GraphicOverlay.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/GraphicOverlay.kt new file mode 100644 index 000000000..d67720d00 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/GraphicOverlay.kt @@ -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() + + /** + * 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) + } + } + } +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/OcrMrzDetectorProcessor.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/OcrMrzDetectorProcessor.kt new file mode 100644 index 000000000..36ba3c259 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/OcrMrzDetectorProcessor.kt @@ -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() { + + 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 { + return detector.process(image) + } + + companion object { + private val TAG = OcrMrzDetectorProcessor::class.java.simpleName + + } +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/VisionImageProcessor.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/VisionImageProcessor.kt new file mode 100755 index 000000000..b53961425 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/VisionImageProcessor.kt @@ -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 { + + /** Processes the images with the underlying machine learning models. */ + fun process(data: ByteBuffer, frameMetadata: FrameMetadata, graphicOverlay: GraphicOverlay?=null, isOriginalImageReturned:Boolean = true, listener: VisionProcessorBase.Listener):Boolean + + /** Processes the bitmap images. */ + fun process(bitmap: Bitmap, rotation: Int = 0, graphicOverlay: GraphicOverlay?=null, isOriginalImageReturned:Boolean = true, convertToNv21:Boolean = true, listener: VisionProcessorBase.Listener):Boolean + + /** Processes the images. */ + fun process(image: Image, rotation: Int = 0, graphicOverlay: GraphicOverlay?=null, isOriginalImageReturned:Boolean = true, listener: VisionProcessorBase.Listener):Boolean + + /** Processes the bitmap images. */ + fun process(frame: Frame, rotation:Int = 0, graphicOverlay: GraphicOverlay?=null, isOriginalImageReturned:Boolean = true, listener: VisionProcessorBase.Listener):Boolean + + /** Processes the FirebaseVisionImage */ + fun process(image: InputImage, metadata: FrameMetadata?, graphicOverlay: GraphicOverlay?, isOriginalImageReturned:Boolean = true, listener: VisionProcessorBase.Listener):Boolean + + /** Stops the underlying machine learning model and release resources. */ + fun stop() + + fun canHandleNewFrame():Boolean + + fun resetThrottle() +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/VisionProcessorBase.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/VisionProcessorBase.kt new file mode 100755 index 000000000..46b19002c --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/mlkit/VisionProcessorBase.kt @@ -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 : VisionImageProcessor { + + // 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):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):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):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):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 + ):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):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 + + + interface Listener { + 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 + } +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/ui/CameraFragment.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/ui/CameraFragment.kt new file mode 100644 index 000000000..2e156d76c --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/ui/CameraFragment.kt @@ -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 + 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 = 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()), + REQUEST_PERMISSIONS) + } + } + + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, + grantResults: IntArray) { + when (requestCode) { + REQUEST_PERMISSIONS -> { + val permissionsDenied = ArrayList() + val permissionsGranted = ArrayList() + 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, permissionsGranted: ArrayList) + + 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 + + } +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/ui/CameraMLKitFragment.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/ui/CameraMLKitFragment.kt new file mode 100644 index 000000000..e0ec56393 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/ui/CameraMLKitFragment.kt @@ -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 + get() { + //Nothing as we don't need any other permission than camera and that's managed in the parent fragment + return ArrayList() + } + + override fun onRequestPermissionsResult(permissionsDenied: ArrayList, permissionsGranted: ArrayList) { + } + + + + //////////////////////////////////////////////////////////////////////////////////////// + // + // Instantiate the text processor to perform OCR + // + //////////////////////////////////////////////////////////////////////////////////////// + + //OCR listener + val ocrListener = object : VisionProcessorBase.Listener { + 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, + 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" + } + + +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/utils/ImageUtil.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/utils/ImageUtil.kt new file mode 100644 index 000000000..95dbd481e --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/utils/ImageUtil.kt @@ -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) + + } +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/utils/MRZUtil.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/utils/MRZUtil.kt new file mode 100644 index 000000000..fa25d3346 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/utils/MRZUtil.kt @@ -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() + var mLines2 = ArrayList() + + 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() + } +} diff --git a/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/utils/OcrUtils.kt b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/utils/OcrUtils.kt new file mode 100644 index 000000000..fcd9b2a5c --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/java/com/selfxyz/selfSDK/utils/OcrUtils.kt @@ -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 = "(?[A-Z0-9<]{9})(?[0-9ILDSOG]{1})(?[A-Z<]{3})(?[0-9ILDSOG]{6})(?[0-9ILDSOG]{1})(?[FM<]){1}(?[0-9ILDSOG]{6})(?[0-9ILDSOG]{1})" + private val REGEX_OLD_PASSPORT_CLEAN = "(?[A-Z0-9<]{9})(?[0-9]{1})(?[A-Z<]{3})(?[0-9]{6})(?[0-9]{1})(?[FM<]){1}(?[0-9]{6})(?[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 = "(?[A-Z]{1}[A-Z0-9<]{1})(?[A-Z<]{3})(?[A-Z0-9<]{9})(?[0-9]{1})(?[A-Z0-9<]{15})" + private val REGEX_TD1_LINE2 = "(?[0-9]{6})(?[0-9]{1})(?[FM<]{1})(?[0-9]{6})(?[0-9]{1})(?[A-Z<]{3})(?[A-Z0-9<]{7})" + private val REGEX_TD1_LINE3 ="(?[A-Z<]{30})" + + // TD1 (ID Card) + private val REGEX_ID_DOCUMENT_CODE = "(?[IP]{1}[DM<]{1})" + private val REGEX_ID_DOCUMENT_NUMBER = "(ID)(?[A-Z<]{3})(?[A-Z0-9<]{9})(?[0-9]{1})" + private val REGEX_ID_DATE_OF_BIRTH = "(?[0-9]{6})(?[0-9]{1})(?[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 = "(?[0-9]{6})(?[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) + } +} diff --git a/packages/mobile-sdk-alpha/android/src/main/res/layout/activity_camera.xml b/packages/mobile-sdk-alpha/android/src/main/res/layout/activity_camera.xml new file mode 100755 index 000000000..ed48800e6 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/res/layout/activity_camera.xml @@ -0,0 +1,21 @@ + + diff --git a/packages/mobile-sdk-alpha/android/src/main/res/layout/fragment_camera_mrz.xml b/packages/mobile-sdk-alpha/android/src/main/res/layout/fragment_camera_mrz.xml new file mode 100755 index 000000000..0ac28e496 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/res/layout/fragment_camera_mrz.xml @@ -0,0 +1,51 @@ + + + + + + + + + + diff --git a/packages/mobile-sdk-alpha/android/src/main/res/values/colors.xml b/packages/mobile-sdk-alpha/android/src/main/res/values/colors.xml new file mode 100644 index 000000000..b6f80f067 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/res/values/colors.xml @@ -0,0 +1,23 @@ + + + #3F51B5 + #303F9F + #FF4081 + + #ffffff + #D9000000 + #616365 + + #000000 + #ffffff + #413392 + #1f000000 + #e6e8ee + #2A3764 + + #ffffffff + #ffd6d6d6 + #60000000 + #ffffffff + + diff --git a/packages/mobile-sdk-alpha/android/src/main/res/values/dimens.xml b/packages/mobile-sdk-alpha/android/src/main/res/values/dimens.xml new file mode 100644 index 000000000..1b425faa1 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/res/values/dimens.xml @@ -0,0 +1,25 @@ + + + 16dp + 10dp + 36dp + 4dp + 8dp + 12dp + 22dp + 16sp + 13sp + 14sp + + 0dp + 112dp + 112dp + + 48dp + 12dp + 1dp + + 4dp + 8dp + 4dp + \ No newline at end of file diff --git a/packages/mobile-sdk-alpha/android/src/main/res/values/strings.xml b/packages/mobile-sdk-alpha/android/src/main/res/values/strings.xml new file mode 100644 index 000000000..4d542d8b2 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/res/values/strings.xml @@ -0,0 +1,119 @@ + + Passport Reader + + This device doesn\'t support Camera2 API. + + Tesseract + Cube + Both + + + Access to the camera is needed for detection + This application cannot run because it does not have the camera permission. The application will now exit. + + Installing & Starting OCR + Error Installing & Starting OCR + Installation OCR: %1d%% + + + + Put your phone over your passport and don\'t move it + + Document Number + Expiration Date + Issuing Date + Issuing State + Nationality + Passport + NA + + %1s %2s + + Doc Number: %1s + Date of Birth: %1s + Expiry Date: %1s + + Data Entry + Manual + Automatic + + Document Number + Document Expiration (yymmdd) + Date of Birth (yymmdd) + + READ NFC + Download Spanish CSCA Master List + Delete CSCA Master List + + + Date format is not valid + Document number is not valid + + There is no NFC available + You need to enable NFC + + Additional person information + Custody + Date of birth + Other names + Other Td numbers + Permanent address + Personal number + Personal summary + Place of birth + Profession + Telephone + Title + + Authentication + BAC + PACE + Chip + Passive + Active + EAC + Document Signing + CSCA + + + Additional document information + Observations + Date personalization + Date issue + Image front + Image rear + Issuing authority + Names of other persons + System serial number + Tax or exit requirements + + + Document Signing Certificate + Country Signing Certificate + Serial number + Public key algorithm + Signature algorithm + Certificate thumbprint + Issuer + Subject + Valid from + Valid to + + + Authentication has failed! Please try to scan the document again or introduce the data manually. + Impossible to read the document. Passport doesn\'t support CLA . + + Valid passport + Invalid Passport + Unknown Passport + The passport chip and content are valid + The passport content is valid + The passport chip is invalid + The passport document information is invalid + The CSCA information is invalid + Unable to authenticate the passport + + Keystore + Do you want to replace the current keystore? + + diff --git a/packages/mobile-sdk-alpha/android/src/main/res/values/styles.xml b/packages/mobile-sdk-alpha/android/src/main/res/values/styles.xml new file mode 100644 index 000000000..757538937 --- /dev/null +++ b/packages/mobile-sdk-alpha/android/src/main/res/values/styles.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile-sdk-alpha/demo-app/__tests__/cryptoPolyfills.test.ts b/packages/mobile-sdk-alpha/demo-app/__tests__/cryptoPolyfills.test.ts new file mode 100644 index 000000000..f516fda91 --- /dev/null +++ b/packages/mobile-sdk-alpha/demo-app/__tests__/cryptoPolyfills.test.ts @@ -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 + }); + }); +}); diff --git a/packages/mobile-sdk-alpha/demo-app/android/app/debug.keystore b/packages/mobile-sdk-alpha/demo-app/android/app/debug.keystore new file mode 100644 index 000000000..364e105ed Binary files /dev/null and b/packages/mobile-sdk-alpha/demo-app/android/app/debug.keystore differ diff --git a/packages/mobile-sdk-alpha/demo-app/index.js b/packages/mobile-sdk-alpha/demo-app/index.js index a3c95d36d..b4975f5b9 100644 --- a/packages/mobile-sdk-alpha/demo-app/index.js +++ b/packages/mobile-sdk-alpha/demo-app/index.js @@ -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); diff --git a/packages/mobile-sdk-alpha/demo-app/metro.config.cjs b/packages/mobile-sdk-alpha/demo-app/metro.config.cjs index 6d74acefb..60b0a1955 100644 --- a/packages/mobile-sdk-alpha/demo-app/metro.config.cjs +++ b/packages/mobile-sdk-alpha/demo-app/metro.config.cjs @@ -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')], }, diff --git a/packages/mobile-sdk-alpha/demo-app/package.json b/packages/mobile-sdk-alpha/demo-app/package.json index ebef30c90..181566bb9 100644 --- a/packages/mobile-sdk-alpha/demo-app/package.json +++ b/packages/mobile-sdk-alpha/demo-app/package.json @@ -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", diff --git a/packages/mobile-sdk-alpha/demo-app/src/utils/ethers.ts b/packages/mobile-sdk-alpha/demo-app/src/utils/ethers.ts new file mode 100644 index 000000000..9b96a0974 --- /dev/null +++ b/packages/mobile-sdk-alpha/demo-app/src/utils/ethers.ts @@ -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 }; diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/PassportReader.m b/packages/mobile-sdk-alpha/ios/SelfSDK/PassportReader.m new file mode 100644 index 000000000..3f4ebc8e0 --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/PassportReader.m @@ -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 +#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 diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/PassportReader.swift b/packages/mobile-sdk-alpha/ios/SelfSDK/PassportReader.swift new file mode 100644 index 000000000..4a8a9f291 --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/PassportReader.swift @@ -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(_ transform: (Key) -> T) -> Dictionary { + Dictionary(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? +// 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? + 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 diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfCameraView.swift b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfCameraView.swift new file mode 100644 index 000000000..e9b3b34ad --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfCameraView.swift @@ -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 + } +} diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfLiveMRZScannerView.swift b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfLiveMRZScannerView.swift new file mode 100644 index 000000000..13326e01e --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfLiveMRZScannerView.swift @@ -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.. [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) + } + } + } + } +} diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScanner.swift b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScanner.swift new file mode 100644 index 000000000..683a44c32 --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScanner.swift @@ -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 + } +} diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerModule.m b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerModule.m new file mode 100644 index 000000000..2ef230d13 --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerModule.m @@ -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 +#import + +@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 diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerModule.swift b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerModule.swift new file mode 100644 index 000000000..1fe4f092c --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerModule.swift @@ -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? = 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") + } +} diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerViewManager.m b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerViewManager.m new file mode 100644 index 000000000..cf09ae342 --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerViewManager.m @@ -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 +#import + +@interface RCT_EXTERN_MODULE(SelfMRZScannerViewManager, RCTViewManager) +RCT_EXPORT_VIEW_PROPERTY(onPassportRead, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onError, RCTDirectEventBlock) + +@end diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerViewManager.swift b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerViewManager.swift new file mode 100644 index 000000000..bafd4aa00 --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfMRZScannerViewManager.swift @@ -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? + + 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 + } +} diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK.xcodeproj/project.pbxproj b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK.xcodeproj/project.pbxproj new file mode 100644 index 000000000..18e5613da --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK.xcodeproj/project.pbxproj @@ -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 = ""; }; + BF7273CD2E53412C002FE485 /* PassportReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassportReader.swift; sourceTree = ""; }; + 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 = ""; }; + BFE3DFCD2E4F4EC400195298 /* SelfLiveMRZScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfLiveMRZScannerView.swift; sourceTree = ""; }; + BFE3DFCE2E4F4EC400195298 /* SelfMRZScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfMRZScanner.swift; sourceTree = ""; }; + BFE3DFCF2E4F4EC400195298 /* SelfMRZScannerModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SelfMRZScannerModule.m; sourceTree = ""; }; + BFE3DFD02E4F4EC400195298 /* SelfMRZScannerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfMRZScannerModule.swift; sourceTree = ""; }; + BFE3DFD12E4F4EC400195298 /* SelfMRZScannerViewManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SelfMRZScannerViewManager.m; sourceTree = ""; }; + BFE3DFD22E4F4EC400195298 /* SelfMRZScannerViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfMRZScannerViewManager.swift; sourceTree = ""; }; + BFE3DFD32E4F4EC400195298 /* SelfSDK-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SelfSDK-Bridging-Header.h"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + BFE3DFBA2E4F4E5C00195298 /* SelfSDK */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SelfSDK; + sourceTree = ""; + }; +/* 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 = ""; + }; + BFE3DFB92E4F4E5C00195298 /* Products */ = { + isa = PBXGroup; + children = ( + BFE3DFB82E4F4E5C00195298 /* SelfSDK.framework */, + ); + name = Products; + sourceTree = ""; + }; + BFE3DFC72E4F4E7A00195298 /* Frameworks */ = { + isa = PBXGroup; + children = ( + BFE3DFCA2E4F4E8000195298 /* Vision.framework */, + BFE3DFC82E4F4E7A00195298 /* AVFoundation.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..89ea23dd4 --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.docc/SelfSDK.md b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.docc/SelfSDK.md new file mode 100644 index 000000000..c0fdcf095 --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.docc/SelfSDK.md @@ -0,0 +1,13 @@ +# `SelfSDK` + +Summary + +## Overview + +Text + +## Topics + +### Group + +- ``Symbol`` diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.m b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.m new file mode 100644 index 000000000..9ffbea78a --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.m @@ -0,0 +1,15 @@ +// +// SelfSDK.m +// SelfSDK +// +// Created by Seshanth on 15/08/25. +// + +#import +#import + +@interface RCT_EXTERN_MODULE(SelfSDK, NSObject) + +RCT_EXTERN_METHOD(registerViewManagers:(RCTBridge *)bridge) + +@end diff --git a/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.swift b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.swift new file mode 100644 index 000000000..25fdeec86 --- /dev/null +++ b/packages/mobile-sdk-alpha/ios/SelfSDK/SelfSDK/SelfSDK.swift @@ -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 + } +} diff --git a/packages/mobile-sdk-alpha/mobile-sdk-alpha.podspec b/packages/mobile-sdk-alpha/mobile-sdk-alpha.podspec new file mode 100644 index 000000000..3b26ba715 --- /dev/null +++ b/packages/mobile-sdk-alpha/mobile-sdk-alpha.podspec @@ -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 diff --git a/packages/mobile-sdk-alpha/package.json b/packages/mobile-sdk-alpha/package.json index 319dbdb4f..ac7f63657 100644 --- a/packages/mobile-sdk-alpha/package.json +++ b/packages/mobile-sdk-alpha/package.json @@ -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", diff --git a/packages/mobile-sdk-alpha/react-native.config.cjs b/packages/mobile-sdk-alpha/react-native.config.cjs new file mode 100644 index 000000000..7ca4aa392 --- /dev/null +++ b/packages/mobile-sdk-alpha/react-native.config.cjs @@ -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', + }, + }, +}; diff --git a/packages/mobile-sdk-alpha/src/adapters/react-native/scanner.ts b/packages/mobile-sdk-alpha/src/adapters/react-native/scanner.ts new file mode 100644 index 000000000..73726ef72 --- /dev/null +++ b/packages/mobile-sdk-alpha/src/adapters/react-native/scanner.ts @@ -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 { + 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 { + 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 { + 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`); + } +} diff --git a/packages/mobile-sdk-alpha/src/browser.ts b/packages/mobile-sdk-alpha/src/browser.ts index 74670430f..086eebcae 100644 --- a/packages/mobile-sdk-alpha/src/browser.ts +++ b/packages/mobile-sdk-alpha/src/browser.ts @@ -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'; diff --git a/packages/mobile-sdk-alpha/src/components/MRZScannerView.tsx b/packages/mobile-sdk-alpha/src/components/MRZScannerView.tsx new file mode 100644 index 000000000..7743d3f6d --- /dev/null +++ b/packages/mobile-sdk-alpha/src/components/MRZScannerView.tsx @@ -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( + 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 = ({ + style, + height, + width, + aspectRatio, + onMRZDetected, + onError, +}) => { + const viewRef = useRef(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 ( + + + + ); + } else { + return ( + + + + ); + } +}; + +// 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; diff --git a/packages/mobile-sdk-alpha/src/components/RCTFragment.tsx b/packages/mobile-sdk-alpha/src/components/RCTFragment.tsx new file mode 100644 index 000000000..f425ee887 --- /dev/null +++ b/packages/mobile-sdk-alpha/src/components/RCTFragment.tsx @@ -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; + 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 ; +}; diff --git a/packages/mobile-sdk-alpha/src/components/index.ts b/packages/mobile-sdk-alpha/src/components/index.ts new file mode 100644 index 000000000..595a0e96a --- /dev/null +++ b/packages/mobile-sdk-alpha/src/components/index.ts @@ -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'; diff --git a/packages/mobile-sdk-alpha/src/components/screens/NFCScannerScreen.tsx b/packages/mobile-sdk-alpha/src/components/screens/NFCScannerScreen.tsx index 60ac940f7..eb5a0b81d 100644 --- a/packages/mobile-sdk-alpha/src/components/screens/NFCScannerScreen.tsx +++ b/packages/mobile-sdk-alpha/src/components/screens/NFCScannerScreen.tsx @@ -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) { diff --git a/packages/mobile-sdk-alpha/src/components/screens/PassportCameraScreen.tsx b/packages/mobile-sdk-alpha/src/components/screens/PassportCameraScreen.tsx index 2852462f5..6c5f4d5a7 100644 --- a/packages/mobile-sdk-alpha/src/components/screens/PassportCameraScreen.tsx +++ b/packages/mobile-sdk-alpha/src/components/screens/PassportCameraScreen.tsx @@ -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) => Passport Camera + +