SELF-1812: integrate sumsub into mobile app (#1650)

* sumsub initial pass

* add sumsub tee url

* agent feedback and fixes

* update lock

* agent feedback

* fix types

* agnet feedback

* fix mock

* agent feedback

* lazy load sumsub screen

* white button color

* fix lint

* add debug url link

* allow us to see recordings

* debug maestro run

* disable e2e screen recording for now. don't load sumsub logic when running e2e test

* remove lazy loading

* skip installing sumsub plugin

* retest ios e2e

* get e2e tests passing

* clean up
This commit is contained in:
Justin Hernandez
2026-01-26 14:06:36 -08:00
committed by GitHub
parent d708d85982
commit ba856226d8
25 changed files with 1422 additions and 42 deletions

View File

@@ -15,6 +15,9 @@ env:
# Disable Maestro analytics in CI
MAESTRO_CLI_NO_ANALYTICS: true
MAESTRO_VERSION: 1.41.0
MAESTRO_CACHE_VERSION: v1 # Bump this to clear Maestro cache
# Disable Maestro recording/artifacts (keep for debugging - set to "true" to re-enable)
ENABLE_MAESTRO_RECORDING: false
on:
push:
@@ -333,9 +336,35 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.maestro
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}-${{ env.MAESTRO_CACHE_VERSION }}
restore-keys: |
${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}-
- name: Validate Maestro cache
if: steps.cache-maestro.outputs.cache-hit == 'true'
run: |
echo "✅ Maestro restored from cache"
echo "Validating cached Maestro installation..."
if [ ! -f "$HOME/.maestro/bin/maestro" ]; then
echo "❌ Maestro binary not found in cache - cache is corrupted"
echo "Clearing corrupted cache..."
rm -rf ~/.maestro
echo "MAESTRO_CACHE_VALID=false" >> $GITHUB_ENV
else
echo "✅ Maestro binary found in cache"
# Test if Maestro is executable
if "$HOME/.maestro/bin/maestro" --version &>/dev/null; then
echo "✅ Maestro cache is valid"
echo "MAESTRO_CACHE_VALID=true" >> $GITHUB_ENV
else
echo "❌ Maestro binary is not executable - cache is corrupted"
echo "Clearing corrupted cache..."
rm -rf ~/.maestro
echo "MAESTRO_CACHE_VALID=false" >> $GITHUB_ENV
fi
fi
- name: Install Maestro
if: steps.cache-maestro.outputs.cache-hit != 'true'
if: steps.cache-maestro.outputs.cache-hit != 'true' || env.MAESTRO_CACHE_VALID == 'false'
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
@@ -344,6 +373,20 @@ jobs:
command: curl -Ls "https://get.maestro.mobile.dev" | bash
- name: Add Maestro to path
run: echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Verify Maestro installation
run: |
echo "🔍 Verifying Maestro installation..."
echo "Maestro path: $(which maestro)"
echo "Maestro version:"
maestro --version || {
echo "❌ Maestro installation verification failed"
echo "Maestro binary location:"
find ~/.maestro -name maestro -type f 2>/dev/null || echo "No maestro binary found"
echo ""
echo "To fix: Bump MAESTRO_CACHE_VERSION in workflow file"
exit 1
}
echo "✅ Maestro installation verified"
- name: Set up Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
@@ -417,6 +460,7 @@ jobs:
BUNDLE_GEMFILE=../Gemfile bundle exec bash scripts/pod-install-with-cache-fix.sh
env:
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
E2E_TESTING: 1
- name: Setup iOS Simulator
run: |
echo "Setting up iOS Simulator..."
@@ -504,6 +548,8 @@ jobs:
echo "WORKSPACE_PATH=$WORKSPACE_PATH" >> "$GITHUB_ENV"
echo "Resolved workspace: $WORKSPACE_PATH"
- name: Build iOS App
env:
E2E_TESTING: 1
run: |
echo "Building iOS app..."
echo "Project: ${{ env.IOS_PROJECT_NAME }}, Scheme: ${{ env.IOS_PROJECT_SCHEME }}"
@@ -538,10 +584,13 @@ jobs:
# Use cached derived data and enable parallel builds for faster compilation
# Additional flags disable indexing, restrict architecture, and use whole-module Swift compilation
# Use the simulator that was set up earlier in the workflow
# E2E_TESTING compilation condition excludes NFCPassportReader which isn't available on simulator
FORCE_BUNDLING=1 RCT_NO_LAUNCH_PACKAGER=1 \
xcodebuild -workspace "$WORKSPACE_PATH" -scheme ${{ env.IOS_PROJECT_SCHEME }} -configuration Debug -destination "id=${{ env.IOS_SIMULATOR_ID }}" -derivedDataPath app/ios/build -jobs "$(sysctl -n hw.ncpu)" -parallelizeTargets -quiet COMPILER_INDEX_STORE_ENABLE=NO ONLY_ACTIVE_ARCH=YES SWIFT_COMPILATION_MODE=wholemodule || { echo "❌ iOS build failed"; exit 1; }
xcodebuild -workspace "$WORKSPACE_PATH" -scheme ${{ env.IOS_PROJECT_SCHEME }} -configuration Debug -destination "id=${{ env.IOS_SIMULATOR_ID }}" -derivedDataPath app/ios/build -jobs "$(sysctl -n hw.ncpu)" -parallelizeTargets -quiet COMPILER_INDEX_STORE_ENABLE=NO ONLY_ACTIVE_ARCH=YES SWIFT_COMPILATION_MODE=wholemodule 'SWIFT_ACTIVE_COMPILATION_CONDITIONS=$(inherited) E2E_TESTING' || { echo "❌ iOS build failed"; exit 1; }
echo "✅ iOS build succeeded"
- name: Install and Test on iOS
continue-on-error: true
id: maestro-test
run: |
echo "Installing app on simulator..."
APP_PATH=$(find app/ios/build/Build/Products/Debug-iphonesimulator -name "*.app" | head -1)
@@ -589,20 +638,213 @@ jobs:
# Final readiness check (suppress errors to avoid annotations)
xcrun simctl get_app_container "$SIMULATOR_ID" "$IOS_BUNDLE_ID" app >/dev/null 2>&1 || sleep 5
echo "🎭 Running Maestro tests..."
echo "Starting test execution..."
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ 🔍 PRE-TEST DIAGNOSTICS ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
# Verify Maestro test file exists
if [ ! -f "app/tests/e2e/launch.ios.flow.yaml" ]; then
echo "❌ Maestro test file not found: app/tests/e2e/launch.ios.flow.yaml"
# Create directory for test artifacts
mkdir -p app/test-artifacts
# Check Maestro version and health
echo "📦 Maestro installation details:"
echo "Binary location: $(which maestro)"
echo "Maestro version:"
maestro --version || {
echo "❌ Failed to get Maestro version"
echo "This is a critical error - Maestro is not working"
exit 1
}
echo ""
# Check Java (required by Maestro)
echo "☕ Java status (required by Maestro):"
if command -v java &>/dev/null; then
java -version 2>&1 | head -3
echo "✅ Java is available"
else
echo "⚠️ Java not found - this may cause Maestro to fail"
fi
echo ""
# Check simulator state
echo "📱 Simulator state:"
xcrun simctl list devices | grep -A 2 "$SIMULATOR_ID" || echo "⚠️ Simulator not found"
echo ""
# Check if app is installed
echo "📲 Checking app installation:"
xcrun simctl listapps "$SIMULATOR_ID" | grep -i "$IOS_BUNDLE_ID" && echo "✅ App is installed" || echo "⚠️ App not found in installed apps"
echo ""
# Check if app is running
echo "🏃 Checking if app is running:"
APP_PID=$(xcrun simctl spawn "$SIMULATOR_ID" launchctl list | grep "$IOS_BUNDLE_ID" | awk '{print $1}' || echo "")
if [ -n "$APP_PID" ]; then
echo "✅ App process found (PID: $APP_PID)"
else
echo " App not currently running (will be launched by Maestro)"
fi
echo ""
# Verify test file
echo "📄 Maestro test file:"
if [ -f "app/tests/e2e/launch.ios.flow.yaml" ]; then
echo "✅ Found: app/tests/e2e/launch.ios.flow.yaml"
echo "Contents:"
cat app/tests/e2e/launch.ios.flow.yaml
else
echo "❌ Test file not found"
fi
echo ""
# Note: Maestro 1.41.0 doesn't have daemon or devices commands
# The test command handles daemon management internally
echo "🔧 Maestro will manage its daemon automatically during test execution"
echo ""
# Take a screenshot before running tests (if recording enabled)
if [ "${ENABLE_MAESTRO_RECORDING}" = "true" ]; then
echo "📸 Taking pre-test screenshot..."
xcrun simctl io "$SIMULATOR_ID" screenshot app/test-artifacts/pre-test-screenshot.png || echo "⚠️ Screenshot failed"
echo ""
fi
# Run Maestro with error handling for cleanup issues
# Note: Maestro may show NSPOSIXErrorDomain code=3 errors during cleanup when
# terminating the test runner app that's already terminated. This is harmless.
MAESTRO_OUTPUT=$(maestro test app/tests/e2e/launch.ios.flow.yaml --format junit --output app/maestro-results.xml 2>&1)
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ 🎭 STARTING MAESTRO TEST ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
# Start native simulator recording BEFORE running tests (if recording enabled)
# This ensures we capture everything even if Maestro fails immediately
if [ "${ENABLE_MAESTRO_RECORDING}" = "true" ]; then
echo "📹 Starting native simulator recording..."
xcrun simctl io "$SIMULATOR_ID" recordVideo --codec=h264 app/test-artifacts/simulator-recording.mp4 &
RECORDING_PID=$!
echo "✅ Recording started (PID: $RECORDING_PID)"
# Give recording a moment to initialize
sleep 2
else
echo " Maestro recording disabled (ENABLE_MAESTRO_RECORDING=false)"
fi
# Run Maestro with verbose output and capture all output (including errors)
echo "🎭 Starting Maestro test with verbose logging..."
echo "Command: maestro test app/tests/e2e/launch.ios.flow.yaml --format junit --output app/maestro-results.xml"
echo "Environment variables:"
echo " MAESTRO_DEVICE_ID: $SIMULATOR_ID"
echo " MAESTRO_DRIVER_STARTUP_TIMEOUT: 180000"
echo " MAESTRO_CLI_NO_ANALYTICS: $MAESTRO_CLI_NO_ANALYTICS"
echo " Working directory: $(pwd)"
echo ""
set +e # Don't exit on error
# Run with explicit device ID, increased timeout, and verbose output
# Note: exit code 2 typically means Maestro couldn't connect to device or daemon
MAESTRO_OUTPUT=$(MAESTRO_DEVICE_ID="$SIMULATOR_ID" MAESTRO_DRIVER_STARTUP_TIMEOUT=180000 maestro test app/tests/e2e/launch.ios.flow.yaml --format junit --output app/maestro-results.xml 2>&1)
MAESTRO_EXIT_CODE=$?
set -e
echo "Maestro command completed with exit code: $MAESTRO_EXIT_CODE"
echo ""
# Stop the simulator recording (if recording was started)
if [ "${ENABLE_MAESTRO_RECORDING}" = "true" ] && [ -n "${RECORDING_PID:-}" ]; then
echo ""
echo "🛑 Stopping recording..."
kill -SIGINT $RECORDING_PID 2>/dev/null || true
wait $RECORDING_PID 2>/dev/null || true
sleep 2 # Give time for video to finalize
fi
# Take a screenshot after test (if recording enabled)
if [ "${ENABLE_MAESTRO_RECORDING}" = "true" ]; then
echo "📸 Taking post-test screenshot..."
xcrun simctl io "$SIMULATOR_ID" screenshot app/test-artifacts/post-test-screenshot.png || echo "⚠️ Screenshot failed"
echo ""
fi
# Save Maestro output to file for debugging
echo "$MAESTRO_OUTPUT" > app/test-artifacts/maestro-output.log
echo "📝 Maestro output saved to maestro-output.log"
# Show Maestro output
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ 📋 MAESTRO OUTPUT ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo "$MAESTRO_OUTPUT"
echo ""
# Post-test diagnostics
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ 🔍 POST-TEST DIAGNOSTICS ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
# Check if app is still running
echo "🏃 App status after test:"
APP_PID_AFTER=$(xcrun simctl spawn "$SIMULATOR_ID" launchctl list | grep "$IOS_BUNDLE_ID" | awk '{print $1}' || echo "")
if [ -n "$APP_PID_AFTER" ]; then
echo "✅ App still running (PID: $APP_PID_AFTER)"
else
echo "⚠️ App not running"
fi
echo ""
# Check simulator logs for any crashes
echo "🔍 Checking for crash logs..."
CRASH_LOGS=$(find ~/Library/Logs/DiagnosticReports -name "Self*.crash" -o -name "OpenPassport*.crash" -mmin -5 2>/dev/null | head -5)
if [ -n "$CRASH_LOGS" ]; then
echo "⚠️ Recent crash logs found:"
echo "$CRASH_LOGS"
# Copy crash logs to artifacts
for log in $CRASH_LOGS; do
cp "$log" app/test-artifacts/ 2>/dev/null || true
done
else
echo "✅ No recent crash logs"
fi
echo ""
# Check system log for relevant messages
echo "📋 Recent simulator system logs (last 50 lines):"
xcrun simctl spawn "$SIMULATOR_ID" log show --predicate 'process == "Self" OR process == "OpenPassport"' --last 5m --style compact 2>/dev/null | tail -50 > app/test-artifacts/simulator-system.log || echo "⚠️ Could not retrieve system logs"
if [ -f app/test-artifacts/simulator-system.log ]; then
echo "✅ System logs saved to simulator-system.log"
echo "Last 20 lines:"
tail -20 app/test-artifacts/simulator-system.log
fi
echo ""
# Check if video was created (if recording was enabled)
if [ "${ENABLE_MAESTRO_RECORDING}" = "true" ]; then
if [ -f "app/test-artifacts/simulator-recording.mp4" ]; then
VIDEO_SIZE=$(ls -lh app/test-artifacts/simulator-recording.mp4 | awk '{print $5}')
echo "✅ Video recording saved: simulator-recording.mp4 ($VIDEO_SIZE)"
echo ""
echo "📹 ===================================================="
echo "📹 VIDEO RECORDING AVAILABLE"
echo "📹 ===================================================="
echo "📹 To view the test recording:"
echo "📹 1. Go to: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "📹 2. Scroll to the 'Artifacts' section at the bottom"
echo "📹 3. Download 'maestro-artifacts-ios'"
echo "📹 4. Extract and open 'simulator-recording.mp4'"
echo "📹 ===================================================="
echo ""
else
echo "⚠️ Video recording not created"
fi
else
echo " Video recording disabled (ENABLE_MAESTRO_RECORDING=false)"
fi
# Analyze test results
echo "🔍 Analyzing test results..."
echo "Maestro exit code: $MAESTRO_EXIT_CODE"
# Check if tests actually passed (ignore cleanup errors)
if echo "$MAESTRO_OUTPUT" | grep -q "Flow Passed"; then
@@ -616,20 +858,76 @@ jobs:
fi
elif echo "$MAESTRO_OUTPUT" | grep -q "Flow Failed"; then
echo "❌ Maestro tests failed"
echo "Check the video recording and maestro-output.log for details"
exit 1
elif [ $MAESTRO_EXIT_CODE -ne 0 ]; then
# Check results file if exit code is non-zero
if [ -f "app/maestro-results.xml" ] && ! grep -q "<failure" app/maestro-results.xml; then
echo "✅ Tests passed (cleanup error caused non-zero exit)"
else
echo "❌ Maestro test failed"
echo "❌ Maestro test failed with exit code: $MAESTRO_EXIT_CODE"
echo "Check the video recording and maestro-output.log for details"
exit 1
fi
fi
- name: Upload test results
if: always()
if: always() && env.ENABLE_MAESTRO_RECORDING == 'true'
uses: actions/upload-artifact@v4
with:
name: maestro-results-ios
path: app/maestro-results.xml
if-no-files-found: warn
- name: Upload test artifacts (video and screenshots)
if: always() && env.ENABLE_MAESTRO_RECORDING == 'true'
uses: actions/upload-artifact@v4
with:
name: maestro-artifacts-ios
path: app/test-artifacts/
if-no-files-found: warn
retention-days: 7
- name: Artifact download instructions
if: always() && env.ENABLE_MAESTRO_RECORDING == 'true'
run: |
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
echo "║ 📹 TEST ARTIFACTS UPLOADED ║"
echo "╚════════════════════════════════════════════════════════════════╝"
echo ""
echo "📦 Artifact name: maestro-artifacts-ios"
echo "🎬 Contains:"
echo " 📹 simulator-recording.mp4 - Full video of simulator during test"
echo " 📝 maestro-output.log - Complete Maestro command output"
echo " 📋 simulator-system.log - iOS simulator system logs"
echo " 📸 pre-test-screenshot.png - Simulator state before test"
echo " 📸 post-test-screenshot.png - Simulator state after test"
echo " 📊 maestro-results.xml - Test results (if generated)"
echo " 💥 *.crash - Crash logs (if any crashes occurred)"
echo "⏰ Retention: 7 days"
echo ""
echo "🔗 Direct link to this run:"
echo " https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo ""
echo "💡 How to download:"
echo " 1. Click the link above (or find this run in the Actions tab)"
echo " 2. Scroll down to the 'Artifacts' section"
echo " 3. Click 'maestro-artifacts-ios' to download the ZIP"
echo " 4. Extract and review:"
echo " 🎬 Play simulator-recording.mp4 to see what happened"
echo " 📝 Read maestro-output.log for Maestro errors"
echo " 📋 Check simulator-system.log for iOS system errors"
echo " 📸 Compare pre/post screenshots to see UI state"
echo " 💥 Review crash logs if app crashed"
echo ""
echo "📝 Alternative - Using GitHub CLI:"
echo " gh run download ${{ github.run_id }} -n maestro-artifacts-ios"
echo ""
# List what files were actually created
echo "📂 Files in test-artifacts directory:"
ls -lh app/test-artifacts/ 2>/dev/null || echo " (No artifacts directory found)"
echo ""
- name: Fail job if tests failed
if: steps.maestro-test.outcome == 'failure'
run: |
echo "❌ Maestro tests failed - failing job after artifact upload"
exit 1

View File

@@ -23,8 +23,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1204.0)
aws-sdk-core (3.241.3)
aws-partitions (1.1209.0)
aws-sdk-core (3.241.4)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -32,11 +32,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-s3 (1.212.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)

View File

@@ -12,6 +12,12 @@
android:usesCleartextTraffic="true"
tools:targetApi="28"
tools:ignore="GoogleAppIndexingWarning"
tools:replace="android:usesCleartextTraffic"
/>
tools:replace="android:usesCleartextTraffic">
<!-- Override conflicting ML Kit dependencies from passportreader (ocr) and Sumsub SDK (face) -->
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="ocr,face"
tools:replace="android:value" />
</application>
</manifest>

View File

@@ -106,5 +106,11 @@
</intent-filter>
<meta-data android:name="photopicker_activity:0:required" android:value="" />
</service>
<!-- Override conflicting ML Kit dependencies from passportreader (ocr) and Sumsub SDK (face) -->
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="ocr,face"
tools:replace="android:value" />
</application>
</manifest>

View File

@@ -41,6 +41,7 @@ allprojects {
url("$rootDir/../../node_modules/jsc-android/dist")
}
maven { url 'https://jitpack.io' }
maven { url "https://maven.sumsub.com/repository/maven-public/" }
}
configurations.configureEach {
resolutionStrategy.dependencySubstitution {

View File

@@ -8,4 +8,5 @@ IS_TEST_BUILD=
MIXPANEL_NFC_PROJECT_TOKEN=
SEGMENT_KEY=
SENTRY_DSN=
SUMSUB_TEE_URL=
IS_TEST_BUILD=

View File

@@ -28,6 +28,9 @@ export const IS_TEST_BUILD = process.env.IS_TEST_BUILD === 'true';
export const MIXPANEL_NFC_PROJECT_TOKEN = undefined;
export const SEGMENT_KEY = process.env.SEGMENT_KEY;
export const SENTRY_DSN = process.env.SENTRY_DSN;
export const SUMSUB_TEE_URL =
process.env.SUMSUB_TEE_URL || 'http://localhost:8080';
export const SUMSUB_TEST_TOKEN = process.env.SUMSUB_TEST_TOKEN;
export const TURNKEY_AUTH_PROXY_CONFIG_ID =
process.env.TURNKEY_AUTH_PROXY_CONFIG_ID;

View File

@@ -1,4 +1,16 @@
source "https://cdn.cocoapods.org/"
# Skip Sumsub configuration for E2E testing
unless ENV["E2E_TESTING"] == "1"
source "https://github.com/SumSubstance/Specs.git"
# Enable Fisherman (Device Intelligence) module
ENV["IDENSIC_WITH_FISHERMAN"] = "true"
# Enable VideoIdent module
ENV["IDENSIC_WITH_VIDEOIDENT"] = "true"
end
use_frameworks!
require "tmpdir"
@@ -39,6 +51,7 @@ def using_https_git_auth?
end
target "Self" do
# Native module exclusion for E2E testing is handled in react-native.config.cjs
config = use_native_modules!
use_frameworks!

View File

@@ -11,6 +11,7 @@ PODS:
- DoubleConversion (1.1.6)
- fast_float (6.1.4)
- FBLazyVector (0.76.9)
- FingerprintPro (2.12.0)
- Firebase (10.24.0):
- Firebase/Core (= 10.24.0)
- Firebase/Core (10.24.0):
@@ -149,6 +150,19 @@ PODS:
- hermes-engine (0.76.9):
- hermes-engine/Pre-built (= 0.76.9)
- hermes-engine/Pre-built (0.76.9)
- IdensicMobileSDK (1.40.2):
- IdensicMobileSDK/Default (= 1.40.2)
- IdensicMobileSDK/Core (1.40.2)
- IdensicMobileSDK/Default (1.40.2):
- IdensicMobileSDK/Core
- IdensicMobileSDK/Fisherman (1.40.2):
- FingerprintPro (~> 2.11)
- IdensicMobileSDK/Core
- IdensicMobileSDK/VideoIdent (1.40.2):
- IdensicMobileSDK/VideoIdent-latest
- IdensicMobileSDK/VideoIdent-latest (1.40.2):
- IdensicMobileSDK/Core
- TwilioVideo (>= 5.8.2)
- lottie-ios (4.5.0)
- lottie-react-native (7.2.2):
- DoubleConversion
@@ -1537,6 +1551,11 @@ PODS:
- Yoga
- react-native-get-random-values (1.11.0):
- React-Core
- react-native-mobilesdk-module (1.40.2):
- IdensicMobileSDK (= 1.40.2)
- IdensicMobileSDK/Fisherman (= 1.40.2)
- IdensicMobileSDK/VideoIdent (= 1.40.2)
- React-Core
- react-native-netinfo (11.4.1):
- React-Core
- react-native-nfc-manager (3.16.3):
@@ -2133,7 +2152,7 @@ PODS:
- ReactCommon/turbomodule/core
- Sentry/HybridSDK (= 8.53.2)
- Yoga
- RNSVG (15.14.0):
- RNSVG (15.15.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2153,9 +2172,9 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNSVG/common (= 15.14.0)
- RNSVG/common (= 15.15.0)
- Yoga
- RNSVG/common (15.14.0):
- RNSVG/common (15.15.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2185,6 +2204,7 @@ PODS:
- React-Core
- SwiftQRScanner (1.1.6)
- SwiftyTesseract (3.1.3)
- TwilioVideo (5.11.1)
- Yoga (0.0.0)
DEPENDENCIES:
@@ -2243,6 +2263,7 @@ DEPENDENCIES:
- react-native-cloud-storage (from `../node_modules/react-native-cloud-storage`)
- "react-native-compat (from `../node_modules/@walletconnect/react-native-compat`)"
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
- "react-native-mobilesdk-module (from `../node_modules/@sumsub/react-native-mobilesdk-module`)"
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-nfc-manager (from `../node_modules/react-native-nfc-manager`)
- react-native-passkey (from `../node_modules/react-native-passkey`)
@@ -2296,8 +2317,11 @@ DEPENDENCIES:
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS:
https://github.com/SumSubstance/Specs.git:
- IdensicMobileSDK
trunk:
- AppAuth
- FingerprintPro
- Firebase
- FirebaseABTesting
- FirebaseAnalytics
@@ -2322,6 +2346,7 @@ SPEC REPOS:
- Sentry
- SocketRocket
- SwiftyTesseract
- TwilioVideo
EXTERNAL SOURCES:
boost:
@@ -2416,6 +2441,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@walletconnect/react-native-compat"
react-native-get-random-values:
:path: "../node_modules/react-native-get-random-values"
react-native-mobilesdk-module:
:path: "../node_modules/@sumsub/react-native-mobilesdk-module"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-nfc-manager:
@@ -2534,6 +2561,7 @@ SPEC CHECKSUMS:
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
FingerprintPro: 035517a1b4e3e4fc073486b53b9956509010f8db
Firebase: 91fefd38712feb9186ea8996af6cbdef41473442
FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe
FirebaseAnalytics: b5efc493eb0f40ec560b04a472e3e1a15d39ca13
@@ -2551,6 +2579,7 @@ SPEC CHECKSUMS:
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
IdensicMobileSDK: 00b13320e1b1e0574e68475bd0fbc7cd30fce26e
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: 7bb65bc88d3f9996ea2f646a96694285405df2f9
Mixpanel-swift: e9bef28a9648faff384d5ba6f48ecc2787eb24c0
@@ -2595,6 +2624,7 @@ SPEC CHECKSUMS:
react-native-cloud-storage: 8d89f2bc574cf11068dfd90933905974087fb9e9
react-native-compat: b80530ebcd3d574be5dd99cb27b984a17c119abc
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-mobilesdk-module: 4770cb45fdd19dc4eed04615f0fcdab013b3dfe2
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-nfc-manager: 66a00e5ddab9704efebe19d605b1b8afb0bb1bd7
react-native-passkey: 8853c3c635164864da68a6dbbcec7148506c3bcf
@@ -2641,15 +2671,16 @@ SPEC CHECKSUMS:
RNReactNativeHapticFeedback: e526ac4a7ca9fb23c7843ea4fd7d823166054c73
RNScreens: 806e1449a8ec63c2a4e4cf8a63cc80203ccda9b8
RNSentry: 6ad982be2c8e32dab912afb4132b6a0d88484ea0
RNSVG: e1cf5a9a5aa12c69f2ec47031defbd87ae7fb697
RNSVG: 39476f26bbbe72ffe6194c6fc8f6acd588087957
segment-analytics-react-native: 0eae155b0e9fa560fa6b17d78941df64537c35b7
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
sovran-react-native: a3ad3f8ff90c2002b2aa9790001a78b0b0a38594
SwiftQRScanner: e85a25f9b843e9231dab89a96e441472fe54a724
SwiftyTesseract: 1f3d96668ae92dc2208d9842c8a59bea9fad2cbb
TwilioVideo: 9f51085d4e4fb3aff8e168b8215b31cb0f486a2f
Yoga: 1259c7a8cbaccf7b4c3ddf8ee36ca11be9dee407
PODFILE CHECKSUM: 0aa47f53692543349c43673cda7380fa23049eba
PODFILE CHECKSUM: f03c12b5d96fb6e22afe20fba517840fef44e76f
COCOAPODS: 1.16.2

View File

@@ -1,9 +1,11 @@
// 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 Foundation
import NFCPassportReader
import Mixpanel
#if !E2E_TESTING
import NFCPassportReader
public class SelfAnalytics: Analytics {
private let enableDebugLogs: Bool
@@ -67,3 +69,13 @@ public class SelfAnalytics: Analytics {
Mixpanel.mainInstance().flush()
}
}
#else
// E2E Testing stub - SelfAnalytics is not used when NFCPassportReader is excluded
public class SelfAnalytics {
public init(token: String, enableDebugLogs: Bool = false, trackAutomaticEvents: Bool = false) {}
public func trackEvent(_ name: String, properties: [String: Any]? = nil) {}
public func trackDebugEvent(_ name: String, properties: [String: Any]? = nil) {}
public func trackError(_ error: Error, context: String) {}
public func flush() {}
}
#endif

View File

@@ -16,7 +16,7 @@ module.exports = {
'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|@selfxyz|@sentry|@anon-aadhaar|react-native-svg|react-native-svg-circle-country-flags|react-native-blur-effect)/)',
'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|@sentry|@anon-aadhaar|react-native-svg|react-native-svg-circle-country-flags|react-native-blur-effect|@sumsub)/)',
],
setupFiles: ['<rootDir>/jest.setup.js'],
testMatch: [

View File

@@ -1237,3 +1237,29 @@ jest.mock('react-native/Libraries/AppState/AppState', () => {
},
};
});
// Mock @sumsub/react-native-mobilesdk-module
jest.mock('@sumsub/react-native-mobilesdk-module', () => {
const createBuilder = () => ({
withHandlers: jest.fn().mockReturnThis(),
withDebug: jest.fn().mockReturnThis(),
withLocale: jest.fn().mockReturnThis(),
withAnalyticsEnabled: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue({
launch: jest.fn().mockResolvedValue({ success: true }),
}),
});
const MockSNSMobileSDK = {
init: jest
.fn()
.mockImplementation((accessToken, tokenExpirationHandler) =>
createBuilder(),
),
};
return {
__esModule: true,
default: MockSNSMobileSDK,
};
});

View File

@@ -110,6 +110,7 @@
"@sentry/react": "^9.32.0",
"@sentry/react-native": "7.0.1",
"@stablelib/cbor": "^2.0.1",
"@sumsub/react-native-mobilesdk-module": "1.40.2",
"@tamagui/animations-css": "1.126.14",
"@tamagui/animations-react-native": "1.126.14",
"@tamagui/config": "1.126.14",
@@ -163,13 +164,13 @@
"react-native-safe-area-context": "^5.6.1",
"react-native-screens": "4.15.3",
"react-native-sqlite-storage": "^6.0.1",
"react-native-svg": "15.14.0",
"react-native-svg": "15.15.0",
"react-native-svg-web": "1.0.9",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "^0.19.0",
"react-native-webview": "^13.16.0",
"react-qr-barcode-scanner": "^2.1.8",
"socket.io-client": "^4.8.1",
"socket.io-client": "^4.8.3",
"tamagui": "1.126.14",
"uuid": "^11.1.0",
"xstate": "^5.20.2",

View File

@@ -2,10 +2,19 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
const dependencies = {
'@selfxyz/mobile-sdk-alpha': { platforms: { android: null, ios: null } },
};
// Disable Sumsub SDK autolinking during E2E testing to avoid build issues
if (process.env.E2E_TESTING === '1') {
dependencies['@sumsub/react-native-mobilesdk-module'] = {
platforms: { android: null, ios: null },
};
}
module.exports = {
project: { ios: {}, android: {} },
dependencies: {
'@selfxyz/mobile-sdk-alpha': { platforms: { android: null, ios: null } },
},
dependencies,
assets: ['../src/assets/fonts'],
};

View File

@@ -0,0 +1,14 @@
// 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 type {
AccessTokenResponse,
SumsubApplicantInfo,
SumsubResult,
} from '@/integrations/sumsub/types';
export {
type SumsubConfig,
fetchAccessToken,
launchSumsub,
} from '@/integrations/sumsub/sumsubService';

View File

@@ -0,0 +1,102 @@
// 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 { SUMSUB_TEE_URL } from '@env';
import SNSMobileSDK from '@sumsub/react-native-mobilesdk-module';
import type {
AccessTokenResponse,
SumsubResult,
} from '@/integrations/sumsub/types';
export interface SumsubConfig {
accessToken: string;
locale?: string;
debug?: boolean;
onStatusChanged?: (prevStatus: string, newStatus: string) => void;
onEvent?: (eventType: string, payload: unknown) => void;
}
const FETCH_TIMEOUT_MS = 30000; // 30 seconds
export const fetchAccessToken = async (
phoneNumber: string,
): Promise<AccessTokenResponse> => {
const apiUrl = SUMSUB_TEE_URL;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(`${apiUrl}/access-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone: phoneNumber }),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
`Failed to get Sumsub access token (HTTP ${response.status})`,
);
}
const body = await response.json();
// Handle both string and object responses
if (typeof body === 'string') {
return JSON.parse(body) as AccessTokenResponse;
}
return body as AccessTokenResponse;
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
`Request to Sumsub TEE timed out after ${FETCH_TIMEOUT_MS / 1000}s`,
);
}
throw new Error(`Failed to get Sumsub access token: ${err.message}`);
}
throw new Error('Failed to get Sumsub access token: Unknown error');
}
};
export const launchSumsub = async (
config: SumsubConfig,
): Promise<SumsubResult> => {
const sdk = SNSMobileSDK.init(config.accessToken, async () => {
// Token refresh not implemented for test flow
throw new Error(
'Sumsub token expired - refresh not implemented for test flow',
);
})
.withHandlers({
onStatusChanged: event => {
console.log(`Sumsub status: ${event.prevStatus} => ${event.newStatus}`);
config.onStatusChanged?.(event.prevStatus, event.newStatus);
},
onLog: _event => {
// Log event received but don't log message (may contain PII)
console.log('[Sumsub] Log event received');
},
onEvent: event => {
// Only log event type, not full payload (may contain PII)
console.log(`Sumsub event: ${event.eventType}`);
config.onEvent?.(event.eventType, event.payload);
},
})
.withDebug(config.debug ?? __DEV__)
.withLocale(config.locale ?? 'en')
.withAnalyticsEnabled(true) // Device Intelligence requires this
.build();
return sdk.launch();
};

View File

@@ -0,0 +1,40 @@
// 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 interface AccessTokenResponse {
token: string;
userId: string;
}
export interface SumsubApplicantInfo {
id: string;
createdAt: string;
key: string;
clientId: string;
inspectionId: string;
externalUserId: string;
info?: {
firstName?: string;
lastName?: string;
dob?: string;
country?: string;
phone?: string;
};
email?: string;
phone?: string;
review: {
reviewAnswer: string;
reviewResult: {
reviewAnswer: string;
};
};
type: string;
}
export interface SumsubResult {
success: boolean;
status: string;
errorType?: string;
errorMsg?: string;
}

View File

@@ -13,6 +13,7 @@ import DevHapticFeedbackScreen from '@/screens/dev/DevHapticFeedbackScreen';
import DevLoadingScreen from '@/screens/dev/DevLoadingScreen';
import DevPrivateKeyScreen from '@/screens/dev/DevPrivateKeyScreen';
import DevSettingsScreen from '@/screens/dev/DevSettingsScreen';
import SumsubTestScreen from '@/screens/dev/SumsubTestScreen';
const devHeaderOptions: NativeStackNavigationOptions = {
headerStyle: {
@@ -21,6 +22,7 @@ const devHeaderOptions: NativeStackNavigationOptions = {
headerTitleStyle: {
color: white,
},
headerTintColor: white,
headerBackTitle: 'close',
};
@@ -80,6 +82,13 @@ const devScreens = {
title: 'Dev Loading Screen',
} as NativeStackNavigationOptions,
},
SumsubTest: {
screen: SumsubTestScreen,
options: {
...devHeaderOptions,
title: 'Sumsub Test',
} as NativeStackNavigationOptions,
},
};
export default devScreens;

View File

@@ -681,6 +681,29 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => {
navigation.navigate('SumsubTest');
}}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
Sumsub Test Flow
</Text>
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
{IS_DEV_MODE && (
<Button
style={{ backgroundColor: 'white' }}

View File

@@ -0,0 +1,686 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, ScrollView, TextInput } from 'react-native';
import { io, type Socket } from 'socket.io-client';
import { Button, Text, XStack, YStack } from 'tamagui';
import { SUMSUB_TEE_URL } from '@env';
import { useNavigation } from '@react-navigation/native';
import { ChevronLeft } from '@tamagui/lucide-icons';
import {
green500,
red500,
slate200,
slate400,
slate500,
slate600,
slate800,
white,
yellow500,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import {
fetchAccessToken,
launchSumsub,
type SumsubApplicantInfo,
type SumsubResult,
} from '@/integrations/sumsub';
const SumsubTestScreen: React.FC = () => {
const navigation = useNavigation();
const [phoneNumber, setPhoneNumber] = useState('+11234567890');
const [accessToken, setAccessToken] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [sdkLaunching, setSdkLaunching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SumsubResult | null>(null);
const [applicantInfo, setApplicantInfo] =
useState<SumsubApplicantInfo | null>(null);
const socketRef = useRef<Socket | null>(null);
const hasSubscribedRef = useRef<boolean>(false);
const isMountedRef = useRef<boolean>(true);
const paddingBottom = useSafeBottomPadding(20);
const handleFetchToken = useCallback(async () => {
setLoading(true);
setError(null);
setAccessToken(null);
setUserId(null);
setResult(null);
try {
const response = await fetchAccessToken(phoneNumber);
if (!isMountedRef.current) return;
setAccessToken(response.token);
setUserId(response.userId);
Alert.alert('Success', 'Access token generated successfully', [
{ text: 'OK' },
]);
} catch (err) {
if (!isMountedRef.current) return;
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
Alert.alert('Error', `Failed to fetch access token: ${message}`, [
{ text: 'OK' },
]);
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
}, [phoneNumber]);
const subscribeToWebSocket = useCallback(() => {
if (!userId || hasSubscribedRef.current) {
return;
}
console.log('Connecting to WebSocket:', SUMSUB_TEE_URL);
const socket = io(SUMSUB_TEE_URL, {
transports: ['websocket', 'polling'],
});
socketRef.current = socket;
socket.on('connect', () => {
console.log('Socket connected, subscribing to user');
hasSubscribedRef.current = true;
socket.emit('subscribe', userId);
});
socket.on('success', (data: SumsubApplicantInfo) => {
console.log('Received applicant info');
if (!isMountedRef.current) return;
setApplicantInfo(data);
Alert.alert(
'Verification Complete',
'Your verification was successful!',
[{ text: 'OK' }],
);
});
socket.on('verification_failed', (reason: string) => {
console.log('Verification failed:', reason);
if (!isMountedRef.current) return;
setError(`Verification failed: ${reason}`);
Alert.alert('Verification Failed', reason, [{ text: 'OK' }]);
});
socket.on('error', (errorMessage: string) => {
console.error('Socket error:', errorMessage);
if (!isMountedRef.current) return;
setError(errorMessage);
hasSubscribedRef.current = false;
});
socket.on('disconnect', () => {
console.log('Socket disconnected');
hasSubscribedRef.current = false;
});
}, [userId]);
const handleLaunchSumsub = useCallback(async () => {
if (!accessToken) {
Alert.alert(
'Error',
'No access token available. Please generate one first.',
[{ text: 'OK' }],
);
return;
}
setSdkLaunching(true);
setResult(null);
setError(null);
try {
const sdkResult = await launchSumsub({
accessToken,
debug: true,
locale: 'en',
onEvent: (eventType, _payload) => {
console.log('SDK Event:', eventType);
// Subscribe to WebSocket when verification is completed
if (eventType === 'idCheck.onApplicantVerificationCompleted') {
subscribeToWebSocket();
}
},
});
if (!isMountedRef.current) return;
setResult(sdkResult);
if (sdkResult.success) {
Alert.alert(
'SDK Closed',
`Sumsub SDK closed with status: ${sdkResult.status}`,
[{ text: 'OK' }],
);
} else {
Alert.alert(
'Error',
`Sumsub failed: ${sdkResult.errorMsg || sdkResult.errorType || 'Unknown error'}`,
[{ text: 'OK' }],
);
}
} catch (err) {
console.error('Sumsub launch error:', err);
if (!isMountedRef.current) return;
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
Alert.alert('Error', `Failed to launch Sumsub SDK: ${message}`, [
{ text: 'OK' },
]);
} finally {
if (isMountedRef.current) {
setSdkLaunching(false);
}
}
}, [accessToken, subscribeToWebSocket]);
const handleReset = useCallback(() => {
setApplicantInfo(null);
setAccessToken(null);
setUserId(null);
setResult(null);
setError(null);
hasSubscribedRef.current = false;
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
}, []);
useEffect(() => {
return () => {
isMountedRef.current = false;
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
hasSubscribedRef.current = false;
}
};
}, []);
// If we have applicant info, show that
if (applicantInfo) {
return (
<ScrollView showsVerticalScrollIndicator={false}>
<YStack
gap="$4"
alignItems="center"
backgroundColor="white"
flex={1}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom={paddingBottom}
>
{/* Back Button */}
<XStack width="100%" justifyContent="flex-start">
<Button
backgroundColor="transparent"
borderRadius="$2"
paddingHorizontal="$0"
onPress={() => navigation.goBack()}
icon={<ChevronLeft size={24} color={slate600} />}
>
<Text
color={slate600}
fontSize="$5"
fontFamily={dinot}
fontWeight="600"
>
Back
</Text>
</Button>
</XStack>
{/* Success Header */}
<YStack
width="100%"
backgroundColor={green500}
borderRadius="$4"
padding="$4"
alignItems="center"
>
<Text
fontSize="$7"
color={white}
fontFamily={dinot}
fontWeight="600"
>
Verification Complete
</Text>
<Text fontSize="$4" color={white} fontFamily={dinot} marginTop="$2">
Your verification was successful
</Text>
</YStack>
{/* Applicant Info */}
<YStack
width="100%"
backgroundColor={slate200}
borderRadius="$4"
padding="$4"
gap="$3"
>
<Text
fontSize="$6"
color={slate600}
fontFamily={dinot}
fontWeight="600"
>
Applicant Information
</Text>
<YStack gap="$2">
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Name:
</Text>
<Text
fontSize="$4"
color={slate800}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.info?.firstName || 'N/A'}{' '}
{applicantInfo.info?.lastName || 'N/A'}
</Text>
</XStack>
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Date of Birth:
</Text>
<Text
fontSize="$4"
color={slate800}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.info?.dob || 'N/A'}
</Text>
</XStack>
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Country:
</Text>
<Text
fontSize="$4"
color={slate800}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.info?.country || 'N/A'}
</Text>
</XStack>
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Phone:
</Text>
<Text
fontSize="$4"
color={slate800}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.info?.phone || 'N/A'}
</Text>
</XStack>
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Email:
</Text>
<Text
fontSize="$4"
color={slate800}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.email || 'N/A'}
</Text>
</XStack>
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Review Result:
</Text>
<Text
fontSize="$4"
color={green500}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.review.reviewAnswer}
</Text>
</XStack>
</YStack>
{/* Raw JSON */}
<YStack
marginTop="$2"
backgroundColor={white}
borderRadius="$2"
padding="$3"
>
<Text
fontSize="$3"
color={slate400}
fontFamily={dinot}
fontWeight="600"
marginBottom="$2"
>
Raw Data:
</Text>
<Text fontSize="$2" color={slate500} fontFamily={dinot}>
{JSON.stringify(applicantInfo, null, 2)}
</Text>
</YStack>
</YStack>
<Button
backgroundColor={slate600}
borderRadius="$2"
height="$6"
width="100%"
onPress={handleReset}
>
<Text
color={white}
fontSize="$6"
fontFamily={dinot}
fontWeight="600"
>
Start New Verification
</Text>
</Button>
</YStack>
</ScrollView>
);
}
return (
<ScrollView showsVerticalScrollIndicator={false}>
<YStack
gap="$4"
alignItems="center"
backgroundColor="white"
flex={1}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom={paddingBottom}
>
{/* Back Button */}
<XStack width="100%" justifyContent="flex-start">
<Button
backgroundColor="transparent"
borderRadius="$2"
paddingHorizontal="$0"
onPress={() => navigation.goBack()}
icon={<ChevronLeft size={24} color={slate600} />}
>
<Text
color={slate600}
fontSize="$5"
fontFamily={dinot}
fontWeight="600"
>
Back
</Text>
</Button>
</XStack>
{/* TEE Service Status */}
<YStack
width="100%"
backgroundColor={slate200}
borderRadius="$4"
padding="$4"
>
<Text
fontSize="$5"
color={slate600}
fontFamily={dinot}
fontWeight="600"
>
TEE Service
</Text>
<Text
fontSize="$3"
color={slate500}
fontFamily={dinot}
marginTop="$2"
>
{SUMSUB_TEE_URL}
</Text>
</YStack>
{/* Phone Number Input */}
<YStack width="100%" gap="$2">
<Text
fontSize="$4"
color={slate600}
fontFamily={dinot}
fontWeight="600"
>
Phone Number
</Text>
<TextInput
value={phoneNumber}
onChangeText={setPhoneNumber}
placeholder="+11234567890"
keyboardType="phone-pad"
style={{
backgroundColor: white,
borderWidth: 1,
borderColor: slate200,
borderRadius: 8,
padding: 12,
fontSize: 16,
fontFamily: dinot,
color: slate800,
}}
/>
</YStack>
{/* Generate Token Button */}
<Button
backgroundColor={slate600}
borderRadius="$2"
height="$6"
width="100%"
onPress={handleFetchToken}
disabled={loading || !phoneNumber}
opacity={loading || !phoneNumber ? 0.5 : 1}
>
<Text color={white} fontSize="$6" fontFamily={dinot} fontWeight="600">
{loading ? 'Requesting token…' : 'Generate Access Token'}
</Text>
</Button>
{/* Token Status */}
{accessToken && (
<YStack
width="100%"
backgroundColor={green500}
borderRadius="$4"
padding="$4"
>
<Text
fontSize="$5"
color={white}
fontFamily={dinot}
fontWeight="600"
>
Access Token Generated
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot} marginTop="$2">
User ID: {userId}
</Text>
<Text
fontSize="$2"
color={white}
fontFamily={dinot}
marginTop="$2"
opacity={0.8}
>
Token: {accessToken.substring(0, 30)}...
</Text>
</YStack>
)}
{/* Launch SDK Button */}
{accessToken && (
<Button
backgroundColor={green500}
borderRadius="$2"
height="$6"
width="100%"
onPress={handleLaunchSumsub}
disabled={sdkLaunching}
opacity={sdkLaunching ? 0.5 : 1}
>
<Text
color={white}
fontSize="$6"
fontFamily={dinot}
fontWeight="600"
>
{sdkLaunching ? 'Launching…' : 'Launch Sumsub SDK'}
</Text>
</Button>
)}
{/* Error Display */}
{error && (
<YStack
width="100%"
backgroundColor={red500}
borderRadius="$4"
padding="$4"
>
<Text
fontSize="$5"
color={white}
fontFamily={dinot}
fontWeight="600"
>
Error
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot} marginTop="$2">
{error}
</Text>
</YStack>
)}
{/* SDK Result Display */}
{result && (
<YStack
width="100%"
backgroundColor={slate200}
borderRadius="$4"
padding="$4"
gap="$2"
>
<Text
fontSize="$6"
color={slate600}
fontFamily={dinot}
fontWeight="600"
>
SDK Result
</Text>
<YStack gap="$1">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Success:{' '}
<Text
fontWeight="600"
color={result.success ? green500 : red500}
>
{result.success ? 'Yes' : 'No'}
</Text>
</Text>
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Status:{' '}
<Text fontWeight="600" color={slate600}>
{result.status}
</Text>
</Text>
{result.errorType && (
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Error Type:{' '}
<Text fontWeight="600" color={red500}>
{result.errorType}
</Text>
</Text>
)}
{result.errorMsg && (
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Error Message:{' '}
<Text fontWeight="600" color={red500}>
{result.errorMsg}
</Text>
</Text>
)}
</YStack>
<Text
fontSize="$3"
color={slate500}
fontFamily={dinot}
marginTop="$2"
>
Waiting for verification results from WebSocket...
</Text>
</YStack>
)}
{/* Instructions */}
<YStack
width="100%"
backgroundColor={yellow500}
borderRadius="$4"
padding="$4"
gap="$2"
>
<Text fontSize="$5" color={white} fontFamily={dinot} fontWeight="600">
Instructions
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot}>
1. Make sure the TEE service is running at {SUMSUB_TEE_URL}
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot}>
2. Enter a phone number and tap "Generate Access Token"
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot}>
3. Tap "Launch Sumsub SDK" to start verification
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot}>
4. Complete the verification flow
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot}>
5. Results will appear automatically via WebSocket
</Text>
</YStack>
</YStack>
</ScrollView>
);
};
export default SumsubTestScreen;

56
app/src/types/sumsub.d.ts vendored Normal file
View File

@@ -0,0 +1,56 @@
// 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.
declare module '@sumsub/react-native-mobilesdk-module' {
export interface SumsubEvent {
eventType: string;
payload: Record<string, unknown>;
}
export interface SumsubHandlers {
onStatusChanged?: (event: SumsubStatusChangedEvent) => void;
onLog?: (event: SumsubLogEvent) => void;
onEvent?: (event: SumsubEvent) => void;
}
export interface SumsubLogEvent {
message: string;
}
export interface SumsubResult {
success: boolean;
status: string;
errorType?: string;
errorMsg?: string;
}
export interface SumsubSDK {
withHandlers(handlers: SumsubHandlers): SumsubSDK;
withDebug(debug: boolean): SumsubSDK;
withLocale(locale: string): SumsubSDK;
withAnalyticsEnabled(enabled: boolean): SumsubSDK;
withAutoCloseOnApprove(seconds: number): SumsubSDK;
withApplicantConf(config: { email?: string; phone?: string }): SumsubSDK;
withPreferredDocumentDefinitions(
definitions: Record<string, { idDocType: string; country: string }>,
): SumsubSDK;
withDisableMLKit(disable: boolean): SumsubSDK;
withStrings(strings: Record<string, string>): SumsubSDK;
build(): SumsubSDK;
launch(): Promise<SumsubResult>;
dismiss(): void;
}
export interface SumsubStatusChangedEvent {
prevStatus: string;
newStatus: string;
}
export default class SNSMobileSDK {
static init(
accessToken: string,
tokenExpirationHandler: () => Promise<string>,
): SumsubSDK;
}
}

View File

@@ -7,7 +7,7 @@
// This pattern avoids hoisting issues with jest.mock
import { Buffer } from 'buffer';
import { parseScanResponse, scan } from '@/integrations/nfc/nfcScanner';
import { scan } from '@/integrations/nfc/nfcScanner';
import { PassportReader } from '@/integrations/nfc/passportReader';
// Declare global variable for platform OS that can be modified per-test

View File

@@ -24,6 +24,24 @@ jest.mock('@/services/analytics', () => ({
flush: jest.fn(),
}));
// Mock Sumsub SDK to prevent ES module parsing errors in isolateModules
jest.mock('@sumsub/react-native-mobilesdk-module', () => {
const createBuilder = () => ({
withHandlers: jest.fn().mockReturnThis(),
withDebug: jest.fn().mockReturnThis(),
withLocale: jest.fn().mockReturnThis(),
withAnalyticsEnabled: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue({
launch: jest.fn().mockResolvedValue({ success: true }),
}),
});
return {
__esModule: true,
default: { init: jest.fn(() => createBuilder()) },
};
});
describe('navigation', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -88,6 +106,7 @@ describe('navigation', () => {
'ShowRecoveryPhrase',
'Splash',
'StarfallPushCode',
'SumsubTest',
'WebView',
]);
});

View File

@@ -0,0 +1,14 @@
diff --git a/node_modules/@sumsub/react-native-mobilesdk-module/android/build.gradle b/node_modules/@sumsub/react-native-mobilesdk-module/android/build.gradle
index 1234567..abcdefg 100644
--- a/node_modules/@sumsub/react-native-mobilesdk-module/android/build.gradle
+++ b/node_modules/@sumsub/react-native-mobilesdk-module/android/build.gradle
@@ -69,9 +69,9 @@ dependencies {
implementation "com.sumsub.sns:idensic-mobile-sdk:1.40.2"
// remove comment to enable Device Intelligence
- // implementation "com.sumsub.sns:idensic-mobile-sdk-fisherman:1.40.2"
+ implementation "com.sumsub.sns:idensic-mobile-sdk-fisherman:1.40.2"
// remove comment if you need VideoIdent support
- // implementation "com.sumsub.sns:idensic-mobile-sdk-videoident:1.40.2"
+ implementation "com.sumsub.sns:idensic-mobile-sdk-videoident:1.40.2"
// remove comment if you need EID support

View File

@@ -8486,6 +8486,7 @@ __metadata:
"@sentry/react": "npm:^9.32.0"
"@sentry/react-native": "npm:7.0.1"
"@stablelib/cbor": "npm:^2.0.1"
"@sumsub/react-native-mobilesdk-module": "npm:1.40.2"
"@tamagui/animations-css": "npm:1.126.14"
"@tamagui/animations-react-native": "npm:1.126.14"
"@tamagui/config": "npm:1.126.14"
@@ -8577,7 +8578,7 @@ __metadata:
react-native-safe-area-context: "npm:^5.6.1"
react-native-screens: "npm:4.15.3"
react-native-sqlite-storage: "npm:^6.0.1"
react-native-svg: "npm:15.14.0"
react-native-svg: "npm:15.15.0"
react-native-svg-transformer: "npm:^1.5.1"
react-native-svg-web: "npm:1.0.9"
react-native-url-polyfill: "npm:^3.0.0"
@@ -8586,7 +8587,7 @@ __metadata:
react-qr-barcode-scanner: "npm:^2.1.8"
react-test-renderer: "npm:^18.3.1"
rollup-plugin-visualizer: "npm:^6.0.3"
socket.io-client: "npm:^4.8.1"
socket.io-client: "npm:^4.8.3"
stream-browserify: "npm:^3.0.0"
tamagui: "npm:1.126.14"
ts-morph: "npm:^22.0.0"
@@ -11147,6 +11148,15 @@ __metadata:
languageName: node
linkType: hard
"@sumsub/react-native-mobilesdk-module@npm:1.40.2":
version: 1.40.2
resolution: "@sumsub/react-native-mobilesdk-module@npm:1.40.2"
peerDependencies:
react-native: ">=0.60.0-rc.0 <1.0.x"
checksum: 10c0/fc5f368d3afdffdb27496ab3b152b38bb0ee8b52f8592b2b48e0a3941197de33fd445502123eeca676d0ee1ecaeaa2e7c6573601e06c4eea79dfa2aec64ed75b
languageName: node
linkType: hard
"@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0":
version: 8.0.0
resolution: "@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0"
@@ -29840,9 +29850,9 @@ __metadata:
languageName: node
linkType: hard
"react-native-svg@npm:15.14.0":
version: 15.14.0
resolution: "react-native-svg@npm:15.14.0"
"react-native-svg@npm:15.15.0":
version: 15.15.0
resolution: "react-native-svg@npm:15.15.0"
dependencies:
css-select: "npm:^5.1.0"
css-tree: "npm:^1.1.3"
@@ -29850,7 +29860,7 @@ __metadata:
peerDependencies:
react: "*"
react-native: "*"
checksum: 10c0/5855bee2a76313f580ac3f8c476d07bb63d1a8e5e0883154275be5a1f4224e35f2416b0ba3b03f5d4c637c3b2f9320df34ede918da27188adb6881b3b8ac96c8
checksum: 10c0/0da39529fedcc84d2deaad19e261453069675520cb446a08d97eebc10f1205ce0393a9760ad58bc7b80feaa60e94c30d2d5b90d3111451f260b3888a4fbc2e06
languageName: node
linkType: hard
@@ -31782,7 +31792,7 @@ __metadata:
languageName: node
linkType: hard
"socket.io-client@npm:^4.8.1":
"socket.io-client@npm:^4.8.1, socket.io-client@npm:^4.8.3":
version: 4.8.3
resolution: "socket.io-client@npm:4.8.3"
dependencies: