Merge pull request #1637 from selfxyz/staging

Release to Production - 2026-01-22
This commit is contained in:
Justin Hernandez
2026-02-10 08:08:30 -08:00
committed by GitHub
295 changed files with 29894 additions and 5079 deletions

View File

@@ -20,9 +20,9 @@
## Core Components
1. Identity Verification Hub
- Manages multi-step verification process for passports and EU ID cards
- Manages multi-step verification process for passports, EU ID cards, Aadhaar, and Selfrica ID cards
- Handles document attestation through zero-knowledge proofs
- Implements verification paths: E-PASSPORT and EU_ID_CARD
- Implements verification paths: E-PASSPORT, EU_ID_CARD, AADHAAR, and SELFRICA_ID_CARD
- File: contracts/contracts/IdentityVerificationHubImplV2.sol
2. Document Verification Processing
@@ -40,10 +40,10 @@
- Files: noir/crates/dg1/src/ofac/*.nr
4. Identity Registry Management
- Maintains separate registries for passports and ID cards
- Maintains separate registries for passports, EU ID cards, Aadhaar, and Selfrica
- Handles DSC key commitment registration
- Implements nullifier tracking for duplicate prevention
- File: contracts/contracts/registry/IdentityRegistryImplV1.sol
- Files: contracts/contracts/registry/IdentityRegistryImplV1.sol, IdentityRegistryIdCardImplV1.sol, IdentityRegistryAadhaarImplV1.sol, IdentityRegistrySelfricaImplV1.sol
## Core Workflows

View File

@@ -0,0 +1,47 @@
name: Cache Core SDK Build
description: Cache core SDK build artifacts (common, sdk/core)
inputs:
mode:
description: "save or restore"
required: true
cache-version:
description: Cache version string
required: false
default: v1
fail-on-cache-miss:
description: Fail if cache not found (restore mode only)
required: false
default: "false"
outputs:
cache-hit:
description: Whether cache was hit
value: ${{ steps.restore.outputs.cache-hit }}
runs:
using: composite
steps:
- id: restore
if: inputs.mode == 'restore'
uses: actions/cache/restore@v4
with:
path: |
common/dist
sdk/core/dist
node_modules
sdk/core/node_modules
common/node_modules
key: core-sdk-build-${{ inputs.cache-version }}-${{ github.sha }}
fail-on-cache-miss: ${{ inputs.fail-on-cache-miss }}
- id: save
if: inputs.mode == 'save'
uses: actions/cache/save@v4
with:
path: |
common/dist
sdk/core/dist
node_modules
sdk/core/node_modules
common/node_modules
key: core-sdk-build-${{ inputs.cache-version }}-${{ github.sha }}

View File

@@ -0,0 +1,47 @@
name: Cache Mobile SDK Build
description: Cache mobile SDK build artifacts (common, mobile-sdk-alpha)
inputs:
mode:
description: "save or restore"
required: true
cache-version:
description: Cache version string
required: false
default: v1
fail-on-cache-miss:
description: Fail if cache not found (restore mode only)
required: false
default: "false"
outputs:
cache-hit:
description: Whether cache was hit
value: ${{ steps.restore.outputs.cache-hit }}
runs:
using: composite
steps:
- id: restore
if: inputs.mode == 'restore'
uses: actions/cache/restore@v4
with:
path: |
common/dist
packages/mobile-sdk-alpha/dist
node_modules
packages/mobile-sdk-alpha/node_modules
common/node_modules
key: mobile-sdk-alpha-build-${{ inputs.cache-version }}-${{ github.sha }}
fail-on-cache-miss: ${{ inputs.fail-on-cache-miss }}
- id: save
if: inputs.mode == 'save'
uses: actions/cache/save@v4
with:
path: |
common/dist
packages/mobile-sdk-alpha/dist
node_modules
packages/mobile-sdk-alpha/node_modules
common/node_modules
key: mobile-sdk-alpha-build-${{ inputs.cache-version }}-${{ github.sha }}

View File

@@ -0,0 +1,43 @@
name: Cache SDK Build
description: Cache SDK build artifacts (common, sdk-common, qrcode)
inputs:
mode:
description: "save or restore"
required: true
cache-version:
description: Cache version string
required: false
default: v1
fail-on-cache-miss:
description: Fail if cache not found (restore mode only)
required: false
default: "false"
outputs:
cache-hit:
description: Whether cache was hit
value: ${{ steps.restore.outputs.cache-hit }}
runs:
using: composite
steps:
- id: restore
if: inputs.mode == 'restore'
uses: actions/cache/restore@v4
with:
path: |
common/dist
sdk/sdk-common/dist
sdk/qrcode/dist
key: qrcode-sdk-build-${{ inputs.cache-version }}-${{ github.sha }}
fail-on-cache-miss: ${{ inputs.fail-on-cache-miss }}
- id: save
if: inputs.mode == 'save'
uses: actions/cache/save@v4
with:
path: |
common/dist
sdk/sdk-common/dist
sdk/qrcode/dist
key: qrcode-sdk-build-${{ inputs.cache-version }}-${{ github.sha }}

View File

@@ -32,7 +32,12 @@ concurrency:
jobs:
build:
runs-on: ["128ram"]
runs-on:
- "32ram"
- "self-hosted"
- "selfxyz-org"
# GitHub-hosted runners cap at 360 min (6h); 720 applies if using self-hosted
timeout-minutes: 720
permissions:
contents: read
actions: read
@@ -164,7 +169,7 @@ jobs:
path: output/
run_id: ${{ inputs.run-id }}
- name: Build cpp circuits
- name: Prepare build scripts
run: |
chmod +x circuits/scripts/build/build_cpp.sh
chmod +x circuits/scripts/build/build_single_circuit.sh
@@ -172,46 +177,58 @@ jobs:
# Validate inputs - only one should be provided
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
if [[ "${{ inputs.circuit-type }}" != "" && "${{ inputs.circuit-name }}" != "" ]]; then
echo " Error: Cannot provide both circuit-type and circuit-name. Use only one."
echo "Error: Cannot provide both circuit-type and circuit-name. Use only one."
exit 1
fi
fi
# Check what type of build to perform
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.circuit-name }}" != "" ]]; then
# Build circuits by name
- name: Build cpp circuits (workflow_dispatch by name/type)
if: github.event_name == 'workflow_dispatch' && (inputs.circuit-name != '' || inputs.circuit-type != '')
run: |
if [[ "${{ inputs.circuit-name }}" != "" ]]; then
INPUT_CIRCUITS="${{ inputs.circuit-name }}"
INPUT_CIRCUITS=$(echo "$INPUT_CIRCUITS" | tr -d ' ')
IFS=',' read -ra CIRCUITS_ARRAY <<< "$INPUT_CIRCUITS"
echo "Building selected circuits: ${{ inputs.circuit-name }}"
echo "Building selected circuits by name: ${{ inputs.circuit-name }}"
for circuit_name in "${CIRCUITS_ARRAY[@]}"; do
echo "Building circuit: $circuit_name"
./circuits/scripts/build/build_single_circuit.sh "$circuit_name"
done
elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.circuit-type }}" != "" ]]; then
# Build circuits by type
else
INPUT_CIRCUITS="${{ inputs.circuit-type }}"
INPUT_CIRCUITS=$(echo "$INPUT_CIRCUITS" | tr -d ' ')
IFS=',' read -ra CIRCUITS_ARRAY <<< "$INPUT_CIRCUITS"
echo "Building selected circuits: ${{ inputs.circuit-type }}"
echo "Building selected circuits by type: ${{ inputs.circuit-type }}"
for circuit in "${CIRCUITS_ARRAY[@]}"; do
echo "Building circuit: $circuit"
./circuits/scripts/build/build_cpp.sh "$circuit"
done
else
# Build all circuits (default behavior)
echo "Building all circuits (default behavior)"
./circuits/scripts/build/build_cpp.sh register
./circuits/scripts/build/build_cpp.sh register_id
./circuits/scripts/build/build_cpp.sh register_aadhaar
./circuits/scripts/build/build_cpp.sh disclose
./circuits/scripts/build/build_cpp.sh dsc
fi
- name: Build cpp circuits - register
if: github.event_name != 'workflow_dispatch' || (inputs.circuit-name == '' && inputs.circuit-type == '')
run: ./circuits/scripts/build/build_cpp.sh register
- name: Build cpp circuits - register_id
if: github.event_name != 'workflow_dispatch' || (inputs.circuit-name == '' && inputs.circuit-type == '')
run: ./circuits/scripts/build/build_cpp.sh register_id
- name: Build cpp circuits - register_aadhaar
if: github.event_name != 'workflow_dispatch' || (inputs.circuit-name == '' && inputs.circuit-type == '')
run: ./circuits/scripts/build/build_cpp.sh register_aadhaar
- name: Build cpp circuits - register_kyc
if: github.event_name != 'workflow_dispatch' || (inputs.circuit-name == '' && inputs.circuit-type == '')
run: ./circuits/scripts/build/build_cpp.sh register_kyc
- name: Build cpp circuits - disclose
if: github.event_name != 'workflow_dispatch' || (inputs.circuit-name == '' && inputs.circuit-type == '')
run: ./circuits/scripts/build/build_cpp.sh disclose
- name: Build cpp circuits - dsc
if: github.event_name != 'workflow_dispatch' || (inputs.circuit-name == '' && inputs.circuit-type == '')
run: ./circuits/scripts/build/build_cpp.sh dsc
- name: Upload Artifact
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4
with:

View File

@@ -56,15 +56,10 @@ jobs:
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/core build
- name: Cache build artifacts
uses: actions/cache/save@v4
uses: ./.github/actions/cache-core-sdk-build
with:
path: |
common/dist
sdk/core/dist
node_modules
sdk/core/node_modules
common/node_modules
key: core-sdk-build-${{ github.sha }}
mode: save
cache-version: v1
lint:
runs-on: ubuntu-latest
@@ -80,19 +75,19 @@ jobs:
corepack prepare yarn@4.12.0 --activate
- name: Restore build artifacts
id: build-cache
uses: actions/cache/restore@v4
uses: ./.github/actions/cache-core-sdk-build
with:
path: |
common/dist
sdk/core/dist
node_modules
sdk/core/node_modules
common/node_modules
key: core-sdk-build-${{ github.sha }}
fail-on-cache-miss: true
mode: restore
cache-version: v1
fail-on-cache-miss: false
- name: Install Dependencies
if: steps.build-cache.outputs.cache-hit != 'true'
uses: ./.github/actions/yarn-install
- name: Build dependencies (fallback on cache miss)
if: steps.build-cache.outputs.cache-hit != 'true'
run: |
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/core build
- name: Run linter
run: yarn workspace @selfxyz/core lint
@@ -110,19 +105,19 @@ jobs:
corepack prepare yarn@4.12.0 --activate
- name: Restore build artifacts
id: build-cache
uses: actions/cache/restore@v4
uses: ./.github/actions/cache-core-sdk-build
with:
path: |
common/dist
sdk/core/dist
node_modules
sdk/core/node_modules
common/node_modules
key: core-sdk-build-${{ github.sha }}
fail-on-cache-miss: true
mode: restore
cache-version: v1
fail-on-cache-miss: false
- name: Install Dependencies
if: steps.build-cache.outputs.cache-hit != 'true'
uses: ./.github/actions/yarn-install
- name: Build dependencies (fallback on cache miss)
if: steps.build-cache.outputs.cache-hit != 'true'
run: |
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/core build
- name: Type checking
run: yarn workspace @selfxyz/core types
@@ -140,18 +135,18 @@ jobs:
corepack prepare yarn@4.12.0 --activate
- name: Restore build artifacts
id: build-cache
uses: actions/cache/restore@v4
uses: ./.github/actions/cache-core-sdk-build
with:
path: |
common/dist
sdk/core/dist
node_modules
sdk/core/node_modules
common/node_modules
key: core-sdk-build-${{ github.sha }}
fail-on-cache-miss: true
mode: restore
cache-version: v1
fail-on-cache-miss: false
- name: Install Dependencies
if: steps.build-cache.outputs.cache-hit != 'true'
uses: ./.github/actions/yarn-install
- name: Build dependencies (fallback on cache miss)
if: steps.build-cache.outputs.cache-hit != 'true'
run: |
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/core build
- name: Run tests
run: yarn workspace @selfxyz/core test

View File

@@ -5,7 +5,7 @@ env:
RUBY_VERSION: 3.2
JAVA_VERSION: 17
ANDROID_NDK_VERSION: 27.0.12077973
XCODE_VERSION: 16.4
XCODE_VERSION: 26
# Path configuration
WORKSPACE: ${{ github.workspace }}
APP_PATH: ${{ github.workspace }}/app

View File

@@ -33,7 +33,7 @@ env:
JAVA_VERSION: 17
ANDROID_API_LEVEL: 35
ANDROID_NDK_VERSION: 27.0.12077973
XCODE_VERSION: 16.4
XCODE_VERSION: 26
# Cache versioning - increment these to bust caches when needed
GH_CACHE_VERSION: v1 # Global cache version

View File

@@ -5,7 +5,7 @@ env:
JAVA_VERSION: 17
ANDROID_API_LEVEL: 33
ANDROID_NDK_VERSION: 27.0.12077973
XCODE_VERSION: 16.4
XCODE_VERSION: 26
# Cache versions
GH_CACHE_VERSION: v2 # Global cache version - bumped to invalidate caches
GH_GEMS_CACHE_VERSION: v1 # Ruby gems cache version
@@ -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

@@ -21,15 +21,10 @@ jobs:
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/mobile-sdk-alpha build
- name: Cache build artifacts
uses: actions/cache/save@v4
uses: ./.github/actions/cache-mobile-sdk-build
with:
path: |
common/dist
packages/mobile-sdk-alpha/dist
node_modules
packages/mobile-sdk-alpha/node_modules
common/node_modules
key: mobile-sdk-alpha-build-${{ github.sha }}
mode: save
cache-version: v1
lint:
runs-on: ubuntu-latest
@@ -39,16 +34,17 @@ jobs:
- name: Install Dependencies
uses: ./.github/actions/yarn-install
- name: Restore build artifacts
uses: actions/cache/restore@v4
id: restore-build
uses: ./.github/actions/cache-mobile-sdk-build
with:
path: |
common/dist
packages/mobile-sdk-alpha/dist
node_modules
packages/mobile-sdk-alpha/node_modules
common/node_modules
key: mobile-sdk-alpha-build-${{ github.sha }}
fail-on-cache-miss: true
mode: restore
cache-version: v1
fail-on-cache-miss: false
- name: Build dependencies (fallback on cache miss)
if: steps.restore-build.outputs.cache-hit != 'true'
run: |
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/mobile-sdk-alpha build
- name: Run linter
run: yarn workspace @selfxyz/mobile-sdk-alpha lint
@@ -60,16 +56,17 @@ jobs:
- name: Install Dependencies
uses: ./.github/actions/yarn-install
- name: Restore build artifacts
uses: actions/cache/restore@v4
id: restore-build
uses: ./.github/actions/cache-mobile-sdk-build
with:
path: |
common/dist
packages/mobile-sdk-alpha/dist
node_modules
packages/mobile-sdk-alpha/node_modules
common/node_modules
key: mobile-sdk-alpha-build-${{ github.sha }}
fail-on-cache-miss: true
mode: restore
cache-version: v1
fail-on-cache-miss: false
- name: Build dependencies (fallback on cache miss)
if: steps.restore-build.outputs.cache-hit != 'true'
run: |
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/mobile-sdk-alpha build
- name: Check Prettier formatting
run: yarn workspace @selfxyz/mobile-sdk-alpha prettier --check .
@@ -81,16 +78,17 @@ jobs:
- name: Install Dependencies
uses: ./.github/actions/yarn-install
- name: Restore build artifacts
uses: actions/cache/restore@v4
id: restore-build
uses: ./.github/actions/cache-mobile-sdk-build
with:
path: |
common/dist
packages/mobile-sdk-alpha/dist
node_modules
packages/mobile-sdk-alpha/node_modules
common/node_modules
key: mobile-sdk-alpha-build-${{ github.sha }}
fail-on-cache-miss: true
mode: restore
cache-version: v1
fail-on-cache-miss: false
- name: Build dependencies (fallback on cache miss)
if: steps.restore-build.outputs.cache-hit != 'true'
run: |
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/mobile-sdk-alpha build
- name: Type checking
run: yarn workspace @selfxyz/mobile-sdk-alpha types
@@ -102,15 +100,16 @@ jobs:
- name: Install Dependencies
uses: ./.github/actions/yarn-install
- name: Restore build artifacts
uses: actions/cache/restore@v4
id: restore-build
uses: ./.github/actions/cache-mobile-sdk-build
with:
path: |
common/dist
packages/mobile-sdk-alpha/dist
node_modules
packages/mobile-sdk-alpha/node_modules
common/node_modules
key: mobile-sdk-alpha-build-${{ github.sha }}
fail-on-cache-miss: true
mode: restore
cache-version: v1
fail-on-cache-miss: false
- name: Build dependencies (fallback on cache miss)
if: steps.restore-build.outputs.cache-hit != 'true'
run: |
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/mobile-sdk-alpha build
- name: Run tests
run: yarn workspace @selfxyz/mobile-sdk-alpha test

View File

@@ -5,7 +5,7 @@ env:
JAVA_VERSION: 17
ANDROID_API_LEVEL: 33
ANDROID_NDK_VERSION: 27.0.12077973
XCODE_VERSION: 16.4
XCODE_VERSION: 26
# Cache versions
GH_CACHE_VERSION: v1
GH_GEMS_CACHE_VERSION: v1
@@ -457,8 +457,39 @@ jobs:
fi
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 packages/mobile-sdk-demo/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 packages/mobile-sdk-demo/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: Build iOS Release Archive (unsigned)
run: |
echo "Building iOS Release archive (unsigned) to validate Release configuration..."
WORKSPACE_PATH="${{ env.IOS_WORKSPACE_PATH }}"
FORCE_BUNDLING=1 RCT_NO_LAUNCH_PACKAGER=1 \
xcodebuild archive \
-workspace "$WORKSPACE_PATH" \
-scheme ${{ env.IOS_PROJECT_SCHEME }} \
-configuration Release \
-archivePath packages/mobile-sdk-demo/ios/build/SelfDemoApp.xcarchive \
-destination "generic/platform=iOS" \
-jobs "$(sysctl -n hw.ncpu)" \
-parallelizeTargets \
-quiet \
COMPILER_INDEX_STORE_ENABLE=NO \
SWIFT_COMPILATION_MODE=wholemodule \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
AD_HOC_CODE_SIGNING_ALLOWED=NO \
|| { echo "❌ iOS Release archive build failed"; exit 1; }
echo "✅ iOS Release archive build succeeded (unsigned)"
# Verify archive was created
if [ -d "packages/mobile-sdk-demo/ios/build/SelfDemoApp.xcarchive" ]; then
echo "📦 Archive created at packages/mobile-sdk-demo/ios/build/SelfDemoApp.xcarchive"
else
echo "❌ Archive not found"
exit 1
fi
- name: Install and Test on iOS
run: |
echo "Installing app on simulator..."

View File

@@ -12,11 +12,26 @@ on:
- "sdk/qrcode-angular/package.json"
- "contracts/package.json"
workflow_dispatch:
inputs:
strict_mode:
description: "Fail workflow on publish errors (false = continue on error)"
required: false
type: boolean
default: false
permissions:
id-token: write # Required for OIDC
contents: read
# Error Handling Strategy:
# - STRICT_PUBLISH_MODE controls whether publish failures stop the workflow
# - Current (false): continue-on-error=true, workflow always succeeds
# - Target (true): continue-on-error=false, fail on real errors (expired tokens, network issues)
# - Manual override: Use workflow_dispatch with strict_mode input to test
# TODO: Set STRICT_PUBLISH_MODE=true once NPM token is rotated and verified
env:
STRICT_PUBLISH_MODE: false
jobs:
detect-changes:
runs-on: ubuntu-latest
@@ -86,8 +101,21 @@ jobs:
run: |
yarn workspace @selfxyz/core build:deps
- name: Check NPM Token
id: check-token
run: |
if [ -z "${{ secrets.NPM_TOKEN }}" ]; then
echo "⚠️ Warning: NPM_TOKEN is not set. Skipping publish."
echo "token_available=false" >> $GITHUB_OUTPUT
else
echo "token_available=true" >> $GITHUB_OUTPUT
fi
- name: Publish to npm
if: steps.check-token.outputs.token_available == 'true'
working-directory: sdk/core
continue-on-error: ${{ github.event.inputs.strict_mode != 'true' && env.STRICT_PUBLISH_MODE != 'true' }}
id: publish
run: |
yarn config set npmPublishAccess public
yarn npm publish --access public
@@ -95,6 +123,17 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish result
if: always()
run: |
if [ "${{ steps.check-token.outputs.token_available }}" != "true" ]; then
echo "::warning::NPM publish skipped - NPM_TOKEN not configured. Please rotate the token in repository secrets."
elif [ "${{ steps.publish.outcome }}" != "success" ]; then
echo "::warning::NPM publish failed - This may be due to an expired or invalid NPM_TOKEN. Please check and rotate the token."
else
echo "✅ Package published successfully"
fi
publish-qrcode:
needs: detect-changes
if: needs.detect-changes.outputs.qrcode_changed == 'true'
@@ -114,8 +153,21 @@ jobs:
run: |
yarn workspace @selfxyz/qrcode build:deps
- name: Check NPM Token
id: check-token
run: |
if [ -z "${{ secrets.NPM_TOKEN }}" ]; then
echo "⚠️ Warning: NPM_TOKEN is not set. Skipping publish."
echo "token_available=false" >> $GITHUB_OUTPUT
else
echo "token_available=true" >> $GITHUB_OUTPUT
fi
- name: Publish to npm
if: steps.check-token.outputs.token_available == 'true'
working-directory: sdk/qrcode
continue-on-error: ${{ github.event.inputs.strict_mode != 'true' && env.STRICT_PUBLISH_MODE != 'true' }}
id: publish
run: |
yarn config set npmPublishAccess public
yarn npm publish --access public
@@ -123,6 +175,17 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish result
if: always()
run: |
if [ "${{ steps.check-token.outputs.token_available }}" != "true" ]; then
echo "::warning::NPM publish skipped - NPM_TOKEN not configured. Please rotate the token in repository secrets."
elif [ "${{ steps.publish.outcome }}" != "success" ]; then
echo "::warning::NPM publish failed - This may be due to an expired or invalid NPM_TOKEN. Please check and rotate the token."
else
echo "✅ Package published successfully"
fi
publish-common:
needs: detect-changes
if: needs.detect-changes.outputs.common_changed == 'true'
@@ -141,14 +204,38 @@ jobs:
run: |
yarn workspace @selfxyz/common build
- name: Check NPM Token
id: check-token
run: |
if [ -z "${{ secrets.NPM_TOKEN }}" ]; then
echo "⚠️ Warning: NPM_TOKEN is not set. Skipping publish."
echo "token_available=false" >> $GITHUB_OUTPUT
else
echo "token_available=true" >> $GITHUB_OUTPUT
fi
- name: Publish to npm
if: steps.check-token.outputs.token_available == 'true'
working-directory: common
continue-on-error: ${{ github.event.inputs.strict_mode != 'true' && env.STRICT_PUBLISH_MODE != 'true' }}
id: publish
run: |
yarn config set npmPublishAccess public
yarn npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish result
if: always()
run: |
if [ "${{ steps.check-token.outputs.token_available }}" != "true" ]; then
echo "::warning::NPM publish skipped - NPM_TOKEN not configured. Please rotate the token in repository secrets."
elif [ "${{ steps.publish.outcome }}" != "success" ]; then
echo "::warning::NPM publish failed - This may be due to an expired or invalid NPM_TOKEN. Please check and rotate the token."
else
echo "✅ Package published successfully"
fi
publish-contracts:
needs: detect-changes
if: needs.detect-changes.outputs.contracts_changed == 'true'
@@ -165,14 +252,38 @@ jobs:
- name: Build package
run: |
yarn workspace @selfxyz/contracts build
- name: Check NPM Token
id: check-token
run: |
if [ -z "${{ secrets.NPM_TOKEN }}" ]; then
echo "⚠️ Warning: NPM_TOKEN is not set. Skipping publish."
echo "token_available=false" >> $GITHUB_OUTPUT
else
echo "token_available=true" >> $GITHUB_OUTPUT
fi
- name: Publish to npm
if: steps.check-token.outputs.token_available == 'true'
working-directory: contracts
continue-on-error: ${{ github.event.inputs.strict_mode != 'true' && env.STRICT_PUBLISH_MODE != 'true' }}
id: publish
run: |
yarn config set npmPublishAccess public
yarn npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish result
if: always()
run: |
if [ "${{ steps.check-token.outputs.token_available }}" != "true" ]; then
echo "::warning::NPM publish skipped - NPM_TOKEN not configured. Please rotate the token in repository secrets."
elif [ "${{ steps.publish.outcome }}" != "success" ]; then
echo "::warning::NPM publish failed - This may be due to an expired or invalid NPM_TOKEN. Please check and rotate the token."
else
echo "✅ Package published successfully"
fi
publish-qrcode-angular:
needs: detect-changes
if: needs.detect-changes.outputs.qrcode_angular_changed == 'true'
@@ -192,8 +303,21 @@ jobs:
run: |
yarn workspace @selfxyz/qrcode-angular build:deps
- name: Check NPM Token
id: check-token
run: |
if [ -z "${{ secrets.NPM_TOKEN }}" ]; then
echo "⚠️ Warning: NPM_TOKEN is not set. Skipping publish."
echo "token_available=false" >> $GITHUB_OUTPUT
else
echo "token_available=true" >> $GITHUB_OUTPUT
fi
- name: Publish to npm
if: steps.check-token.outputs.token_available == 'true'
working-directory: sdk/qrcode-angular
continue-on-error: ${{ github.event.inputs.strict_mode != 'true' && env.STRICT_PUBLISH_MODE != 'true' }}
id: publish
run: |
yarn config set npmPublishAccess public
yarn npm publish --access public
@@ -201,6 +325,17 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish result
if: always()
run: |
if [ "${{ steps.check-token.outputs.token_available }}" != "true" ]; then
echo "::warning::NPM publish skipped - NPM_TOKEN not configured. Please rotate the token in repository secrets."
elif [ "${{ steps.publish.outcome }}" != "success" ]; then
echo "::warning::NPM publish failed - This may be due to an expired or invalid NPM_TOKEN. Please check and rotate the token."
else
echo "✅ Package published successfully"
fi
publish-msdk:
needs: detect-changes
if: needs.detect-changes.outputs.msdk_changed == 'true'
@@ -221,11 +356,35 @@ jobs:
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/mobile-sdk-alpha build
- name: Check NPM Token
id: check-token
run: |
if [ -z "${{ secrets.NPM_TOKEN }}" ]; then
echo "⚠️ Warning: NPM_TOKEN is not set. Skipping publish."
echo "token_available=false" >> $GITHUB_OUTPUT
else
echo "token_available=true" >> $GITHUB_OUTPUT
fi
- name: Publish to npm
if: steps.check-token.outputs.token_available == 'true'
working-directory: packages/mobile-sdk-alpha
continue-on-error: ${{ github.event.inputs.strict_mode != 'true' && env.STRICT_PUBLISH_MODE != 'true' }}
id: publish
run: |
yarn config set npmPublishAccess restricted
yarn npm publish --access restricted --tag alpha
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish result
if: always()
run: |
if [ "${{ steps.check-token.outputs.token_available }}" != "true" ]; then
echo "::warning::NPM publish skipped - NPM_TOKEN not configured. Please rotate the token in repository secrets."
elif [ "${{ steps.publish.outcome }}" != "success" ]; then
echo "::warning::NPM publish failed - This may be due to an expired or invalid NPM_TOKEN. Please check and rotate the token."
else
echo "✅ Package published successfully"
fi

View File

@@ -80,16 +80,14 @@ jobs:
- name: Cache Yarn dependencies
id: yarn-cache
uses: actions/cache@v4
uses: ./.github/actions/cache-yarn
with:
path: |
.yarn/cache
node_modules
sdk/qrcode/node_modules
common/node_modules
key: ${{ runner.os }}-${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ runner.os }}-${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-
cache-version: ${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}
- name: Install Dependencies
uses: ./.github/actions/yarn-install
@@ -102,13 +100,10 @@ jobs:
yarn workspace @selfxyz/qrcode build
- name: Cache build artifacts
uses: actions/cache/save@v4
uses: ./.github/actions/cache-sdk-build
with:
path: |
common/dist
sdk/sdk-common/dist
sdk/qrcode/dist
key: qrcode-sdk-build-${{ env.GH_SDK_CACHE_VERSION }}-${{ github.sha }}
mode: save
cache-version: ${{ env.GH_SDK_CACHE_VERSION }}
# Quality checks job
quality-checks:
@@ -141,29 +136,32 @@ jobs:
- name: Cache Yarn dependencies
id: yarn-cache
uses: actions/cache@v4
uses: ./.github/actions/cache-yarn
with:
path: |
.yarn/cache
node_modules
sdk/qrcode/node_modules
common/node_modules
key: ${{ runner.os }}-${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ runner.os }}-${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-
cache-version: ${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}
- name: Install Dependencies
uses: ./.github/actions/yarn-install
- name: Restore build artifacts
uses: actions/cache/restore@v4
id: restore-build
uses: ./.github/actions/cache-sdk-build
with:
path: |
common/dist
sdk/sdk-common/dist
sdk/qrcode/dist
key: qrcode-sdk-build-${{ env.GH_SDK_CACHE_VERSION }}-${{ github.sha }}
fail-on-cache-miss: true
mode: restore
cache-version: ${{ env.GH_SDK_CACHE_VERSION }}
fail-on-cache-miss: false
- name: Build dependencies (fallback on cache miss)
if: steps.restore-build.outputs.cache-hit != 'true'
run: |
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/sdk-common build
yarn workspace @selfxyz/qrcode build
- name: Run linter
run: yarn workspace @selfxyz/qrcode lint:imports:check
@@ -210,29 +208,32 @@ jobs:
- name: Cache Yarn dependencies
id: yarn-cache
uses: actions/cache@v4
uses: ./.github/actions/cache-yarn
with:
path: |
.yarn/cache
node_modules
sdk/qrcode/node_modules
common/node_modules
key: ${{ runner.os }}-${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ runner.os }}-${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-
cache-version: ${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}
- name: Install Dependencies
uses: ./.github/actions/yarn-install
- name: Restore build artifacts
uses: actions/cache/restore@v4
id: restore-build
uses: ./.github/actions/cache-sdk-build
with:
path: |
common/dist
sdk/sdk-common/dist
sdk/qrcode/dist
key: qrcode-sdk-build-${{ env.GH_SDK_CACHE_VERSION }}-${{ github.sha }}
fail-on-cache-miss: true
mode: restore
cache-version: ${{ env.GH_SDK_CACHE_VERSION }}
fail-on-cache-miss: false
- name: Build dependencies (fallback on cache miss)
if: steps.restore-build.outputs.cache-hit != 'true'
run: |
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/sdk-common build
yarn workspace @selfxyz/qrcode build
- name: Verify build artifacts
run: |
@@ -273,29 +274,41 @@ jobs:
- name: Cache Yarn dependencies
id: yarn-cache
uses: actions/cache@v4
uses: ./.github/actions/cache-yarn
with:
path: |
.yarn/cache
node_modules
sdk/qrcode/node_modules
common/node_modules
key: ${{ runner.os }}-${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ runner.os }}-${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-
cache-version: ${{ env.GH_YARN_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}
- name: Install Dependencies
uses: ./.github/actions/yarn-install
- name: Restore build artifacts
uses: actions/cache/restore@v4
id: restore-build
uses: ./.github/actions/cache-sdk-build
with:
path: |
common/dist
sdk/sdk-common/dist
sdk/qrcode/dist
key: qrcode-sdk-build-${{ env.GH_SDK_CACHE_VERSION }}-${{ github.sha }}
fail-on-cache-miss: true
mode: restore
cache-version: ${{ env.GH_SDK_CACHE_VERSION }}
fail-on-cache-miss: false
- name: Build dependencies (fallback on cache miss)
if: steps.restore-build.outputs.cache-hit != 'true'
run: |
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/sdk-common build
yarn workspace @selfxyz/qrcode build
- name: Check for nested require() in tests
run: |
# Check SDK tests for nested require patterns that cause OOM
if grep -rE "require\(['\"]react(-native)?['\"])" sdk/qrcode/src/ sdk/qrcode/tests/ 2>/dev/null; then
echo "❌ Found nested require() patterns that cause OOM in CI"
exit 1
fi
echo "✅ No nested require() patterns found"
- name: Run tests
run: yarn workspace @selfxyz/qrcode test

View File

@@ -153,12 +153,28 @@ jobs:
echo "✓ Successfully pushed branch ${BRANCH_NAME}"
fi
- name: Read app version
if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.check_dev_staging.outputs.existing_pr == '' }}
id: app_version
shell: bash
run: |
set -euo pipefail
python - <<PY >> "$GITHUB_OUTPUT"
import json
import pathlib
package_json = pathlib.Path("app/package.json")
version = json.loads(package_json.read_text())["version"]
print(f"app_version={version}")
PY
- name: Create dev to staging release PR
if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.check_dev_staging.outputs.existing_pr == '' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_DATE: ${{ steps.check_dev_staging.outputs.date }}
BRANCH_NAME: ${{ steps.check_dev_staging.outputs.branch_name }}
APP_VERSION: ${{ steps.app_version.outputs.app_version }}
shell: bash
run: |
set -euo pipefail
@@ -200,7 +216,7 @@ jobs:
"""))
PY
TITLE="Release to Staging - ${PR_DATE}"
TITLE="Release to Staging v${APP_VERSION} - ${PR_DATE}"
echo "Creating PR with title: ${TITLE} from branch ${BRANCH_NAME}"
if ! gh pr create \
@@ -319,12 +335,28 @@ jobs:
gh label create "${LABEL}" --color BFD4F2 --force || true
done
- name: Read app version
if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.staging_not_ahead != 'true' && steps.production_status.outputs.existing_pr == '' }}
id: app_version
shell: bash
run: |
set -euo pipefail
python - <<PY >> "$GITHUB_OUTPUT"
import json
import pathlib
package_json = pathlib.Path("app/package.json")
version = json.loads(package_json.read_text())["version"]
print(f"app_version={version}")
PY
- name: Create staging to main release PR
if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.staging_not_ahead != 'true' && steps.production_status.outputs.existing_pr == '' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_DATE: ${{ steps.production_status.outputs.date }}
COMMITS_AHEAD: ${{ steps.production_status.outputs.commits }}
APP_VERSION: ${{ steps.app_version.outputs.app_version }}
shell: bash
run: |
set -euo pipefail
@@ -367,7 +399,7 @@ jobs:
"""))
PY
TITLE="Release to Production - ${PR_DATE}"
TITLE="Release to Production v${APP_VERSION} - ${PR_DATE}"
echo "Creating PR with title: ${TITLE} from staging with ${COMMITS_AHEAD} commits ahead."
gh pr create \

View File

@@ -5,6 +5,7 @@
8bc1e85075f73906767652ab35d5563efce2a931:packages/mobile-sdk-alpha/src/animations/passport_verify.json:aws-access-token:6
0e4555eee6589aa9cca68f451227b149277d8c90:app/tests/src/utils/points/api.test.ts:generic-api-key:34
circuits/circuits/gcp_jwt_verifier/example_jwt.txt:jwt:1
circuits/circuits/gcp_jwt_verifier/example_jwt_fail.txt:jwt:1
cadd7ae5b768c261230f84426eac879c1853ce70:app/ios/Podfile.lock:generic-api-key:2586
aeb8287078f088ecd8e9430e3f6a9f2c593ef1fc:app/src/utils/points/constants.ts:generic-api-key:7
app/src/services/points/constants.ts:generic-api-key:10

View File

@@ -101,4 +101,3 @@ We are actively looking for contributors. Please check the [open issues](https:/
Thanks [Rémi](https://github.com/remicolin), [Florent](https://github.com/0xturboblitz), [Ayman](https://github.com/Nesopie), [Justin](https://github.com/transphorm), [Seshanth](https://github.com/seshanthS), [Nico](https://github.com/motemotech) and all other contributors for building Self.
Thanks [Aayush](https://twitter.com/yush_g), [Vivek](https://twitter.com/viv_boop), [Andy](https://twitter.com/AndyGuzmanEth) and [Vitalik](https://github.com/vbuterin) for contributing ideas and inspiring us to build this technology, and [PSE](https://pse.dev/) for supporting the initial work through grants!

View File

@@ -23,8 +23,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1201.0)
aws-sdk-core (3.241.3)
aws-partitions (1.1213.0)
aws-sdk-core (3.242.0)
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.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -230,7 +230,7 @@ GEM
i18n (1.14.8)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.18.0)
json (2.18.1)
jwt (2.10.2)
base64
logger (1.7.0)
@@ -254,7 +254,7 @@ GEM
optparse (0.8.1)
os (1.1.4)
plist (3.7.2)
prism (1.7.0)
prism (1.9.0)
public_suffix (4.0.7)
racc (1.8.1)
rake (13.3.1)

View File

@@ -134,8 +134,8 @@ android {
applicationId "com.proofofpassportapp"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 121
versionName "2.9.11"
versionCode 140
versionName "2.9.15"
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
externalNativeBuild {
cmake {
@@ -231,8 +231,9 @@ dependencies {
implementation "com.google.guava:guava:31.1-android"
implementation "androidx.profileinstaller:profileinstaller:1.3.1"
implementation "androidx.activity:activity:1.9.3"
implementation "androidx.activity:activity-ktx:1.9.3"
implementation "androidx.activity:activity:1.10.1"
implementation "androidx.activity:activity-ktx:1.10.1"
implementation "com.google.android.material:material:1.12.0"
implementation "com.google.android.play:app-update:2.1.0"
}

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

@@ -17,6 +17,8 @@
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".MainApplication"
@@ -25,7 +27,8 @@
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:extractNativeLibs="false"
tools:replace="android:icon, android:roundIcon, android:name, android:extractNativeLibs"
android:allowBackup="false"
tools:replace="android:icon, android:roundIcon, android:name, android:extractNativeLibs, android:allowBackup"
android:theme="@style/AppTheme"
android:supportsRtl="true"
android:usesCleartextTraffic="false"
@@ -71,6 +74,7 @@
<service
android:name="com.google.firebase.messaging.FirebaseMessagingService"
android:foregroundServiceType="dataSync"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
@@ -105,5 +109,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

@@ -5,9 +5,8 @@ package com.proofofpassportapp
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.graphics.Color
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
@@ -44,12 +43,11 @@ class MainActivity : ReactActivity() {
// Prevent fragment state restoration to avoid react-native-screens crash
// See: https://github.com/software-mansion/react-native-screens/issues/17#issuecomment-424704978
super.onCreate(null)
// Ensure edge-to-edge is enabled consistently across Android versions using
// the AndroidX helper so deprecated window color APIs are avoided.
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT)
)
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).apply {
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
// Allow system to manage orientation for large screens
}
}

View File

@@ -256,11 +256,15 @@ class CameraMLKitFragment(cameraMLKitCallback: CameraMLKitCallback) : CameraFrag
if (!isAdded) {
return
}
OcrUtils.processOcr(
results = results,
timeRequired = timeRequired,
callback = mrzListener
)
try {
OcrUtils.processOcr(
results = results,
timeRequired = timeRequired,
callback = mrzListener
)
} catch (e: Exception) {
mrzListener.onFailure(e, timeRequired)
}
}
override fun onCanceled(timeRequired: Long) {

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,9 +28,11 @@ 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;
export const TURNKEY_GOOGLE_CLIENT_ID = process.env.TURNKEY_GOOGLE_CLIENT_ID;
export const TURNKEY_ORGANIZATION_ID = process.env.TURNKEY_ORGANIZATION_ID;

View File

@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.9.11</string>
<string>2.9.15</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -70,7 +70,7 @@
<key>NSHumanReadableCopyright</key>
<string></string>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<string>We use your location to improve document scanning reliability, as performance can vary by region and document type.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need access to your photo library to allow you to choose passport photos or save generated QR codes.</string>
<key>UIAppFonts</key>

View File

@@ -2,8 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
<key>aps-environment</key>
<string>production</string>
<key>com.apple.developer.associated-appclip-app-identifiers</key>
<array>
<string>5B29R5LYHQ.com.warroom.proofofpassport.Clip</string>
@@ -37,7 +37,5 @@
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

View File

@@ -39,7 +39,5 @@
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

View File

@@ -11,9 +11,9 @@ import Foundation
import React
#if !E2E_TESTING
import NFCPassportReader
import Mixpanel
#endif
import Security
import Mixpanel
import Sentry
#if !E2E_TESTING

View File

@@ -1,4 +1,20 @@
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 for fraud detection
# Privacy: Device ID collection declared in app/ios/PrivacyInfo.xcprivacy
ENV["IDENSIC_WITH_FISHERMAN"] = "true"
# VideoIdent module disabled for current release
# This feature provides liveness checks via live video calls with human agents
# Disabled to avoid microphone permission requirements on both platforms
# TODO: Re-enable for future release when liveness checks are needed
# ENV["IDENSIC_WITH_VIDEOIDENT"] = "true"
end
use_frameworks!
require "tmpdir"
@@ -39,9 +55,8 @@ 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!
# Skip NFCPassportReader for e2e testing to avoid build issues
unless ENV["E2E_TESTING"] == "1"
# Check if we're running in a selfxyz repo or an external fork
@@ -66,10 +81,13 @@ target "Self" do
pod "NFCPassportReader", git: nfc_repo_url, commit: "9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b"
end
# Explicitly declare Mixpanel to ensure it's available even in E2E builds
# (NFCPassportReader also includes Mixpanel, but is skipped during E2E testing)
pod "Mixpanel-swift", :modular_headers => true
pod "QKMRZScanner"
pod "lottie-ios"
pod "SwiftQRScanner", :git => "https://github.com/vinodiOS/SwiftQRScanner"
pod "Mixpanel-swift", "~> 5.0.0"
# RNReactNativeHapticFeedback is handled by autolinking
use_react_native!(

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,14 @@ 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
- lottie-ios (4.5.0)
- lottie-react-native (7.2.2):
- DoubleConversion
@@ -1537,9 +1546,13 @@ 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)
- React-Core
- react-native-netinfo (11.4.1):
- React-Core
- react-native-nfc-manager (3.16.3):
- react-native-nfc-manager (3.17.2):
- React-Core
- react-native-passkey (3.3.1):
- DoubleConversion
@@ -1965,7 +1978,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNDeviceInfo (14.1.1):
- RNDeviceInfo (15.0.1):
- React-Core
- RNFBApp (19.3.0):
- Firebase/CoreOnly (= 10.24.0)
@@ -2023,7 +2036,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNLocalize (3.6.0):
- RNLocalize (3.6.1):
- DoubleConversion
- glog
- hermes-engine
@@ -2110,7 +2123,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNSentry (7.0.1):
- RNSentry (7.0.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2133,7 +2146,7 @@ PODS:
- ReactCommon/turbomodule/core
- Sentry/HybridSDK (= 8.53.2)
- Yoga
- RNSVG (15.14.0):
- RNSVG (15.12.1):
- DoubleConversion
- glog
- hermes-engine
@@ -2153,9 +2166,9 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNSVG/common (= 15.14.0)
- RNSVG/common (= 15.12.1)
- Yoga
- RNSVG/common (15.14.0):
- RNSVG/common (15.12.1):
- DoubleConversion
- glog
- hermes-engine
@@ -2203,7 +2216,7 @@ DEPENDENCIES:
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- lottie-ios
- lottie-react-native (from `../node_modules/lottie-react-native`)
- Mixpanel-swift (~> 5.0.0)
- Mixpanel-swift
- "NFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`, commit `9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b`)"
- QKMRZScanner
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
@@ -2243,6 +2256,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 +2310,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
@@ -2416,6 +2433,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 +2553,7 @@ SPEC CHECKSUMS:
DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
FingerprintPro: 035517a1b4e3e4fc073486b53b9956509010f8db
Firebase: 91fefd38712feb9186ea8996af6cbdef41473442
FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe
FirebaseAnalytics: b5efc493eb0f40ec560b04a472e3e1a15d39ca13
@@ -2551,6 +2571,7 @@ SPEC CHECKSUMS:
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
IdensicMobileSDK: 00b13320e1b1e0574e68475bd0fbc7cd30fce26e
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: 7bb65bc88d3f9996ea2f646a96694285405df2f9
Mixpanel-swift: e9bef28a9648faff384d5ba6f48ecc2787eb24c0
@@ -2595,8 +2616,9 @@ SPEC CHECKSUMS:
react-native-cloud-storage: 8d89f2bc574cf11068dfd90933905974087fb9e9
react-native-compat: b80530ebcd3d574be5dd99cb27b984a17c119abc
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-mobilesdk-module: 08c16fea2be97669f8e4c38153106e5fe698126a
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-nfc-manager: 66a00e5ddab9704efebe19d605b1b8afb0bb1bd7
react-native-nfc-manager: c8891e460b4943b695d63f7f4effc6345bbefc83
react-native-passkey: 8853c3c635164864da68a6dbbcec7148506c3bcf
react-native-safe-area-context: a7aad44fe544b55e2369a3086e16a01be60ce398
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
@@ -2630,18 +2652,18 @@ SPEC CHECKSUMS:
ReactCommon: b2eb96a61b826ff327a773a74357b302cf6da678
RNCAsyncStorage: 0003b916f1a69fe2d20b7910e0d08da3d32c7bd6
RNCClipboard: a4827e134e4774e97fa86f7f986694dd89320f13
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388
RNFBApp: 4097f75673f8b42a7cd1ba17e6ea85a94b45e4d1
RNFBMessaging: 92325b0d5619ac90ef023a23cfd16fd3b91d0a88
RNFBRemoteConfig: a569bacaa410acfcaba769370e53a787f80fd13b
RNGestureHandler: a63b531307e5b2e6ea21d053a1a7ad4cf9695c57
RNInAppBrowser: 6d3eb68d471b9834335c664704719b8be1bfdb20
RNKeychain: 471ceef8c13f15a5534c3cd2674dbbd9d0680e52
RNLocalize: 4f5e4a46d2bccd04ccb96721e438dcb9de17c2e0
RNLocalize: 2760999d1e2fc95fb7b7e5247631feb3c08156dc
RNReactNativeHapticFeedback: e526ac4a7ca9fb23c7843ea4fd7d823166054c73
RNScreens: 806e1449a8ec63c2a4e4cf8a63cc80203ccda9b8
RNSentry: 6ad982be2c8e32dab912afb4132b6a0d88484ea0
RNSVG: e1cf5a9a5aa12c69f2ec47031defbd87ae7fb697
RNSentry: f79dd124cc49088445c16d23955860dd0d1db6f3
RNSVG: 0c1fc3e7b147949dc15644845e9124947ac8c9bb
segment-analytics-react-native: 0eae155b0e9fa560fa6b17d78941df64537c35b7
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
@@ -2650,6 +2672,6 @@ SPEC CHECKSUMS:
SwiftyTesseract: 1f3d96668ae92dc2208d9842c8a59bea9fad2cbb
Yoga: 1259c7a8cbaccf7b4c3ddf8ee36ca11be9dee407
PODFILE CHECKSUM: 0aa47f53692543349c43673cda7380fa23049eba
PODFILE CHECKSUM: ced4db0072978f965783277bc810af9a7bebe695
COCOAPODS: 1.16.2

View File

@@ -30,7 +30,20 @@
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeDeviceID</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeFraudPreventionAndSecurity</string>
</array>
</dict>
</array>
<key>NSPrivacyTracking</key>
<false/>
</dict>

View File

@@ -236,7 +236,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1620;
LastUpgradeCheck = 2620;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1430;
@@ -433,8 +433,9 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 5B29R5LYHQ;
ENABLE_APP_SANDBOX = NO;
ENABLE_BITCODE = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\"${PODS_CONFIGURATION_BUILD_DIR}/DoubleConversion\"",
@@ -546,7 +547,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.9.11;
MARKETING_VERSION = 2.9.15;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -574,7 +575,8 @@
CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassport.entitlements;
CURRENT_PROJECT_VERSION = 189;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 5B29R5LYHQ;
ENABLE_APP_SANDBOX = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\"${PODS_CONFIGURATION_BUILD_DIR}/DoubleConversion\"",
@@ -686,7 +688,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.9.11;
MARKETING_VERSION = 2.9.15;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -736,8 +738,10 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CXX = "";
DEVELOPMENT_TEAM = 5B29R5LYHQ;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
@@ -799,6 +803,7 @@
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
USE_HERMES = true;
};
@@ -837,8 +842,10 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
CXX = "";
DEVELOPMENT_TEAM = 5B29R5LYHQ;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
@@ -892,6 +899,7 @@
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -1,8 +1,10 @@
// 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
#if !E2E_TESTING
import Mixpanel
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

@@ -72,6 +72,9 @@ const config = {
new RegExp(
'packages/mobile-sdk-alpha/node_modules/react-native-svg(/|$)',
),
new RegExp(
'packages/mobile-sdk-alpha/node_modules/react-native-webview(/|$)',
),
new RegExp('packages/mobile-sdk-demo/node_modules/react(/|$)'),
new RegExp('packages/mobile-sdk-demo/node_modules/react-dom(/|$)'),
new RegExp('packages/mobile-sdk-demo/node_modules/react-native(/|$)'),

View File

@@ -1,6 +1,6 @@
{
"name": "@selfxyz/mobile-app",
"version": "2.9.11",
"version": "2.9.15",
"private": true,
"type": "module",
"scripts": {
@@ -63,8 +63,8 @@
"test:ci": "yarn jest:run --passWithNoTests && node --test scripts/tests/*.cjs",
"test:coverage": "yarn jest:run --coverage --passWithNoTests",
"test:coverage:ci": "yarn jest:run --coverage --passWithNoTests --ci --coverageReporters=lcov --coverageReporters=text --coverageReporters=json",
"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:e2e:android": "./scripts/test-e2e-local.sh android --ci-match",
"test:e2e:ios": "./scripts/test-e2e-local.sh ios --ci-match",
"test:fastlane": "bundle exec ruby -Itest fastlane/test/helpers_test.rb",
"test:tree-shaking": "node ./scripts/test-tree-shaking.cjs",
"test:web-build": "yarn jest:run tests/web-build-render.test.ts --testTimeout=180000",
@@ -85,13 +85,13 @@
"react-native-webview": "13.16.0"
},
"dependencies": {
"@babel/runtime": "^7.28.3",
"@ethersproject/shims": "^5.7.0",
"@babel/runtime": "^7.28.6",
"@ethersproject/shims": "^5.8.0",
"@noble/hashes": "^1.5.0",
"@openpassport/zk-kit-imt": "^0.0.5",
"@openpassport/zk-kit-lean-imt": "^0.0.6",
"@openpassport/zk-kit-smt": "^0.0.1",
"@peculiar/x509": "^1.13.0",
"@peculiar/x509": "^1.14.3",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-clipboard/clipboard": "1.16.3",
"@react-native-community/blur": "^4.4.1",
@@ -108,8 +108,9 @@
"@selfxyz/euclid": "^0.6.1",
"@selfxyz/mobile-sdk-alpha": "workspace:^",
"@sentry/react": "^9.32.0",
"@sentry/react-native": "7.0.1",
"@sentry/react-native": "7.0.0",
"@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",
@@ -121,7 +122,7 @@
"@turnkey/react-native-wallet-kit": "1.1.5",
"@walletconnect/react-native-compat": "^2.23.0",
"@xstate/react": "^5.0.3",
"asn1js": "^3.0.6",
"asn1js": "^3.0.7",
"axios": "^1.13.2",
"buffer": "^6.0.3",
"country-emoji": "^1.5.6",
@@ -135,8 +136,8 @@
"js-sha512": "^0.9.0",
"lottie-react": "^2.4.1",
"lottie-react-native": "7.2.2",
"node-forge": "^1.3.1",
"pkijs": "^3.2.5",
"node-forge": "^1.3.3",
"pkijs": "^3.3.3",
"poseidon-lite": "^0.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -146,7 +147,7 @@
"react-native-blur-effect": "^1.1.3",
"react-native-check-version": "^1.3.0",
"react-native-cloud-storage": "^2.2.2",
"react-native-device-info": "^14.0.4",
"react-native-device-info": "^15.0.1",
"react-native-dotenv": "^3.4.11",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "2.19.0",
@@ -155,35 +156,35 @@
"react-native-inappbrowser-reborn": "^3.7.0",
"react-native-keychain": "^10.0.0",
"react-native-linear-gradient": "^2.8.3",
"react-native-localize": "^3.5.2",
"react-native-logs": "^5.3.0",
"react-native-nfc-manager": "3.16.3",
"react-native-passkey": "^3.3.1",
"react-native-localize": "^3.6.1",
"react-native-logs": "^5.5.0",
"react-native-nfc-manager": "3.17.2",
"react-native-passkey": "^3.3.2",
"react-native-passport-reader": "1.0.3",
"react-native-safe-area-context": "^5.6.1",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "4.15.3",
"react-native-sqlite-storage": "^6.0.1",
"react-native-svg": "15.14.0",
"react-native-svg": "15.12.1",
"react-native-svg-web": "1.0.9",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "^0.19.0",
"react-native-web": "^0.21.2",
"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",
"zustand": "^4.5.2"
},
"devDependencies": {
"@babel/core": "^7.28.3",
"@babel/plugin-syntax-flow": "^7.27.1",
"@babel/plugin-transform-classes": "^7.27.1",
"@babel/core": "^7.28.6",
"@babel/plugin-syntax-flow": "^7.28.6",
"@babel/plugin-transform-classes": "^7.28.6",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-flow-strip-types": "^7.27.1",
"@babel/plugin-transform-private-methods": "^7.27.1",
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.27.1",
"@babel/plugin-transform-private-methods": "^7.28.6",
"@babel/preset-env": "^7.28.6",
"@babel/preset-react": "^7.28.5",
"@react-native-community/cli": "^16.0.3",
"@react-native/babel-preset": "0.76.9",
"@react-native/eslint-config": "0.76.9",
@@ -209,11 +210,11 @@
"@types/react-test-renderer": "^18",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react-swc": "^3.10.2",
"@vitejs/plugin-react-swc": "^4.2.2",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-transform-remove-console": "^6.9.4",
"constants-browserify": "^1.0.0",
"dompurify": "^3.2.6",
"dompurify": "^3.3.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-typescript": "^3.7.0",
@@ -228,14 +229,15 @@
"jest": "^30.2.0",
"path-browserify": "^1.0.1",
"prettier": "^3.5.3",
"react-native-svg-transformer": "^1.5.1",
"prop-types": "^15.8.1",
"react-native-svg-transformer": "^1.5.2",
"react-test-renderer": "^18.3.1",
"rollup-plugin-visualizer": "^6.0.3",
"rollup-plugin-visualizer": "^6.0.5",
"stream-browserify": "^3.0.0",
"ts-morph": "^22.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-svgr": "^4.5.0"
},
"packageManager": "yarn@4.12.0",

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

@@ -18,20 +18,22 @@ NC='\033[0m' # No Color
print_usage() {
echo "🎭 Local E2E Testing"
echo "Usage: $0 [ios|android] [--workflow-match]"
echo "Usage: $0 [ios|android] [--ci-match|--workflow-match]"
echo ""
echo "Modes:"
echo " (default) - Debug builds, requires Metro server running"
echo " --ci-match - Debug builds with bundled JS (matches GitHub CI exactly)"
echo " --workflow-match - Release builds (legacy, for quick local testing)"
echo ""
echo "Examples:"
echo " $0 ios - Run iOS e2e tests locally"
echo " $0 android - Run Android e2e tests locally"
echo " $0 android --workflow-match - Run Android tests matching GitHub Actions workflow"
echo " $0 ios - Run iOS e2e tests locally (requires Metro)"
echo " $0 ios --ci-match - Run iOS tests matching GitHub CI exactly"
echo " $0 android - Run Android e2e tests locally (requires Metro)"
echo " $0 android --ci-match - Run Android tests matching GitHub CI exactly"
echo ""
echo "Prerequisites:"
echo " iOS: Xcode, iOS Simulator, CocoaPods"
echo " Android: Android SDK, running emulator"
echo ""
echo "Workflow Match Mode:"
echo " --workflow-match - Use Release builds and exact workflow steps"
echo " (No Metro dependency, matches CI environment)"
}
log_info() {
@@ -249,18 +251,39 @@ build_ios_app() {
# Set environment variable for e2e testing to enable OpenSSL fixes
export E2E_TESTING=1
# Set build configuration based on workflow match
if [ "$WORKFLOW_MATCH" = "true" ]; then
if [ "$CI_MATCH" = "true" ]; then
log_info "Using Debug configuration with bundled JS (matches CI)"
BUILD_CONFIG="Debug"
# Match CI xcodebuild flags exactly with FORCE_BUNDLING and RCT_NO_LAUNCH_PACKAGER
if ! FORCE_BUNDLING=1 RCT_NO_LAUNCH_PACKAGER=1 \
xcodebuild -workspace ios/OpenPassport.xcworkspace \
-scheme OpenPassport \
-configuration Debug \
-sdk iphonesimulator \
-derivedDataPath ios/build \
-jobs "$(sysctl -n hw.ncpu)" \
-parallelizeTargets \
COMPILER_INDEX_STORE_ENABLE=NO \
ONLY_ACTIVE_ARCH=YES \
SWIFT_COMPILATION_MODE=wholemodule \
SWIFT_ACTIVE_COMPILATION_CONDITIONS="E2E_TESTING"; then
log_error "iOS build failed"
exit 1
fi
elif [ "$WORKFLOW_MATCH" = "true" ]; then
log_info "Using Release configuration for workflow match"
BUILD_CONFIG="Release"
if ! xcodebuild -workspace ios/OpenPassport.xcworkspace -scheme OpenPassport -configuration "$BUILD_CONFIG" -sdk iphonesimulator -derivedDataPath ios/build -jobs "$(sysctl -n hw.ncpu)" -parallelizeTargets SWIFT_ACTIVE_COMPILATION_CONDITIONS="E2E_TESTING"; then
log_error "iOS build failed"
exit 1
fi
else
log_info "Using Debug configuration for local development"
BUILD_CONFIG="Debug"
fi
if ! xcodebuild -workspace ios/OpenPassport.xcworkspace -scheme OpenPassport -configuration "$BUILD_CONFIG" -sdk iphonesimulator -derivedDataPath ios/build -jobs "$(sysctl -n hw.ncpu)" -parallelizeTargets SWIFT_ACTIVE_COMPILATION_CONDITIONS="E2E_TESTING"; then
log_error "iOS build failed"
exit 1
if ! xcodebuild -workspace ios/OpenPassport.xcworkspace -scheme OpenPassport -configuration "$BUILD_CONFIG" -sdk iphonesimulator -derivedDataPath ios/build -jobs "$(sysctl -n hw.ncpu)" -parallelizeTargets SWIFT_ACTIVE_COMPILATION_CONDITIONS="E2E_TESTING"; then
log_error "iOS build failed"
exit 1
fi
fi
log_success "iOS build succeeded"
}
@@ -436,17 +459,32 @@ setup_android_environment() {
build_android_app() {
log_info "🔨 Building Android APK..."
# Note: Using Release builds to avoid Metro dependency in CI
# Debug builds require Metro server, Release builds have JS bundled
# Run the build inside the android directory so gradlew is available
echo "Current working directory: $(pwd)"
echo "Checking if gradlew exists:"
ls -la android/gradlew || echo "gradlew not found in android/"
cd android
if ! ./gradlew assembleRelease --quiet; then
log_error "Android build failed"
exit 1
if [ "$CI_MATCH" = "true" ]; then
log_info "Using Debug build with bundled JS (matches CI)"
# Force JS bundling in debug build to match CI behavior
if ! ./gradlew assembleDebug -PbundleInDebug=true --quiet; then
log_error "Android build failed"
exit 1
fi
elif [ "$WORKFLOW_MATCH" = "true" ]; then
log_info "Using Release build for workflow match"
if ! ./gradlew assembleRelease --quiet; then
log_error "Android build failed"
exit 1
fi
else
# Default local dev uses Debug (requires Metro, like iOS default)
log_info "Using Debug build for local development"
if ! ./gradlew assembleDebug --quiet; then
log_error "Android build failed"
exit 1
fi
fi
log_success "Android build succeeded"
cd ..
@@ -454,8 +492,14 @@ build_android_app() {
install_android_app() {
log_info "📦 Installing app on emulator..."
# Check if APK was built successfully (matching workflow)
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
# Check if APK was built successfully
if [ "$WORKFLOW_MATCH" = "true" ]; then
# WORKFLOW_MATCH uses Release builds
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
else
# CI_MATCH and default mode both use Debug builds
APK_PATH="android/app/build/outputs/apk/debug/app-debug.apk"
fi
log_info "Looking for APK at: $APK_PATH"
if [ ! -f "$APK_PATH" ]; then
log_error "APK not found at expected location"
@@ -575,7 +619,10 @@ run_ios_tests() {
echo "🍎 Starting local iOS e2e testing..."
shutdown_all_simulators
check_metro_running
# Skip Metro check for CI_MATCH (bundled JS) and WORKFLOW_MATCH (Release)
if [ "$WORKFLOW_MATCH" != "true" ] && [ "$CI_MATCH" != "true" ]; then
check_metro_running
fi
setup_ios_environment
setup_ios_simulator
build_ios_app
@@ -600,8 +647,8 @@ run_android_tests() {
# Set up trap to cleanup emulator on script exit
trap cleanup_android_emulator EXIT
# Only check Metro if not in workflow match mode
if [ "$WORKFLOW_MATCH" != "true" ]; then
# Skip Metro check for CI_MATCH (bundled JS) and WORKFLOW_MATCH (Release)
if [ "$WORKFLOW_MATCH" != "true" ] && [ "$CI_MATCH" != "true" ]; then
check_metro_running
fi
@@ -628,13 +675,16 @@ main() {
exit 1
fi
# Check for workflow match mode
# Check for workflow match mode and CI match mode
WORKFLOW_MATCH="false"
CI_MATCH="false"
for arg in "$@"; do
if [ "$arg" = "--workflow-match" ]; then
WORKFLOW_MATCH="true"
log_info "🔧 Running in workflow match mode (Release builds, no Metro)"
break
elif [ "$arg" = "--ci-match" ]; then
CI_MATCH="true"
log_info "🔧 Running in CI match mode (Debug builds with bundled JS, matches GitHub CI)"
fi
done

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="117" height="72" viewBox="0 0 210 297"><path d="M-111.5625 24.75V136.125H23.539306A82.5 82.5 0 0 1 105 66 82.5 82.5 0 0 1 186.5 136.125H321.5625V24.75ZM105 90.75A57.75 57.75 0 0 0 47.25 148.5 57.75 57.75 0 0 0 105 206.25 57.75 57.75 0 0 0 162.75 148.5 57.75 57.75 0 0 0 105 90.75Zm-216.5625 70.125V272.25h433.125V160.875H186.46068A82.5 82.5 0 0 1 105 231 82.5 82.5 0 0 1 23.5 160.875Z" stroke-width="81.90428162"/></svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@@ -0,0 +1,3 @@
<svg width="100" height="120" viewBox="0 0 100.078 119.796" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M50.0391 119.796C49.4831 119.796 48.8278 119.677 48.0732 119.438C47.3187 119.24 46.5641 118.942 45.8096 118.545C37.3506 114.018 30.1823 109.927 24.3047 106.273C18.4668 102.66 13.7607 99.1051 10.1865 95.6104C6.65202 92.1156 4.07064 88.3626 2.44238 84.3516C0.814128 80.3008 0 75.5947 0 70.2334V26.3896C0 22.6169 0.714844 19.8171 2.14453 17.9902C3.61393 16.1237 5.95703 14.5352 9.17383 13.2246C10.4049 12.748 12.1523 12.0928 14.416 11.2588C16.7194 10.3851 19.2611 9.45182 22.041 8.45898C24.821 7.42643 27.5811 6.43359 30.3213 5.48047C33.1012 4.52734 35.6032 3.6735 37.8271 2.91895C40.0908 2.12467 41.8382 1.52897 43.0693 1.13184C44.1813 0.814128 45.333 0.55599 46.5244 0.357422C47.7555 0.119141 48.9271 0 50.0391 0C51.151 0 52.3226 0.119141 53.5537 0.357422C54.8245 0.55599 55.9961 0.814128 57.0684 1.13184C58.2995 1.52897 60.027 2.12467 62.251 2.91895C64.5146 3.6735 67.0166 4.5472 69.7568 5.54004C72.5368 6.49316 75.2969 7.46615 78.0371 8.45898C80.8171 9.45182 83.3389 10.3652 85.6025 11.1992C87.9059 12.0332 89.6732 12.7083 90.9043 13.2246C94.1608 14.5749 96.5039 16.1634 97.9336 17.9902C99.3633 19.8171 100.078 22.6169 100.078 26.3896V70.2334C100.078 75.5947 99.2839 80.3206 97.6953 84.4111C96.1465 88.5016 93.6048 92.334 90.0703 95.9082C86.5358 99.4427 81.8298 102.997 75.9521 106.571C70.1143 110.185 62.9062 114.176 54.3281 118.545C53.5339 118.942 52.7594 119.24 52.0049 119.438C51.2503 119.677 50.5951 119.796 50.0391 119.796ZM30.8574 82.3857C32.6842 82.3857 34.2132 81.79 35.4443 80.5986L50.1582 65.8252L64.9316 80.5986C66.0833 81.79 67.5527 82.3857 69.3398 82.3857C71.0872 82.3857 72.5566 81.79 73.748 80.5986C74.9395 79.4072 75.5352 77.9378 75.5352 76.1904C75.5352 74.4827 74.9196 73.0531 73.6885 71.9014L58.8555 57.0684L73.748 42.2354C74.9395 41.0042 75.5352 39.5745 75.5352 37.9463C75.5352 36.1989 74.9395 34.7493 73.748 33.5977C72.5964 32.4062 71.1667 31.8105 69.459 31.8105C67.7116 31.8105 66.2422 32.4062 65.0508 33.5977L50.1582 48.4307L35.3252 33.6572C34.0941 32.4658 32.6048 31.8701 30.8574 31.8701C29.1497 31.8701 27.7002 32.4658 26.5088 33.6572C25.3571 34.8089 24.7812 36.2585 24.7812 38.0059C24.7812 39.6341 25.377 41.0439 26.5684 42.2354L41.4609 57.0684L26.5684 71.9609C25.377 73.1523 24.7812 74.5622 24.7812 76.1904C24.7812 77.9378 25.3571 79.4072 26.5088 80.5986C27.7002 81.79 29.1497 82.3857 30.8574 82.3857Z" fill="#FACC15"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -30,7 +30,7 @@ const ModalBackDrop = styled(View, {
height: '100%',
});
export interface FeedbackModalScreenParams {
export interface AlertModalParams {
titleText: string;
bodyText: string;
buttonText: string;
@@ -41,13 +41,13 @@ export interface FeedbackModalScreenParams {
preventDismiss?: boolean;
}
interface FeedbackModalScreenProps {
interface AlertModalProps {
visible: boolean;
modalParams: FeedbackModalScreenParams | null;
modalParams: AlertModalParams | null;
onHideModal?: () => void;
}
const FeedbackModalScreen: React.FC<FeedbackModalScreenProps> = ({
const AlertModal: React.FC<AlertModalProps> = ({
visible,
modalParams,
onHideModal,
@@ -145,4 +145,4 @@ const styles = StyleSheet.create({
},
});
export default FeedbackModalScreen;
export default AlertModal;

View File

@@ -2,24 +2,25 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useState } from 'react';
import { Alert, Modal, StyleSheet, Text, TextInput, View } from 'react-native';
import React from 'react';
import { Modal, StyleSheet, Text, View } from 'react-native';
import { Button, XStack, YStack } from 'tamagui';
import { Caption } from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
slate400,
white,
zinc800,
zinc900,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { advercase, dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import ModalClose from '@/assets/icons/modal_close.svg';
import { openSupportForm } from '@/services/support';
interface FeedbackModalProps {
visible: boolean;
onClose: () => void;
onSubmit: (
onSubmit?: (
feedback: string,
category: string,
name?: string,
@@ -27,65 +28,10 @@ interface FeedbackModalProps {
) => void;
}
const FeedbackModal: React.FC<FeedbackModalProps> = ({
visible,
onClose,
onSubmit,
}) => {
const [feedback, setFeedback] = useState('');
const [category, setCategory] = useState('general');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const categories = [
{ value: 'general', label: 'General Feedback' },
{ value: 'bug', label: 'Bug Report' },
{ value: 'feature', label: 'Feature Request' },
{ value: 'ui', label: 'UI/UX Issue' },
];
const handleSubmit = async () => {
if (!feedback.trim()) {
Alert.alert('Error', 'Please enter your feedback');
return;
}
setIsSubmitting(true);
try {
await onSubmit(
feedback.trim(),
category,
name.trim() || undefined,
email.trim() || undefined,
);
setFeedback('');
setCategory('general');
setName('');
setEmail('');
onClose();
Alert.alert('Success', 'Thank you for your feedback!');
} catch (error) {
console.error('Error submitting feedback:', error);
Alert.alert('Error', 'Failed to submit feedback. Please try again.');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (feedback.trim() || name.trim() || email.trim()) {
Alert.alert(
'Discard Feedback?',
'You have unsaved feedback. Are you sure you want to close?',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Discard', style: 'destructive', onPress: onClose },
],
);
} else {
onClose();
}
const FeedbackModal: React.FC<FeedbackModalProps> = ({ visible, onClose }) => {
const handleSupportForm = async () => {
await openSupportForm();
onClose();
};
return (
@@ -93,93 +39,33 @@ const FeedbackModal: React.FC<FeedbackModalProps> = ({
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={handleClose}
onRequestClose={onClose}
>
<View style={styles.overlay}>
<View style={styles.modalContainer}>
<YStack gap="$4" padding="$4">
<XStack justifyContent="space-between" alignItems="center">
<Text style={styles.title}>Send Feedback</Text>
<Button
size="$2"
variant="outlined"
onPress={handleClose}
disabled={isSubmitting}
>
</Button>
<ModalClose onPress={onClose} />
</XStack>
<YStack gap="$2">
<Caption style={styles.label}>Category</Caption>
<XStack gap="$2" flexWrap="wrap">
{categories.map(cat => (
<Button
key={cat.value}
size="$2"
backgroundColor={
category === cat.value ? white : 'transparent'
}
color={category === cat.value ? black : white}
borderColor={white}
onPress={() => setCategory(cat.value)}
disabled={isSubmitting}
>
{cat.label}
</Button>
))}
</XStack>
</YStack>
<YStack gap="$2">
<Caption style={styles.label}>
Contact Information (Optional)
<YStack gap="$3" alignItems="center" paddingVertical="$2">
<Caption style={styles.messageText}>
Have feedback, suggestions, or found a bug?
</Caption>
<Caption style={styles.messageText}>
Fill out our feedback form and we'll review it as soon as
possible.
</Caption>
<XStack gap="$2">
<TextInput
style={[styles.textInput, { flex: 1, minHeight: 48 }]}
placeholder="Name"
placeholderTextColor={slate400}
value={name}
onChangeText={setName}
editable={!isSubmitting}
/>
<TextInput
style={[styles.textInput, { flex: 1, minHeight: 48 }]}
placeholder="Email"
placeholderTextColor={slate400}
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
editable={!isSubmitting}
/>
</XStack>
</YStack>
<YStack gap="$2">
<Caption style={styles.label}>Your Feedback</Caption>
<TextInput
style={styles.textInput}
placeholder="Tell us what you think, report a bug, or suggest a feature..."
placeholderTextColor={slate400}
value={feedback}
onChangeText={setFeedback}
multiline
numberOfLines={6}
textAlignVertical="top"
editable={!isSubmitting}
/>
</YStack>
<Button
size="$4"
backgroundColor={white}
color={black}
onPress={handleSubmit}
disabled={isSubmitting || !feedback.trim()}
color="$black"
onPress={handleSupportForm}
>
{isSubmitting ? 'Submitting...' : 'Submit Feedback'}
Open Feedback Form
</Button>
</YStack>
</View>
@@ -201,7 +87,6 @@ const styles = StyleSheet.create({
borderRadius: 16,
width: '100%',
maxWidth: 400,
maxHeight: '80%',
borderWidth: 1,
borderColor: zinc800,
},
@@ -211,22 +96,12 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: white,
},
label: {
messageText: {
fontFamily: dinot,
color: white,
fontSize: 14,
fontWeight: '500',
},
textInput: {
backgroundColor: black,
borderWidth: 1,
borderColor: zinc800,
borderRadius: 8,
padding: 12,
color: white,
fontSize: 16,
fontFamily: dinot,
minHeight: 120,
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
},
});

View File

@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { Platform } from 'react-native';
import { SystemBars as EdgeToEdgeSystemBars } from 'react-native-edge-to-edge';
export type { SystemBarStyle } from 'react-native-edge-to-edge';
type SystemBarsProps = React.ComponentProps<typeof EdgeToEdgeSystemBars>;
export const SystemBars: React.FC<SystemBarsProps> = props => {
if (Platform.OS === 'android' || Platform.OS === 'web') {
return null;
}
return <EdgeToEdgeSystemBars {...props} />;
};

View File

@@ -4,8 +4,6 @@
import React, { useMemo } from 'react';
import type { TextProps } from 'react-native';
import type { SystemBarStyle } from 'react-native-edge-to-edge';
import { SystemBars } from 'react-native-edge-to-edge';
import { ChevronLeft, X } from '@tamagui/lucide-icons';
import type { ViewProps } from '@selfxyz/mobile-sdk-alpha/components';
@@ -16,6 +14,9 @@ import {
XStack,
} from '@selfxyz/mobile-sdk-alpha/components';
import type { SystemBarStyle } from '@/components/SystemBars';
import { SystemBars } from '@/components/SystemBars';
interface NavBarProps extends ViewProps {
children: React.ReactNode;
backgroundColor?: string;

View File

@@ -2,9 +2,10 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { SystemBars } from 'react-native-edge-to-edge';
import type { NativeStackHeaderProps } from '@react-navigation/native-stack';
import { SystemBars } from '@/components/SystemBars';
export const HeadlessNavForEuclid = (props: NativeStackHeaderProps) => {
return (
<>

View File

@@ -33,6 +33,8 @@ export const referralBaseUrl = 'https://referral.self.xyz';
export const selfLogoReverseUrl =
'https://storage.googleapis.com/self-logo-reverse/Self%20Logomark%20Reverse.png';
export const selfUrl = 'https://self.xyz';
export const supportFormUrl =
'https://hail-jonquil-ef8.notion.site/2b057801cd128041985dfd6e1722eca1';
export const supportedBiometricIdsUrl =
'https://docs.self.xyz/use-self/self-map-countries-list';
export const telegramUrl = 'https://t.me/selfxyz';

View File

@@ -92,13 +92,22 @@ export const useEarnPointsFlow = ({
}, [hasReferrer, navigation, navigateToPointsProof]);
const showPointsInfoScreen = useCallback(() => {
navigation.navigate('PointsInfo', {
showNextButton: true,
onNextButtonPress: () => {
const callbackId = registerModalCallbacks({
onButtonPress: () => {
showPointsDisclosureModal();
},
onModalDismiss: () => {
if (hasReferrer) {
useUserStore.getState().clearDeepLinkReferrer();
}
},
});
}, [navigation, showPointsDisclosureModal]);
navigation.navigate('PointsInfo', {
showNextButton: true,
callbackId,
});
}, [hasReferrer, navigation, showPointsDisclosureModal]);
const handleReferralFlow = useCallback(async () => {
if (!referrer) {

View File

@@ -0,0 +1,27 @@
// 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 } from 'react';
import type { InjectedErrorType } from '@/stores/errorInjectionStore';
import { useErrorInjectionStore } from '@/stores/errorInjectionStore';
import { IS_DEV_MODE } from '@/utils/devUtils';
/**
* Hook for checking if a specific error should be injected
* Only active in dev mode
*/
export function useErrorInjection() {
const injectedErrors = useErrorInjectionStore(state => state.injectedErrors);
const shouldInjectError = useCallback(
(errorType: InjectedErrorType): boolean => {
if (!IS_DEV_MODE) return false;
return injectedErrors.includes(errorType);
},
[injectedErrors],
);
return { shouldInjectError };
}

View File

@@ -9,7 +9,7 @@ import {
showFeedbackWidget,
} from '@sentry/react-native';
import type { FeedbackModalScreenParams } from '@/components/FeedbackModalScreen';
import type { AlertModalParams } from '@/components/AlertModal';
import { captureFeedback } from '@/config/sentry';
export type FeedbackType = 'button' | 'widget' | 'custom';
@@ -18,8 +18,7 @@ export const useFeedbackModal = () => {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [modalParams, setModalParams] =
useState<FeedbackModalScreenParams | null>(null);
const [modalParams, setModalParams] = useState<AlertModalParams | null>(null);
const showFeedbackModal = useCallback((type: FeedbackType = 'button') => {
if (timeoutRef.current) {
@@ -81,7 +80,7 @@ export const useFeedbackModal = () => {
setIsVisible(false);
}, []);
const showModal = useCallback((params: FeedbackModalScreenParams) => {
const showModal = useCallback((params: AlertModalParams) => {
setModalParams(params);
setIsModalVisible(true);
}, []);

View File

@@ -0,0 +1,127 @@
// 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, useState } from 'react';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { sanitizeErrorMessage } from '@selfxyz/mobile-sdk-alpha';
import { fetchAccessToken, launchSumsub } from '@/integrations/sumsub';
import type { SumsubResult } from '@/integrations/sumsub/types';
import type { RootStackParamList } from '@/navigation';
export type FallbackErrorSource = 'mrz_scan_failed' | 'nfc_scan_failed';
export interface UseSumsubLauncherOptions {
/**
* Country code for the user's document
*/
countryCode: string;
/**
* Error source to track where the Sumsub launch was initiated from
*/
errorSource: FallbackErrorSource;
/**
* Optional callback to handle successful verification
*/
onSuccess?: (result: SumsubResult) => void | Promise<void>;
/**
* Optional callback to handle user cancellation
*/
onCancel?: () => void | Promise<void>;
/**
* Optional callback to handle verification failure
*/
onError?: (error: unknown, result?: SumsubResult) => void | Promise<void>;
}
/**
* Custom hook for launching Sumsub verification with consistent error handling.
*
* Abstracts the common pattern of:
* 1. Fetching access token
* 2. Launching Sumsub SDK
* 3. Handling errors by navigating to fallback screen
* 4. Managing loading state
*
* @example
* ```tsx
* const { launchSumsubVerification, isLoading } = useSumsubLauncher({
* countryCode: 'US',
* errorSource: 'nfc_scan_failed',
* });
*
* <Button onPress={launchSumsubVerification} disabled={isLoading}>
* {isLoading ? 'Loading...' : 'Try Alternative Verification'}
* </Button>
* ```
*/
export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
const { countryCode, errorSource, onSuccess, onCancel, onError } = options;
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [isLoading, setIsLoading] = useState(false);
const launchSumsubVerification = useCallback(async () => {
setIsLoading(true);
try {
const accessToken = await fetchAccessToken();
const result = await launchSumsub({ accessToken: accessToken.token });
// Handle user cancellation
if (!result.success && result.status === 'Interrupted') {
await onCancel?.();
return;
}
// Handle verification failure
if (!result.success) {
const error = result.errorMsg || result.errorType || 'Unknown error';
const safeError = sanitizeErrorMessage(error);
console.error('Sumsub verification failed:', safeError);
// Call custom error handler if provided, otherwise navigate to fallback screen
if (onError) {
await onError(safeError, result);
} else {
// Navigate to the appropriate fallback screen based on error source
if (errorSource === 'mrz_scan_failed') {
navigation.navigate('RegistrationFallbackMRZ', { countryCode });
} else {
navigation.navigate('RegistrationFallbackNFC', { countryCode });
}
}
return;
}
// Handle success
await onSuccess?.(result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const safeError = sanitizeErrorMessage(errorMessage);
console.error('Error launching alternative verification:', safeError);
// Call custom error handler if provided, otherwise navigate to fallback screen
if (onError) {
await onError(safeError);
} else {
// Navigate to the appropriate fallback screen based on error source
if (errorSource === 'mrz_scan_failed') {
navigation.navigate('RegistrationFallbackMRZ', { countryCode });
} else {
navigation.navigate('RegistrationFallbackNFC', { countryCode });
}
}
} finally {
setIsLoading(false);
}
}, [navigation, countryCode, errorSource, onSuccess, onCancel, onError]);
return {
launchSumsubVerification,
isLoading,
};
};

View File

@@ -7,10 +7,12 @@ import type {
ACCESSIBLE,
GetOptions,
SECURITY_LEVEL,
SetOptions,
} from 'react-native-keychain';
import Keychain from 'react-native-keychain';
import { useSettingStore } from '@/stores/settingStore';
import type { ExtendedSetOptions } from '@/types/react-native-keychain';
/**
* Security configuration for keychain operations
*/
@@ -23,6 +25,8 @@ export interface AdaptiveSecurityConfig {
export interface GetSecureOptions {
requireAuth?: boolean;
promptMessage?: string;
/** Whether to use StrongBox-backed key generation on Android. Default: true */
useStrongBox?: boolean;
}
/**
@@ -61,7 +65,8 @@ export async function checkPasscodeAvailable(): Promise<boolean> {
await Keychain.setGenericPassword('test', 'test', {
service: testService,
accessible: Keychain.ACCESSIBLE.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
});
useStrongBox: false,
} as ExtendedSetOptions);
// Clean up test entry
await Keychain.resetGenericPassword({ service: testService });
return true;
@@ -78,7 +83,7 @@ export async function createKeychainOptions(
options: GetSecureOptions,
capabilities?: SecurityCapabilities,
): Promise<{
setOptions: SetOptions;
setOptions: ExtendedSetOptions;
getOptions: GetOptions;
}> {
const config = await getAdaptiveSecurityConfig(
@@ -86,10 +91,14 @@ export async function createKeychainOptions(
capabilities,
);
const setOptions: SetOptions = {
const useStrongBox =
options.useStrongBox ?? useSettingStore.getState().useStrongBox;
const setOptions: ExtendedSetOptions = {
accessible: config.accessible,
...(config.securityLevel && { securityLevel: config.securityLevel }),
...(config.accessControl && { accessControl: config.accessControl }),
useStrongBox,
};
const getOptions: GetOptions = {

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,154 @@
// 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 { alpha2ToAlpha3 } from '@selfxyz/common';
import type {
AccessTokenResponse,
SumsubResult,
} from '@/integrations/sumsub/types';
// Maps Self document type codes to Sumsub document types
type SelfDocumentType = 'p' | 'i';
type SumsubDocumentType = 'PASSPORT' | 'ID_CARD';
const DOCUMENT_TYPE_MAP: Record<SelfDocumentType, SumsubDocumentType> = {
p: 'PASSPORT',
i: 'ID_CARD',
};
export interface SumsubConfig {
accessToken: string;
locale?: string;
debug?: boolean;
/** Self document type code ('p' for passport, 'i' for ID card) */
documentType?: SelfDocumentType;
/** ISO 3166-1 alpha-2 country code (e.g., 'US', 'GB') */
countryCode?: string;
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 requestBody: Record<string, string> = {};
if (phoneNumber) {
requestBody.phone = phoneNumber;
}
const response = await fetch(`${apiUrl}/access-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
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> => {
let 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')
// Platform configuration:
// - Device Intelligence (Fisherman): Enabled on both iOS and Android
// * iOS: Configured via IDENSIC_WITH_FISHERMAN in Podfile
// * Android: Configured via idensic-mobile-sdk-fisherman in patch file
// * Privacy: iOS declares device ID collection in PrivacyInfo.xcprivacy
// * Privacy: Android should declare device fingerprinting in Google Play Data Safety
// - VideoIdent (live video calls): Disabled on both platforms for current release
// * iOS: Disabled in Podfile (avoids microphone permission requirements)
// * Android: Disabled in patch file (avoids FOREGROUND_SERVICE_MICROPHONE permission)
// * Note: VideoIdent will be re-enabled on both platforms in future release for liveness checks
.withAnalyticsEnabled(true); // Required for Device Intelligence to function
// Pre-select document type and country if provided
// This skips the document selection step in Sumsub
if (config.documentType && config.countryCode) {
const sumsubDocType = DOCUMENT_TYPE_MAP[config.documentType];
// Handle both 2-letter (US) and 3-letter (USA) country codes
// alpha2ToAlpha3 returns undefined for 3-letter codes, so use the original if conversion fails
const alpha3Country =
alpha2ToAlpha3(config.countryCode) ?? config.countryCode;
if (sumsubDocType && alpha3Country) {
console.log(
`[Sumsub] Pre-selecting document: ${sumsubDocType} from ${alpha3Country}`,
);
sdk = sdk.withPreferredDocumentDefinitions({
IDENTITY: {
idDocType: sumsubDocType,
country: alpha3Country,
},
});
}
}
return sdk.build().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

@@ -3,7 +3,6 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { SystemBars } from 'react-native-edge-to-edge';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type {
@@ -15,6 +14,8 @@ import type {
import { ExpandableBottomLayout as BaseExpandableBottomLayout } from '@selfxyz/mobile-sdk-alpha';
import { black } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { SystemBars } from '@/components/SystemBars';
const Layout: React.FC<LayoutProps> = ({
children,
backgroundColor,

View File

@@ -35,20 +35,26 @@ export default function SimpleScrolledTitleLayout({
footer,
}: DetailListProps) {
const insets = useSafeAreaInsets();
const dismissBottomPadding = Math.min(16, insets.bottom);
return (
<ExpandableBottomLayout.Layout backgroundColor={white}>
<ExpandableBottomLayout.FullSection paddingTop={0} flex={1}>
<YStack paddingTop={insets.top + 12}>
<YStack paddingTop={insets.top + 24}>
<Title>{title}</Title>
{header}
</YStack>
<ScrollView flex={1} showsVerticalScrollIndicator={false}>
<ScrollView
flex={1}
showsVerticalScrollIndicator={true}
indicatorStyle="black"
scrollIndicatorInsets={{ right: 1 }}
>
<YStack paddingTop={0} paddingBottom={12} flex={1}>
{children}
</YStack>
</ScrollView>
{footer && (
<YStack marginTop={8} marginBottom={12}>
<YStack marginTop={16} marginBottom={12}>
{footer}
</YStack>
)}
@@ -60,8 +66,8 @@ export default function SimpleScrolledTitleLayout({
{secondaryButtonText}
</SecondaryButton>
)}
{/* Anchor the Dismiss button to bottom with only safe area padding */}
<YStack paddingBottom={insets.bottom + 8}>
{/* Anchor the Dismiss button to bottom with sane spacing */}
<YStack marginTop="auto" paddingBottom={dismissBottomPadding}>
<PrimaryButton onPress={onDismiss}>Dismiss</PrimaryButton>
</YStack>
</ExpandableBottomLayout.FullSection>

View File

@@ -3,11 +3,11 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { SystemBars } from 'react-native-edge-to-edge';
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import type { DocumentCategory } from '@selfxyz/common/utils/types';
import { SystemBars } from '@/components/SystemBars';
import DeferredLinkingInfoScreen from '@/screens/app/DeferredLinkingInfoScreen';
import GratificationScreen from '@/screens/app/GratificationScreen';
import LoadingScreen from '@/screens/app/LoadingScreen';

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

@@ -19,10 +19,15 @@ import DocumentCameraTroubleScreen from '@/screens/documents/scanning/DocumentCa
import DocumentNFCMethodSelectionScreen from '@/screens/documents/scanning/DocumentNFCMethodSelectionScreen';
import DocumentNFCScanScreen from '@/screens/documents/scanning/DocumentNFCScanScreen';
import DocumentNFCTroubleScreen from '@/screens/documents/scanning/DocumentNFCTroubleScreen';
import RegistrationFallbackMRZScreen from '@/screens/documents/scanning/RegistrationFallbackMRZScreen';
import RegistrationFallbackNFCScreen from '@/screens/documents/scanning/RegistrationFallbackNFCScreen';
import ConfirmBelongingScreen from '@/screens/documents/selection/ConfirmBelongingScreen';
import CountryPickerScreen from '@/screens/documents/selection/CountryPickerScreen';
import DocumentOnboardingScreen from '@/screens/documents/selection/DocumentOnboardingScreen';
import IDPickerScreen from '@/screens/documents/selection/IDPickerScreen';
import LogoConfirmationScreen from '@/screens/documents/selection/LogoConfirmationScreen';
import KycConnectionErrorScreen from '@/screens/kyc/KycConnectionErrorScreen';
import KycFailureScreen from '@/screens/kyc/KycFailureScreen';
const documentsScreens = {
DocumentCamera: {
@@ -93,6 +98,16 @@ const documentsScreens = {
documentTypes: [],
},
},
LogoConfirmation: {
screen: LogoConfirmationScreen,
options: {
headerShown: false,
} as NativeStackNavigationOptions,
initialParams: {
documentType: '',
countryCode: '',
},
},
ConfirmBelonging: {
screen: ConfirmBelongingScreen,
options: {
@@ -147,14 +162,52 @@ const documentsScreens = {
AadhaarUploadError: {
screen: AadhaarUploadErrorScreen,
options: {
title: 'AADHAAR REGISTRATION',
header: AadhaarNavBar,
headerBackVisible: false,
headerShown: false,
} as NativeStackNavigationOptions,
initialParams: {
errorType: 'general',
},
},
RegistrationFallbackMRZ: {
screen: RegistrationFallbackMRZScreen,
options: {
title: 'REGISTRATION',
headerShown: false,
} as NativeStackNavigationOptions,
initialParams: {
countryCode: '',
},
},
RegistrationFallbackNFC: {
screen: RegistrationFallbackNFCScreen,
options: {
title: 'REGISTRATION',
headerShown: false,
} as NativeStackNavigationOptions,
initialParams: {
countryCode: '',
},
},
KycFailure: {
screen: KycFailureScreen,
options: {
headerShown: false,
animation: 'fade',
} as NativeStackNavigationOptions,
initialParams: {
countryCode: '',
canRetry: true,
},
},
KycConnectionError: {
screen: KycConnectionErrorScreen,
options: {
headerShown: false,
} as NativeStackNavigationOptions,
initialParams: {
countryCode: '',
},
},
};
export default documentsScreens;

View File

@@ -13,7 +13,6 @@ import {
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { DocumentCategory } from '@selfxyz/common/utils/types';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { DefaultNavBar } from '@/components/navbar';
@@ -28,11 +27,9 @@ import homeScreens from '@/navigation/home';
import onboardingScreens from '@/navigation/onboarding';
import sharedScreens from '@/navigation/shared';
import starfallScreens from '@/navigation/starfall';
import type { ExplicitRouteParams, OmittedRouteKeys } from '@/navigation/types';
import verificationScreens from '@/navigation/verification';
import type { ModalNavigationParams } from '@/screens/app/ModalScreen';
import type { WebViewScreenParams } from '@/screens/shared/WebViewScreen';
import { trackScreenView } from '@/services/analytics';
import type { ProofHistory } from '@/stores/proofTypes';
export const navigationScreens = {
...appScreens,
@@ -58,143 +55,13 @@ const AppNavigation = createNativeStackNavigator({
type BaseRootStackParamList = StaticParamList<typeof AppNavigation>;
// Explicitly declare route params that are not inferred from initialParams
// Explicitly declare route params that are not inferred from initialParams.
// Route param types are defined in @/navigation/types for better organization.
export type RootStackParamList = Omit<
BaseRootStackParamList,
| 'AadhaarUpload'
| 'AadhaarUploadError'
| 'AadhaarUploadSuccess'
| 'AccountRecovery'
| 'AccountVerifiedSuccess'
| 'CloudBackupSettings'
| 'ComingSoon'
| 'ConfirmBelonging'
| 'CreateMock'
| 'Disclaimer'
| 'DocumentNFCScan'
| 'DocumentOnboarding'
| 'DocumentSelectorForProving'
| 'ProvingScreenRouter'
| 'Gratification'
| 'Home'
| 'IDPicker'
| 'IdDetails'
| 'Loading'
| 'Modal'
| 'MockDataDeepLink'
| 'Points'
| 'PointsInfo'
| 'ProofHistoryDetail'
| 'Prove'
| 'SaveRecoveryPhrase'
| 'WebView'
> & {
// Shared screens
ComingSoon: {
countryCode?: string;
documentCategory?: string;
};
WebView: WebViewScreenParams;
// Document screens
IDPicker: {
countryCode: string;
documentTypes: string[];
};
ConfirmBelonging:
| {
documentCategory?: DocumentCategory;
signatureAlgorithm?: string;
curveOrExponent?: string;
}
| undefined;
DocumentNFCScan:
| {
passportNumber?: string;
dateOfBirth?: string;
dateOfExpiry?: string;
}
| undefined;
DocumentCameraTrouble: undefined;
DocumentOnboarding: undefined;
// Aadhaar screens
AadhaarUpload: {
countryCode: string;
};
AadhaarUploadSuccess: undefined;
AadhaarUploadError: {
errorType: string;
};
// Account/Recovery screens
AccountRecovery:
| {
nextScreen?: string;
}
| undefined;
SaveRecoveryPhrase:
| {
nextScreen?: string;
}
| undefined;
CloudBackupSettings:
| {
nextScreen?: 'SaveRecoveryPhrase';
returnToScreen?: 'Points';
}
| undefined;
ProofSettings: undefined;
AccountVerifiedSuccess: undefined;
// Proof/Verification screens
ProofHistoryDetail: {
data: ProofHistory;
};
Prove:
| {
scrollOffset?: number;
}
| undefined;
ProvingScreenRouter: undefined;
DocumentSelectorForProving:
| {
documentType?: string;
}
| undefined;
// App screens
Loading: {
documentCategory?: DocumentCategory;
signatureAlgorithm?: string;
curveOrExponent?: string;
};
Modal: ModalNavigationParams;
Gratification: {
points?: number;
};
StarfallPushCode: undefined;
// Home screens
Home: {
testReferralFlow?: boolean;
};
Points: undefined;
PointsInfo:
| {
showNextButton?: boolean;
onNextButtonPress?: () => void;
}
| undefined;
IdDetails: undefined;
// Onboarding screens
Disclaimer: undefined;
// Dev screens
CreateMock: undefined;
MockDataDeepLink: undefined;
};
OmittedRouteKeys
> &
ExplicitRouteParams;
export type RootStackScreenProps<T extends keyof RootStackParamList> =
NativeStackScreenProps<RootStackParamList, T>;

View File

@@ -4,6 +4,8 @@
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import KycSuccessScreen from '@/screens/kyc/KycSuccessScreen';
import KYCVerifiedScreen from '@/screens/kyc/KYCVerifiedScreen';
import AccountVerifiedSuccessScreen from '@/screens/onboarding/AccountVerifiedSuccessScreen';
import DisclaimerScreen from '@/screens/onboarding/DisclaimerScreen';
import SaveRecoveryPhraseScreen from '@/screens/onboarding/SaveRecoveryPhraseScreen';
@@ -30,6 +32,20 @@ const onboardingScreens = {
animation: 'slide_from_bottom',
} as NativeStackNavigationOptions,
},
KycSuccess: {
screen: KycSuccessScreen,
options: {
headerShown: false,
animation: 'slide_from_bottom',
} as NativeStackNavigationOptions,
},
KYCVerified: {
screen: KYCVerifiedScreen,
options: {
headerShown: false,
animation: 'slide_from_bottom',
} as NativeStackNavigationOptions,
},
};
export default onboardingScreens;

View File

@@ -2,18 +2,213 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { DocumentCategory } from '@selfxyz/common/types';
import type { DocumentCategory } from '@selfxyz/common/utils/types';
import type { ModalNavigationParams } from '@/screens/app/ModalScreen';
import type { WebViewScreenParams } from '@/screens/shared/WebViewScreen';
import type { ProofHistory } from '@/stores/proofTypes';
// =============================================================================
// Aadhaar Screens
// =============================================================================
export type AadhaarRoutesParamList = {
AadhaarUpload: {
countryCode: string;
};
AadhaarUploadSuccess: undefined;
AadhaarUploadError: {
errorType: string;
};
};
// =============================================================================
// Account/Recovery Screens
// =============================================================================
export type AccountRoutesParamList = {
AccountRecovery:
| {
nextScreen?: string;
}
| undefined;
SaveRecoveryPhrase:
| {
nextScreen?: string;
}
| undefined;
CloudBackupSettings:
| {
nextScreen?: 'SaveRecoveryPhrase';
returnToScreen?: 'Points';
}
| undefined;
ProofSettings: undefined;
AccountVerifiedSuccess: undefined;
};
// =============================================================================
// App Screens
// =============================================================================
export type AppRoutesParamList = {
Loading: {
documentCategory?: DocumentCategory;
signatureAlgorithm?: string;
curveOrExponent?: string;
};
Modal: ModalNavigationParams;
Gratification: {
points?: number;
};
StarfallPushCode: undefined;
};
// =============================================================================
// Dev Screens
// =============================================================================
export type DevRoutesParamList = {
CreateMock: undefined;
MockDataDeepLink: undefined;
};
// =============================================================================
// Document Screens
// =============================================================================
export type DocumentRoutesParamList = {
IDPicker: {
countryCode: string;
documentTypes: string[];
};
LogoConfirmation: {
documentType: string;
countryCode: string;
};
ConfirmBelonging:
| {
documentCategory?: DocumentCategory;
signatureAlgorithm?: string;
curveOrExponent?: string;
}
| undefined;
DocumentNFCScan:
| {
passportNumber?: string;
dateOfBirth?: string;
dateOfExpiry?: string;
}
| undefined;
DocumentCameraTrouble: undefined;
DocumentOnboarding: undefined;
IdDetails: undefined;
};
// =============================================================================
// Combined Types
// =============================================================================
/**
* All route param types that need to be explicitly defined (not inferred from initialParams).
* This is used to compose RootStackParamList in index.tsx.
*/
export type ExplicitRouteParams = AadhaarRoutesParamList &
AccountRoutesParamList &
AppRoutesParamList &
DevRoutesParamList &
DocumentRoutesParamList &
HomeRoutesParamList &
OnboardingRoutesParamList &
RegistrationRoutesParamList &
SharedRoutesParamList &
VerificationRoutesParamList;
// =============================================================================
// Home Screens
// =============================================================================
export type HomeRoutesParamList = {
Home: {
testReferralFlow?: boolean;
};
Points: undefined;
PointsInfo:
| {
showNextButton?: boolean;
callbackId?: number;
}
| undefined;
};
/**
* Keys that need to be omitted from BaseRootStackParamList before merging with ExplicitRouteParams.
* These are routes whose params are explicitly defined rather than inferred.
*/
export type OmittedRouteKeys = keyof ExplicitRouteParams;
// =============================================================================
// Onboarding Screens
// =============================================================================
export type OnboardingRoutesParamList = {
Disclaimer: undefined;
KycSuccess:
| {
userId?: string;
}
| undefined;
KYCVerified:
| {
status?: string;
userId?: string;
}
| undefined;
KycFailure: {
countryCode?: string;
canRetry?: boolean;
};
KycConnectionError: {
countryCode?: string;
};
};
// =============================================================================
// Registration Fallback Screens
// =============================================================================
export type RegistrationRoutesParamList = {
RegistrationFallbackMRZ: {
countryCode: string;
};
RegistrationFallbackNFC: {
countryCode: string;
};
};
// =============================================================================
// Shared Screens
// =============================================================================
export type SharedRoutesParamList = {
ComingSoon: {
countryCode?: string;
documentCategory?: DocumentCategory;
};
WebView: {
url: string;
title?: string;
shareTitle?: string;
shareMessage?: string;
shareUrl?: string;
documentCategory?: string;
};
WebView: WebViewScreenParams;
};
// =============================================================================
// Verification/Proof Screens
// =============================================================================
export type VerificationRoutesParamList = {
ProofHistoryDetail: {
data: ProofHistory;
};
Prove:
| {
scrollOffset?: number;
}
| undefined;
ProvingScreenRouter: undefined;
DocumentSelectorForProving:
| {
documentType?: string;
}
| undefined;
};

View File

@@ -5,9 +5,9 @@
import type { ReactNode } from 'react';
import React, { createContext, useContext } from 'react';
import type { AlertModalParams } from '@/components/AlertModal';
import AlertModal from '@/components/AlertModal';
import FeedbackModal from '@/components/FeedbackModal';
import type { FeedbackModalScreenParams } from '@/components/FeedbackModalScreen';
import FeedbackModalScreen from '@/components/FeedbackModalScreen';
import type { FeedbackType } from '@/hooks/useFeedbackModal';
import { useFeedbackModal } from '@/hooks/useFeedbackModal';
@@ -19,7 +19,7 @@ interface FeedbackContextType {
name?: string,
email?: string,
) => Promise<void>;
showModal: (params: FeedbackModalScreenParams) => void;
showModal: (params: AlertModalParams) => void;
}
const FeedbackContext = createContext<FeedbackContextType | undefined>(
@@ -50,13 +50,9 @@ export const FeedbackProvider: React.FC<FeedbackProviderProps> = ({
>
{children}
<FeedbackModal
visible={isVisible}
onClose={hideFeedbackModal}
onSubmit={submitFeedback}
/>
<FeedbackModal visible={isVisible} onClose={hideFeedbackModal} />
<FeedbackModalScreen
<AlertModal
visible={isModalVisible}
modalParams={modalParams}
onHideModal={hideModal}

View File

@@ -4,16 +4,98 @@
import type { PropsWithChildren } from 'react';
import React, { useEffect } from 'react';
import type { FirebaseMessagingTypes } from '@react-native-firebase/messaging';
import messaging from '@react-native-firebase/messaging';
import { NotificationEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import { navigationRef } from '@/navigation';
import { trackEvent } from '@/services/analytics';
// Queue for pending navigation actions that need to wait for navigation to be ready
let pendingNavigation: FirebaseMessagingTypes.RemoteMessage | null = null;
/**
* Execute navigation for a notification
* @returns true if navigation was executed, false if it needs to be queued
*/
const executeNotificationNavigation = (
remoteMessage: FirebaseMessagingTypes.RemoteMessage,
): boolean => {
if (!navigationRef.isReady()) {
return false;
}
const notificationType = remoteMessage.data?.type;
const status = remoteMessage.data?.status;
// Handle KYC result notifications
if (notificationType === 'kyc_result') {
if (status === 'approved') {
navigationRef.navigate('KYCVerified', {
status: String(status),
userId: remoteMessage.data?.user_id
? String(remoteMessage.data.user_id)
: undefined,
});
return true;
} else if (status === 'rejected') {
navigationRef.navigate('KycFailure', {
canRetry: false,
});
return true;
} else if (status === 'retry') {
// Take user directly to verification flow to retry
navigationRef.navigate('CountryPicker');
return true;
}
}
return true; // Navigation handled (or not applicable)
};
/**
* Handle navigation based on notification type and data
* Queues navigation if navigationRef is not ready yet
*/
const handleNotificationNavigation = (
remoteMessage: FirebaseMessagingTypes.RemoteMessage,
) => {
const executed = executeNotificationNavigation(remoteMessage);
if (!executed) {
// Navigation not ready yet - queue for later
pendingNavigation = remoteMessage;
if (__DEV__) {
console.log(
'Navigation not ready, queuing notification navigation:',
remoteMessage.data?.type,
);
}
}
};
/**
* Process any pending navigation once navigation is ready
*/
const processPendingNavigation = () => {
if (pendingNavigation && navigationRef.isReady()) {
if (__DEV__) {
console.log(
'Processing pending notification navigation:',
pendingNavigation.data?.type,
);
}
executeNotificationNavigation(pendingNavigation);
pendingNavigation = null;
}
};
export const NotificationTrackingProvider: React.FC<PropsWithChildren> = ({
children,
}) => {
useEffect(() => {
// Handle notification tap when app is in background
const unsubscribe = messaging().onNotificationOpenedApp(remoteMessage => {
trackEvent(NotificationEvents.BACKGROUND_NOTIFICATION_OPENED, {
messageId: remoteMessage.messageId,
@@ -22,8 +104,12 @@ export const NotificationTrackingProvider: React.FC<PropsWithChildren> = ({
// Track if user interacted with any actions
actionId: remoteMessage.data?.actionId,
});
// Handle navigation based on notification type
handleNotificationNavigation(remoteMessage);
});
// Handle notification tap when app is completely closed (cold start)
messaging()
.getInitialNotification()
.then(remoteMessage => {
@@ -35,11 +121,34 @@ export const NotificationTrackingProvider: React.FC<PropsWithChildren> = ({
// Track if user interacted with any actions
actionId: remoteMessage.data?.actionId,
});
// Handle navigation based on notification type
handleNotificationNavigation(remoteMessage);
}
});
return unsubscribe;
}, []);
// Monitor navigation readiness and process pending navigation
useEffect(() => {
// Check immediately if navigation is already ready
if (navigationRef.isReady()) {
processPendingNavigation();
return;
}
// Poll for navigation readiness if not ready yet
const checkInterval = setInterval(() => {
if (navigationRef.isReady()) {
processPendingNavigation();
clearInterval(checkInterval);
}
}, 100); // Check every 100ms
// Cleanup interval on unmount
return () => clearInterval(checkInterval);
}, []);
return <>{children}</>;
};

View File

@@ -13,14 +13,17 @@ import {
type LogLevel,
type NFCScanContext,
reactNativeScannerAdapter,
sanitizeErrorMessage,
SdkEvents,
SelfClientProvider as SDKSelfClientProvider,
type TrackEventParams,
useMRZStore,
webNFCScannerShim,
type WsConn,
} from '@selfxyz/mobile-sdk-alpha';
import { logNFCEvent, logProofEvent } from '@/config/sentry';
import { fetchAccessToken, launchSumsub } from '@/integrations/sumsub';
import type { RootStackParamList } from '@/navigation';
import { navigationRef } from '@/navigation';
import {
@@ -32,7 +35,12 @@ import {
setPassportKeychainErrorCallback,
} from '@/providers/passportDataProvider';
import { trackEvent, trackNfcEvent } from '@/services/analytics';
import {
type InjectedErrorType,
useErrorInjectionStore,
} from '@/stores/errorInjectionStore';
import { useSettingStore } from '@/stores/settingStore';
import { IS_DEV_MODE } from '@/utils/devUtils';
import {
registerModalCallbacks,
unregisterModalCallbacks,
@@ -67,7 +75,20 @@ function navigateIfReady<RouteName extends keyof RootStackParamList>(
}
export const SelfClientProvider = ({ children }: PropsWithChildren) => {
const config = useMemo(() => ({}), []);
const config = useMemo(
() => ({
devConfig: IS_DEV_MODE
? {
shouldTrigger: (errorType: string) => {
return useErrorInjectionStore
.getState()
.shouldTrigger(errorType as InjectedErrorType);
},
}
: undefined,
}),
[],
);
const adapters: Adapters = useMemo(
() => ({
scanner:
@@ -166,6 +187,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
const appListeners = useMemo(() => {
const { map, addListener } = createListenersMap();
// Track current countryCode for error navigation
let currentCountryCode = '';
addListener(SdkEvents.PROVING_PASSPORT_DATA_NOT_FOUND, () => {
if (navigationRef.isReady()) {
navigationRef.navigate('DocumentDataNotFound');
@@ -260,7 +284,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
});
addListener(SdkEvents.DOCUMENT_MRZ_READ_FAILURE, () => {
navigateIfReady('DocumentCameraTrouble');
navigateIfReady('RegistrationFallbackMRZ', {
countryCode: currentCountryCode,
});
});
addListener(SdkEvents.PROVING_AADHAAR_UPLOAD_SUCCESS, () => {
@@ -279,6 +305,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
countryCode: string;
documentTypes: string[];
}) => {
currentCountryCode = countryCode;
// Store country code early so it's available for Sumsub fallback flows
useMRZStore.getState().update({ countryCode });
navigateIfReady('IDPicker', { countryCode, documentTypes });
},
);
@@ -288,16 +317,106 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
if (navigationRef.isReady()) {
switch (documentType) {
case 'p':
navigationRef.navigate('DocumentOnboarding');
break;
case 'i':
navigationRef.navigate('DocumentOnboarding');
// Navigate to logo confirmation screen for biometric IDs
navigationRef.navigate('LogoConfirmation', {
documentType,
countryCode,
});
break;
case 'a':
if (countryCode) {
navigationRef.navigate('AadhaarUpload', { countryCode });
}
break;
case 'kyc':
(async () => {
try {
// Dev-only: Check for injected initialization error
if (
useErrorInjectionStore
.getState()
.shouldTrigger('sumsub_initialization')
) {
console.log('[DEV] Injecting Sumsub initialization error');
throw new Error(
'Injected Sumsub initialization error for testing',
);
}
const accessToken = await fetchAccessToken();
const result = await launchSumsub({
accessToken: accessToken.token,
});
console.log('[Sumsub] Result:', JSON.stringify(result));
// User cancelled/dismissed without completing verification
// Status values: 'Initial' (never started), 'Incomplete' (started but not finished),
// 'Interrupted' (explicitly cancelled)
const cancelledStatuses = [
'Initial',
'Incomplete',
'Interrupted',
];
if (cancelledStatuses.includes(result.status)) {
console.log(
'[Sumsub] User cancelled or closed without completing, status:',
result.status,
);
return;
}
// Dev-only: Check for injected verification error
const shouldInjectVerificationError = useErrorInjectionStore
.getState()
.shouldTrigger('sumsub_verification');
// Actual error from provider
if (!result.success || shouldInjectVerificationError) {
if (shouldInjectVerificationError) {
console.log('[DEV] Injecting Sumsub verification error');
} else {
const safeError = sanitizeErrorMessage(
result.errorMsg || result.errorType || 'unknown_error',
);
console.error('KYC provider failed:', safeError);
}
// Guard navigation call after async operations
if (navigationRef.isReady()) {
navigationRef.navigate('KycFailure', {
countryCode,
canRetry: true,
});
}
return;
}
// User completed verification (status: 'Pending', 'Approved', etc.)
// Navigate to KYC success screen
console.log(
'[Sumsub] Verification submitted, status:',
result.status,
);
if (navigationRef.isReady()) {
navigationRef.navigate('KycSuccess', {
userId: accessToken.userId,
});
}
} catch (error) {
const safeInitError = sanitizeErrorMessage(
error instanceof Error ? error.message : String(error),
);
console.error('Error in KYC flow:', safeInitError);
// Guard navigation call after async operations
if (navigationRef.isReady()) {
navigationRef.navigate('KycConnectionError', {
countryCode,
});
}
}
})();
break;
default:
if (countryCode) {
navigationRef.navigate('ComingSoon', { countryCode });

View File

@@ -202,6 +202,10 @@ export function getAlternativeCSCA(
useProtocolStore: SelfClient['useProtocolStore'],
docCategory: DocumentCategory,
): AlternativeCSCA {
if (docCategory === 'kyc') {
//TODO
throw new Error('KYC is not supported yet');
}
if (docCategory === 'aadhaar') {
const publicKeys = useProtocolStore.getState().aadhaar.public_keys;
// Convert string[] to Record<string, string> format expected by AlternativeCSCA

View File

@@ -111,6 +111,10 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
return useProtocolStore.getState()[docCategory].commitment_tree;
},
getAltCSCA(docCategory) {
if (docCategory === 'kyc') {
//TODO
throw new Error('KYC is not supported yet');
}
if (docCategory === 'aadhaar') {
const publicKeys =
useProtocolStore.getState().aadhaar.public_keys;

View File

@@ -99,6 +99,10 @@ const RecoverWithPhraseScreen: React.FC = () => {
return useProtocolStore.getState()[docCategory].commitment_tree;
},
getAltCSCA(docCategory) {
if (docCategory === 'kyc') {
//TODO
throw new Error('KYC is not supported yet');
}
if (docCategory === 'aadhaar') {
const publicKeys =
useProtocolStore.getState().aadhaar.public_keys;

View File

@@ -18,12 +18,7 @@ import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSettingStore } from '@/stores/settingStore';
const ProofSettingsScreen: React.FC = () => {
const {
skipDocumentSelector,
setSkipDocumentSelector,
skipDocumentSelectorIfSingle,
setSkipDocumentSelectorIfSingle,
} = useSettingStore();
const { skipDocumentSelector, setSkipDocumentSelector } = useSettingStore();
return (
<YStack flex={1} backgroundColor={white}>
@@ -49,35 +44,6 @@ const ProofSettingsScreen: React.FC = () => {
testID="skip-document-selector-toggle"
/>
</View>
<View style={styles.divider} />
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={styles.settingLabel}>
Skip when only one document
</Text>
<Text style={styles.settingDescription}>
Automatically select your document when you only have one valid
ID available
</Text>
</View>
<Switch
value={skipDocumentSelectorIfSingle}
onValueChange={setSkipDocumentSelectorIfSingle}
trackColor={{ false: slate200, true: blue600 }}
thumbColor={white}
disabled={skipDocumentSelector}
testID="skip-document-selector-if-single-toggle"
/>
</View>
{skipDocumentSelector && (
<Text style={styles.infoText}>
Document selection is always skipped. The &quot;Skip when only one
document&quot; setting has no effect.
</Text>
)}
</YStack>
</ScrollView>
</YStack>
@@ -114,17 +80,6 @@ const styles = StyleSheet.create({
fontFamily: dinot,
color: slate500,
},
divider: {
height: 1,
backgroundColor: slate200,
},
infoText: {
fontSize: 13,
fontFamily: dinot,
fontStyle: 'italic',
color: slate500,
paddingHorizontal: 4,
},
});
export { ProofSettingsScreen };

View File

@@ -6,7 +6,6 @@ import type { PropsWithChildren } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { Linking, Platform, Share, View as RNView } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { getCountry, getLocales, getTimeZone } from 'react-native-localize';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { SvgProps } from 'react-native-svg';
import { Button, ScrollView, View, XStack, YStack } from 'tamagui';
@@ -45,10 +44,10 @@ import {
} from '@/consts/links';
import { impactLight } from '@/integrations/haptics';
import { usePassport } from '@/providers/passportDataProvider';
import { openSupportForm } from '@/services/support';
import { useSettingStore } from '@/stores/settingStore';
import { extraYPadding } from '@/utils/styleUtils';
import { version } from '../../../../package.json';
// Avoid importing RootStackParamList to prevent type cycles; use minimal typing
type MinimalRootStackParamList = Record<string, object | undefined>;
@@ -61,9 +60,8 @@ interface SocialButtonProps {
href: string;
}
const emailFeedback = 'support@self.xyz';
// Avoid importing RootStackParamList; we only need string route names plus a few literals
type RouteOption = string | 'share' | 'email_feedback' | 'ManageDocuments';
type RouteOption = string | 'share' | 'support_form' | 'ManageDocuments';
const storeURL = Platform.OS === 'ios' ? appStoreUrl : playStoreUrl;
@@ -79,7 +77,7 @@ const routes =
[Lock, 'Reveal recovery phrase', 'ShowRecoveryPhrase'],
[Cloud, 'Cloud backup', 'CloudBackupSettings'],
[Settings2 as React.FC<SvgProps>, 'Proof settings', 'ProofSettings'],
[Feedback, 'Send feedback', 'email_feedback'],
[Feedback, 'Get support', 'support_form'],
[ShareIcon, 'Share Self app', 'share'],
[
FileText as React.FC<SvgProps>,
@@ -90,7 +88,7 @@ const routes =
: ([
[Data, 'View document info', 'DocumentDataInfo'],
[Settings2 as React.FC<SvgProps>, 'Proof settings', 'ProofSettings'],
[Feedback, 'Send feeback', 'email_feedback'],
[Feedback, 'Get support', 'support_form'],
[
FileText as React.FC<SvgProps>,
'Manage ID documents',
@@ -222,32 +220,17 @@ const SettingsScreen: React.FC = () => {
);
break;
case 'email_feedback':
const subject = 'SELF App Feedback';
const deviceInfo = [
['device', `${Platform.OS}@${Platform.Version}`],
['app', `v${version}`],
[
'locales',
getLocales()
.map(locale => `${locale.languageCode}-${locale.countryCode}`)
.join(','),
],
['country', getCountry()],
['tz', getTimeZone()],
['ts', new Date()],
['origin', 'settings/feedback'],
] as [string, string][];
const body = `
---
${deviceInfo.map(([k, v]) => `${k}=${v}`).join('; ')}
---`;
await Linking.openURL(
`mailto:${emailFeedback}?subject=${encodeURIComponent(
subject,
)}&body=${encodeURIComponent(body)}`,
);
case 'support_form':
try {
await openSupportForm();
} catch (error) {
console.warn(
'SettingsScreen: failed to open support form:',
error instanceof Error ? error.message : String(error),
);
// Error is already handled and displayed to user in openSupportForm,
// but we log here for debugging purposes
}
break;
case 'ManageDocuments':

View File

@@ -9,7 +9,6 @@ import {
StyleSheet,
Text as RNText,
} from 'react-native';
import { SystemBars } from 'react-native-edge-to-edge';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text, View, YStack } from 'tamagui';
import { useNavigation, useRoute } from '@react-navigation/native';
@@ -28,6 +27,7 @@ import { dinot, dinotBold } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import GratificationBg from '@/assets/images/gratification_bg.svg';
import SelfLogo from '@/assets/logos/self.svg';
import { SystemBars } from '@/components/SystemBars';
import type { RootStackParamList } from '@/navigation';
const GratificationScreen: React.FC = () => {

View File

@@ -107,8 +107,8 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({ route }) => {
} else {
await init(selfClient, 'dsc', true);
}
} catch {
console.error('Error loading selected document:');
} catch (error) {
console.error('Error loading selected document:', error);
await init(selfClient, 'dsc', true);
} finally {
setIsInitializing(false);

View File

@@ -2,812 +2,115 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { PropsWithChildren } from 'react';
import React, {
cloneElement,
isValidElement,
useEffect,
useMemo,
useState,
} from 'react';
import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
import { Alert, ScrollView, TouchableOpacity } from 'react-native';
import { Button, Sheet, Text, XStack, YStack } from 'tamagui';
import React from 'react';
import { ScrollView } from 'react-native';
import { YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Check, ChevronDown, ChevronRight } from '@tamagui/lucide-icons';
import {
red500,
slate100,
slate200,
slate400,
slate500,
slate600,
slate800,
slate900,
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 BugIcon from '@/assets/icons/bug_icon.svg';
import WarningIcon from '@/assets/icons/warning.svg';
import ErrorBoundary from '@/components/ErrorBoundary';
import type { RootStackParamList } from '@/navigation';
import { navigationScreens } from '@/navigation';
import { unsafe_clearSecrets } from '@/providers/authProvider';
import { usePassport } from '@/providers/passportDataProvider';
import { ErrorInjectionSelector } from '@/screens/dev/components/ErrorInjectionSelector';
import { LogLevelSelector } from '@/screens/dev/components/LogLevelSelector';
import { ParameterSection } from '@/screens/dev/components/ParameterSection';
import { useDangerZoneActions } from '@/screens/dev/hooks/useDangerZoneActions';
import { useNotificationHandlers } from '@/screens/dev/hooks/useNotificationHandlers';
import {
isNotificationSystemReady,
requestNotificationPermission,
subscribeToTopics,
unsubscribeFromTopics,
} from '@/services/notifications/notificationService';
import { usePointEventStore } from '@/stores/pointEventStore';
DangerZoneSection,
DebugShortcutsSection,
DevTogglesSection,
PushNotificationsSection,
} from '@/screens/dev/sections';
import { useSettingStore } from '@/stores/settingStore';
import { IS_DEV_MODE } from '@/utils/devUtils';
interface TopicToggleButtonProps {
label: string;
isSubscribed: boolean;
onToggle: () => void;
}
const TopicToggleButton: React.FC<TopicToggleButtonProps> = ({
label,
isSubscribed,
onToggle,
}) => {
return (
<Button
backgroundColor={isSubscribed ? '$green9' : slate200}
borderRadius="$2"
height="$5"
onPress={onToggle}
flexDirection="row"
justifyContent="space-between"
paddingHorizontal="$4"
pressStyle={{
opacity: 0.8,
scale: 0.98,
}}
>
<Text
color={isSubscribed ? white : slate600}
fontSize="$5"
fontFamily={dinot}
fontWeight="600"
>
{label}
</Text>
<Text
color={isSubscribed ? white : slate400}
fontSize="$3"
fontFamily={dinot}
>
{isSubscribed ? 'Enabled' : 'Disabled'}
</Text>
</Button>
);
};
interface DevSettingsScreenProps extends PropsWithChildren {
color?: string;
width?: number;
justifyContent?:
| 'center'
| 'unset'
| 'flex-start'
| 'flex-end'
| 'space-between'
| 'space-around'
| 'space-evenly';
userSelect?: 'all' | 'text' | 'none' | 'contain';
textAlign?: 'center' | 'left' | 'right';
style?: StyleProp<TextStyle | ViewStyle>;
}
function ParameterSection({
icon,
title,
description,
darkMode,
children,
}: {
icon: React.ReactNode;
title: string;
description: string;
darkMode?: boolean;
children: React.ReactNode;
}) {
const renderIcon = () => {
const iconElement =
typeof icon === 'function'
? (icon as () => React.ReactNode)()
: isValidElement(icon)
? icon
: null;
return iconElement
? cloneElement(iconElement as React.ReactElement, {
width: '100%',
height: '100%',
})
: null;
};
return (
<YStack
width="100%"
backgroundColor={darkMode ? slate900 : slate100}
borderRadius="$4"
borderWidth={1}
borderColor={darkMode ? slate800 : slate200}
padding="$4"
flexDirection="column"
gap="$3"
>
<XStack
width="100%"
flexDirection="row"
justifyContent="flex-start"
gap="$4"
>
<YStack
backgroundColor="gray"
borderRadius={5}
width={46}
height={46}
justifyContent="center"
alignItems="center"
padding="$2"
>
{renderIcon()}
</YStack>
<YStack flexDirection="column" gap="$1">
<Text
fontSize="$5"
color={darkMode ? white : slate600}
fontFamily={dinot}
>
{title}
</Text>
<Text fontSize="$3" color={slate400} fontFamily={dinot}>
{description}
</Text>
</YStack>
</XStack>
{children}
</YStack>
);
}
const ScreenSelector = ({}) => {
const navigation = useNavigation();
const [open, setOpen] = useState(false);
const screenList = useMemo(
() =>
(
Object.keys(navigationScreens) as (keyof typeof navigationScreens)[]
).sort(),
[],
);
return (
<>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => setOpen(true)}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
Select screen
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<Sheet
modal
open={open}
onOpenChange={setOpen}
snapPoints={[85]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
>
<YStack padding="$4">
<XStack
alignItems="center"
justifyContent="space-between"
marginBottom="$4"
>
<Text fontSize="$8" fontFamily={dinot}>
Select screen
</Text>
<Button
onPress={() => setOpen(false)}
padding="$2"
backgroundColor="transparent"
>
<ChevronDown
color={slate500}
strokeWidth={2.5}
style={{ transform: [{ rotate: '180deg' }] }}
/>
</Button>
</XStack>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 100 }}
>
{screenList.map(item => (
<TouchableOpacity
key={item}
onPress={() => {
setOpen(false);
navigation.navigate(item as never);
}}
>
<XStack
paddingVertical="$3"
paddingHorizontal="$2"
borderBottomWidth={1}
borderBottomColor={slate200}
>
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
{item}
</Text>
</XStack>
</TouchableOpacity>
))}
</ScrollView>
</YStack>
</Sheet.Frame>
</Sheet>
</>
);
};
const LogLevelSelector = ({
currentLevel,
onSelect,
}: {
currentLevel: string;
onSelect: (level: 'debug' | 'info' | 'warn' | 'error') => void;
}) => {
const [open, setOpen] = useState(false);
const logLevels = ['debug', 'info', 'warn', 'error'] as const;
return (
<>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => setOpen(true)}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
{currentLevel.toUpperCase()}
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<Sheet
modal
open={open}
onOpenChange={setOpen}
snapPoints={[50]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
>
<YStack padding="$4">
<XStack
alignItems="center"
justifyContent="space-between"
marginBottom="$4"
>
<Text fontSize="$8" fontFamily={dinot}>
Select log level
</Text>
<Button
onPress={() => setOpen(false)}
padding="$2"
backgroundColor="transparent"
>
<ChevronDown
color={slate500}
strokeWidth={2.5}
style={{ transform: [{ rotate: '180deg' }] }}
/>
</Button>
</XStack>
<ScrollView showsVerticalScrollIndicator={false}>
{logLevels.map(level => (
<TouchableOpacity
key={level}
onPress={() => {
setOpen(false);
onSelect(level);
}}
>
<XStack
paddingVertical="$3"
paddingHorizontal="$2"
borderBottomWidth={1}
borderBottomColor={slate200}
alignItems="center"
justifyContent="space-between"
>
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
{level.toUpperCase()}
</Text>
{currentLevel === level && (
<Check color={slate600} size={20} />
)}
</XStack>
</TouchableOpacity>
))}
</ScrollView>
</YStack>
</Sheet.Frame>
</Sheet>
</>
);
};
const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
const { clearDocumentCatalogForMigrationTesting } = usePassport();
const clearPointEvents = usePointEventStore(state => state.clearEvents);
const { resetBackupForPoints } = useSettingStore();
const DevSettingsScreen: React.FC = () => {
const navigation =
useNavigation() as NativeStackScreenProps<RootStackParamList>['navigation'];
const subscribedTopics = useSettingStore(state => state.subscribedTopics);
const loggingSeverity = useSettingStore(state => state.loggingSeverity);
const setLoggingSeverity = useSettingStore(state => state.setLoggingSeverity);
const [hasNotificationPermission, setHasNotificationPermission] =
useState(false);
const paddingBottom = useSafeBottomPadding(20);
// Check notification permissions on mount
useEffect(() => {
const checkPermissions = async () => {
const readiness = await isNotificationSystemReady();
setHasNotificationPermission(readiness.ready);
};
checkPermissions();
}, []);
// Settings store
const loggingSeverity = useSettingStore(state => state.loggingSeverity);
const setLoggingSeverity = useSettingStore(state => state.setLoggingSeverity);
const useStrongBox = useSettingStore(state => state.useStrongBox);
const setUseStrongBox = useSettingStore(state => state.setUseStrongBox);
const kycEnabled = useSettingStore(state => state.kycEnabled);
const setKycEnabled = useSettingStore(state => state.setKycEnabled);
const handleTopicToggle = async (topics: string[], topicLabel: string) => {
// Check permissions first
if (!hasNotificationPermission) {
Alert.alert(
'Permissions Required',
'Push notifications are not enabled. Would you like to enable them?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Enable',
onPress: async () => {
try {
const granted = await requestNotificationPermission();
if (granted) {
// Update permission state
setHasNotificationPermission(true);
Alert.alert(
'Success',
'Permissions granted! You can now subscribe to topics.',
[{ text: 'OK' }],
);
} else {
Alert.alert(
'Failed',
'Could not enable notifications. Please enable them in your device Settings.',
[{ text: 'OK' }],
);
}
} catch (error) {
Alert.alert(
'Error',
error instanceof Error
? error.message
: 'Failed to request permissions',
[{ text: 'OK' }],
);
}
},
},
],
);
return;
}
const isCurrentlySubscribed = topics.every(topic =>
subscribedTopics.includes(topic),
);
if (isCurrentlySubscribed) {
// Show confirmation dialog for unsubscribe
Alert.alert(
'Disable Notifications',
`Are you sure you want to disable push notifications for ${topicLabel}?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Disable',
style: 'destructive',
onPress: async () => {
try {
const result = await unsubscribeFromTopics(topics);
if (result.successes.length > 0) {
Alert.alert(
'Success',
`Disabled notifications for ${topicLabel}`,
[{ text: 'OK' }],
);
} else {
Alert.alert(
'Error',
`Failed to disable: ${result.failures.map(f => f.error).join(', ')}`,
[{ text: 'OK' }],
);
}
} catch (error) {
Alert.alert(
'Error',
error instanceof Error
? error.message
: 'Failed to unsubscribe',
[{ text: 'OK' }],
);
}
},
},
],
);
} else {
// Subscribe without confirmation
try {
const result = await subscribeToTopics(topics);
if (result.successes.length > 0) {
Alert.alert('✅ Success', `Enabled notifications for ${topicLabel}`, [
{ text: 'OK' },
]);
} else {
Alert.alert(
'Error',
`Failed to enable: ${result.failures.map(f => f.error).join(', ')}`,
[{ text: 'OK' }],
);
}
} catch (error) {
Alert.alert(
'Error',
error instanceof Error ? error.message : 'Failed to subscribe',
[{ text: 'OK' }],
);
}
}
};
const handleClearSecretsPress = () => {
Alert.alert(
'Delete Keychain Secrets',
"Are you sure you want to remove your keychain secrets?\n\nIf this secret is not backed up, your account will be lost and the ID documents attached to it won't be usable.",
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await unsafe_clearSecrets();
},
},
],
);
};
const handleClearDocumentCatalogPress = () => {
Alert.alert(
'Clear Document Catalog',
'Are you sure you want to clear the document catalog?\n\nThis will remove all documents from the new storage system but preserve legacy storage for migration testing. You will need to restart the app to test migration.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
await clearDocumentCatalogForMigrationTesting();
},
},
],
);
};
const handleClearPointEventsPress = () => {
Alert.alert(
'Clear Point Events',
'Are you sure you want to clear all point events from local storage?\n\nThis will reset your point history but not affect your actual points on the blockchain.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
await clearPointEvents();
Alert.alert('Success', 'Point events cleared successfully.', [
{ text: 'OK' },
]);
},
},
],
);
};
const handleResetBackupStatePress = () => {
Alert.alert(
'Reset Backup State',
'Are you sure you want to reset the backup state?\n\nThis will allow you to see and trigger the backup points flow again.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Reset',
style: 'destructive',
onPress: () => {
resetBackupForPoints();
Alert.alert('Success', 'Backup state reset successfully.', [
{ text: 'OK' },
]);
},
},
],
);
};
const handleClearBackupEventsPress = () => {
Alert.alert(
'Clear Backup Events',
'Are you sure you want to clear all backup point events from local storage?\n\nThis will remove backup events from your point history.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
const events = usePointEventStore.getState().events;
const backupEvents = events.filter(
event => event.type === 'backup',
);
for (const event of backupEvents) {
await usePointEventStore.getState().removeEvent(event.id);
}
Alert.alert('Success', 'Backup events cleared successfully.', [
{ text: 'OK' },
]);
},
},
],
);
};
// Custom hooks
const { hasNotificationPermission, subscribedTopics, handleTopicToggle } =
useNotificationHandlers();
const {
handleClearSecretsPress,
handleClearDocumentCatalogPress,
handleClearPointEventsPress,
handleResetBackupStatePress,
handleClearBackupEventsPress,
} = useDangerZoneActions();
return (
<ScrollView showsVerticalScrollIndicator={false}>
<YStack
gap="$3"
alignItems="center"
backgroundColor="white"
flex={1}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom={paddingBottom}
>
<ParameterSection
icon={<BugIcon />}
title="Debug Shortcuts"
description="Jump directly to any screen for testing"
<ErrorBoundary>
<ScrollView showsVerticalScrollIndicator={false}>
<YStack
gap="$3"
alignItems="center"
backgroundColor="white"
flex={1}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom={paddingBottom}
>
<YStack gap="$2">
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => {
navigation.navigate('DevPrivateKey');
}}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
View Private Key
</Text>
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
{IS_DEV_MODE && (
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => {
navigation.navigate('Home', { testReferralFlow: true });
}}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
Test Referral Flow
</Text>
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
)}
<ScreenSelector />
</YStack>
</ParameterSection>
<DebugShortcutsSection navigation={navigation} />
<ParameterSection
icon={<BugIcon />}
title="Push Notifications"
description="Manage topic subscriptions"
>
<YStack gap="$2">
<TopicToggleButton
label="Starfall"
isSubscribed={
hasNotificationPermission && subscribedTopics.includes('nova')
}
onToggle={() => handleTopicToggle(['nova'], 'Starfall')}
{IS_DEV_MODE && (
<DevTogglesSection
kycEnabled={kycEnabled}
setKycEnabled={setKycEnabled}
useStrongBox={useStrongBox}
setUseStrongBox={setUseStrongBox}
/>
<TopicToggleButton
label="General"
isSubscribed={
hasNotificationPermission &&
subscribedTopics.includes('general')
}
onToggle={() => handleTopicToggle(['general'], 'General')}
/>
<TopicToggleButton
label="Both (Starfall + General)"
isSubscribed={
hasNotificationPermission &&
subscribedTopics.includes('nova') &&
subscribedTopics.includes('general')
}
onToggle={() =>
handleTopicToggle(['nova', 'general'], 'both topics')
}
/>
</YStack>
</ParameterSection>
)}
<ParameterSection
icon={<BugIcon />}
title="Log Level"
description="Configure logging verbosity"
>
<LogLevelSelector
currentLevel={loggingSeverity}
onSelect={setLoggingSeverity}
<PushNotificationsSection
hasNotificationPermission={hasNotificationPermission}
subscribedTopics={subscribedTopics}
onTopicToggle={handleTopicToggle}
/>
</ParameterSection>
<ParameterSection
icon={<WarningIcon color={yellow500} />}
title="Danger Zone"
description="These actions are sensitive"
darkMode={true}
>
{[
{
label: 'Delete your private key',
onPress: handleClearSecretsPress,
dangerTheme: true,
},
{
label: 'Clear document catalog',
onPress: handleClearDocumentCatalogPress,
dangerTheme: true,
},
{
label: 'Clear point events',
onPress: handleClearPointEventsPress,
dangerTheme: true,
},
{
label: 'Reset backup state',
onPress: handleResetBackupStatePress,
dangerTheme: true,
},
{
label: 'Clear backup events',
onPress: handleClearBackupEventsPress,
dangerTheme: true,
},
].map(({ label, onPress, dangerTheme }) => (
<Button
key={label}
style={{ backgroundColor: dangerTheme ? red500 : white }}
borderRadius="$2"
height="$5"
onPress={onPress}
flexDirection="row"
justifyContent="flex-start"
<ParameterSection
icon={<BugIcon />}
title="Log Level"
description="Configure logging verbosity"
>
<LogLevelSelector
currentLevel={loggingSeverity}
onSelect={setLoggingSeverity}
/>
</ParameterSection>
{IS_DEV_MODE && (
<ParameterSection
icon={<BugIcon />}
title="Onboarding Error Testing"
description="Test onboarding error flows"
>
<Text
color={dangerTheme ? white : slate500}
fontSize="$5"
fontFamily={dinot}
>
{label}
</Text>
</Button>
))}
</ParameterSection>
</YStack>
</ScrollView>
<ErrorInjectionSelector />
</ParameterSection>
)}
<DangerZoneSection
onClearSecrets={handleClearSecretsPress}
onClearDocumentCatalog={handleClearDocumentCatalogPress}
onClearPointEvents={handleClearPointEventsPress}
onResetBackupState={handleResetBackupStatePress}
onClearBackupEvents={handleClearBackupEventsPress}
/>
</YStack>
</ScrollView>
</ErrorBoundary>
);
};

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;

View File

@@ -0,0 +1,208 @@
// 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, useRef, useState } from 'react';
import { ScrollView, TouchableOpacity } from 'react-native';
import { Button, Sheet, Text, XStack, YStack } from 'tamagui';
import { Check, ChevronDown } from '@tamagui/lucide-icons';
import {
red500,
slate200,
slate500,
slate600,
slate800,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import type { InjectedErrorType } from '@/stores/errorInjectionStore';
import {
ERROR_GROUPS,
ERROR_LABELS,
useErrorInjectionStore,
} from '@/stores/errorInjectionStore';
import {
registerModalCallbacks,
unregisterModalCallbacks,
} from '@/utils/modalCallbackRegistry';
export const ErrorInjectionSelector = () => {
const injectedErrors = useErrorInjectionStore(state => state.injectedErrors);
const setInjectedErrors = useErrorInjectionStore(
state => state.setInjectedErrors,
);
const clearAllErrors = useErrorInjectionStore(state => state.clearAllErrors);
const [open, setOpen] = useState(false);
const callbackIdRef = useRef<number>();
const handleModalDismiss = useCallback(() => {
setOpen(false);
if (callbackIdRef.current !== undefined) {
unregisterModalCallbacks(callbackIdRef.current);
callbackIdRef.current = undefined;
}
}, []);
const openSheet = useCallback(() => {
setOpen(true);
const id = registerModalCallbacks({
onButtonPress: () => {},
onModalDismiss: handleModalDismiss,
});
callbackIdRef.current = id;
}, [handleModalDismiss]);
const closeSheet = useCallback(() => {
handleModalDismiss();
}, [handleModalDismiss]);
const handleSheetOpenChange = useCallback(
(isOpen: boolean) => {
if (!isOpen) {
handleModalDismiss();
} else {
setOpen(isOpen);
}
},
[handleModalDismiss],
);
// Single error selection - replace instead of toggle
const selectError = (errorType: InjectedErrorType) => {
// If clicking the same error, clear it; otherwise set the new one
if (injectedErrors.length === 1 && injectedErrors[0] === errorType) {
clearAllErrors();
} else {
setInjectedErrors([errorType]);
}
// Close the sheet after selection
closeSheet();
};
const currentError = injectedErrors.length > 0 ? injectedErrors[0] : null;
const currentErrorLabel = currentError ? ERROR_LABELS[currentError] : null;
return (
<YStack gap="$2">
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={openSheet}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
{currentErrorLabel || 'Select onboarding error to test'}
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
{currentError && (
<Button
backgroundColor={red500}
borderRadius="$2"
height="$5"
onPress={clearAllErrors}
pressStyle={{
opacity: 0.8,
scale: 0.98,
}}
>
<Text color={white} fontSize="$5" fontFamily={dinot}>
Clear
</Text>
</Button>
)}
<Sheet
modal
open={open}
onOpenChange={handleSheetOpenChange}
snapPoints={[85]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
>
<YStack padding="$4">
<XStack
alignItems="center"
justifyContent="space-between"
marginBottom="$4"
>
<Text fontSize="$8" fontFamily={dinot}>
Onboarding Error Testing
</Text>
<Button
onPress={closeSheet}
padding="$2"
backgroundColor="transparent"
>
<ChevronDown
color={slate500}
strokeWidth={2.5}
style={{ transform: [{ rotate: '180deg' }] }}
/>
</Button>
</XStack>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 100 }}
>
{Object.entries(ERROR_GROUPS).map(([groupName, errors]) => (
<YStack key={groupName} marginBottom="$4">
<Text
fontSize="$6"
fontFamily={dinot}
fontWeight="600"
color={slate800}
marginBottom="$2"
>
{groupName}
</Text>
{errors.map((errorType: InjectedErrorType) => (
<TouchableOpacity
key={errorType}
onPress={() => selectError(errorType)}
>
<XStack
paddingVertical="$3"
paddingHorizontal="$2"
borderBottomWidth={1}
borderBottomColor={slate200}
alignItems="center"
justifyContent="space-between"
>
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
{ERROR_LABELS[errorType]}
</Text>
{currentError === errorType && (
<Check color={slate600} size={20} />
)}
</XStack>
</TouchableOpacity>
))}
</YStack>
))}
</ScrollView>
</YStack>
</Sheet.Frame>
</Sheet>
</YStack>
);
};

View File

@@ -0,0 +1,175 @@
// 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 { ScrollView, TouchableOpacity } from 'react-native';
import { Button, Sheet, Text, XStack, YStack } from 'tamagui';
import { Check, ChevronDown } from '@tamagui/lucide-icons';
import {
slate200,
slate500,
slate600,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import {
registerModalCallbacks,
unregisterModalCallbacks,
} from '@/utils/modalCallbackRegistry';
interface LogLevelSelectorProps {
currentLevel: string;
onSelect: (level: 'debug' | 'info' | 'warn' | 'error') => void;
}
export const LogLevelSelector: React.FC<LogLevelSelectorProps> = ({
currentLevel,
onSelect,
}) => {
const [open, setOpen] = useState(false);
const callbackIdRef = useRef<number>();
const logLevels = ['debug', 'info', 'warn', 'error'] as const;
// Cleanup effect to unregister callbacks on unmount
useEffect(() => {
return () => {
if (callbackIdRef.current !== undefined) {
unregisterModalCallbacks(callbackIdRef.current);
callbackIdRef.current = undefined;
}
};
}, []);
const handleModalDismiss = useCallback(() => {
setOpen(false);
if (callbackIdRef.current !== undefined) {
unregisterModalCallbacks(callbackIdRef.current);
callbackIdRef.current = undefined;
}
}, []);
const openSheet = useCallback(() => {
setOpen(true);
const id = registerModalCallbacks({
onButtonPress: () => {},
onModalDismiss: handleModalDismiss,
});
callbackIdRef.current = id;
}, [handleModalDismiss]);
const closeSheet = useCallback(() => {
handleModalDismiss();
}, [handleModalDismiss]);
const handleSheetOpenChange = useCallback(
(isOpen: boolean) => {
if (!isOpen) {
handleModalDismiss();
} else {
setOpen(isOpen);
}
},
[handleModalDismiss],
);
const handleLevelSelect = useCallback(
(level: 'debug' | 'info' | 'warn' | 'error') => {
closeSheet();
onSelect(level);
},
[closeSheet, onSelect],
);
return (
<>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={openSheet}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
{currentLevel.toUpperCase()}
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<Sheet
modal
open={open}
onOpenChange={handleSheetOpenChange}
snapPoints={[50]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
>
<YStack padding="$4">
<XStack
alignItems="center"
justifyContent="space-between"
marginBottom="$4"
>
<Text fontSize="$8" fontFamily={dinot}>
Select log level
</Text>
<Button
onPress={closeSheet}
padding="$2"
backgroundColor="transparent"
>
<ChevronDown
color={slate500}
strokeWidth={2.5}
style={{ transform: [{ rotate: '180deg' }] }}
/>
</Button>
</XStack>
<ScrollView showsVerticalScrollIndicator={false}>
{logLevels.map(level => (
<TouchableOpacity
key={level}
onPress={() => handleLevelSelect(level)}
>
<XStack
paddingVertical="$3"
paddingHorizontal="$2"
borderBottomWidth={1}
borderBottomColor={slate200}
alignItems="center"
justifyContent="space-between"
>
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
{level.toUpperCase()}
</Text>
{currentLevel === level && (
<Check color={slate600} size={20} />
)}
</XStack>
</TouchableOpacity>
))}
</ScrollView>
</YStack>
</Sheet.Frame>
</Sheet>
</>
);
};

View File

@@ -0,0 +1,94 @@
// 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 type { PropsWithChildren } from 'react';
import React, { cloneElement, isValidElement } from 'react';
import { Text, XStack, YStack } from 'tamagui';
import {
slate100,
slate200,
slate400,
slate600,
slate800,
slate900,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
interface ParameterSectionProps extends PropsWithChildren {
icon: React.ReactNode;
title: string;
description: string;
darkMode?: boolean;
}
export function ParameterSection({
icon,
title,
description,
darkMode,
children,
}: ParameterSectionProps) {
const renderIcon = () => {
const iconElement =
typeof icon === 'function'
? (icon as () => React.ReactNode)()
: isValidElement(icon)
? icon
: null;
return iconElement
? cloneElement(iconElement as React.ReactElement, {
width: '100%',
height: '100%',
})
: null;
};
return (
<YStack
width="100%"
backgroundColor={darkMode ? slate900 : slate100}
borderRadius="$4"
borderWidth={1}
borderColor={darkMode ? slate800 : slate200}
padding="$4"
flexDirection="column"
gap="$3"
>
<XStack
width="100%"
flexDirection="row"
justifyContent="flex-start"
gap="$4"
>
<YStack
backgroundColor="gray"
borderRadius={5}
width={46}
height={46}
justifyContent="center"
alignItems="center"
padding="$2"
>
{renderIcon()}
</YStack>
<YStack flexDirection="column" gap="$1">
<Text
fontSize="$5"
color={darkMode ? white : slate600}
fontFamily={dinot}
>
{title}
</Text>
<Text fontSize="$3" color={slate400} fontFamily={dinot}>
{description}
</Text>
</YStack>
</XStack>
{children}
</YStack>
);
}

View File

@@ -0,0 +1,122 @@
// 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, { useMemo, useState } from 'react';
import { ScrollView, TouchableOpacity } from 'react-native';
import { Button, Sheet, Text, XStack, YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import { ChevronDown } from '@tamagui/lucide-icons';
import {
slate200,
slate500,
slate600,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { navigationScreens } from '@/navigation';
export const ScreenSelector = () => {
const navigation = useNavigation();
const [open, setOpen] = useState(false);
const screenList = useMemo(
() =>
(
Object.keys(navigationScreens) as (keyof typeof navigationScreens)[]
).sort(),
[],
);
return (
<>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => setOpen(true)}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
Select screen
</Text>
<ChevronDown color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<Sheet
modal
open={open}
onOpenChange={setOpen}
snapPoints={[85]}
animation="medium"
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame
backgroundColor={white}
borderTopLeftRadius="$9"
borderTopRightRadius="$9"
>
<YStack padding="$4">
<XStack
alignItems="center"
justifyContent="space-between"
marginBottom="$4"
>
<Text fontSize="$8" fontFamily={dinot}>
Select screen
</Text>
<Button
onPress={() => setOpen(false)}
padding="$2"
backgroundColor="transparent"
>
<ChevronDown
color={slate500}
strokeWidth={2.5}
style={{ transform: [{ rotate: '180deg' }] }}
/>
</Button>
</XStack>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 100 }}
>
{screenList.map(item => (
<TouchableOpacity
key={item}
onPress={() => {
setOpen(false);
navigation.navigate(item as never);
}}
>
<XStack
paddingVertical="$3"
paddingHorizontal="$2"
borderBottomWidth={1}
borderBottomColor={slate200}
>
<Text fontSize="$5" color={slate600} fontFamily={dinot}>
{item}
</Text>
</XStack>
</TouchableOpacity>
))}
</ScrollView>
</YStack>
</Sheet.Frame>
</Sheet>
</>
);
};

View File

@@ -0,0 +1,58 @@
// 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 from 'react';
import { Button, Text } from 'tamagui';
import {
slate200,
slate400,
slate600,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
export interface TopicToggleButtonProps {
label: string;
isSubscribed: boolean;
onToggle: () => void;
}
export const TopicToggleButton: React.FC<TopicToggleButtonProps> = ({
label,
isSubscribed,
onToggle,
}) => {
return (
<Button
backgroundColor={isSubscribed ? '$green9' : slate200}
borderRadius="$2"
height="$5"
onPress={onToggle}
flexDirection="row"
justifyContent="space-between"
paddingHorizontal="$4"
pressStyle={{
opacity: 0.8,
scale: 0.98,
}}
>
<Text
color={isSubscribed ? white : slate600}
fontSize="$5"
fontFamily={dinot}
fontWeight="600"
>
{label}
</Text>
<Text
color={isSubscribed ? white : slate400}
fontSize="$3"
fontFamily={dinot}
>
{isSubscribed ? 'Enabled' : 'Disabled'}
</Text>
</Button>
);
};

View File

@@ -0,0 +1,10 @@
// 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 { TopicToggleButtonProps } from '@/screens/dev/components/TopicToggleButton';
export { ErrorInjectionSelector } from '@/screens/dev/components/ErrorInjectionSelector';
export { LogLevelSelector } from '@/screens/dev/components/LogLevelSelector';
export { ParameterSection } from '@/screens/dev/components/ParameterSection';
export { ScreenSelector } from '@/screens/dev/components/ScreenSelector';
export { TopicToggleButton } from '@/screens/dev/components/TopicToggleButton';

View File

@@ -0,0 +1,197 @@
// 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 { Alert } from 'react-native';
import { unsafe_clearSecrets } from '@/providers/authProvider';
import { usePassport } from '@/providers/passportDataProvider';
import { usePointEventStore } from '@/stores/pointEventStore';
import { useSettingStore } from '@/stores/settingStore';
export const useDangerZoneActions = () => {
const { clearDocumentCatalogForMigrationTesting } = usePassport();
const clearPointEvents = usePointEventStore(state => state.clearEvents);
const { resetBackupForPoints } = useSettingStore();
const handleClearSecretsPress = () => {
Alert.alert(
'Delete Keychain Secrets',
"Are you sure you want to remove your keychain secrets?\n\nIf this secret is not backed up, your account will be lost and the ID documents attached to it won't be usable.",
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await unsafe_clearSecrets();
Alert.alert('Success', 'Keychain secrets cleared successfully.', [
{ text: 'OK' },
]);
} catch (error) {
console.error(
'Failed to clear keychain secrets:',
error instanceof Error ? error.message : String(error),
);
Alert.alert(
'Error',
'Failed to clear keychain secrets. Please try again.',
[{ text: 'OK' }],
);
}
},
},
],
);
};
const handleClearDocumentCatalogPress = () => {
Alert.alert(
'Clear Document Catalog',
'Are you sure you want to clear the document catalog?\n\nThis will remove all documents from the new storage system but preserve legacy storage for migration testing. You will need to restart the app to test migration.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
try {
await clearDocumentCatalogForMigrationTesting();
Alert.alert(
'Success',
'Document catalog cleared successfully. Please restart the app to test migration.',
[{ text: 'OK' }],
);
} catch (error) {
console.error(
'Failed to clear document catalog:',
error instanceof Error ? error.message : String(error),
);
Alert.alert(
'Error',
'Failed to clear document catalog. Please try again.',
[{ text: 'OK' }],
);
}
},
},
],
);
};
const handleClearPointEventsPress = () => {
Alert.alert(
'Clear Point Events',
'Are you sure you want to clear all point events from local storage?\n\nThis will reset your point history but not affect your actual points on the blockchain.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
try {
await clearPointEvents();
Alert.alert('Success', 'Point events cleared successfully.', [
{ text: 'OK' },
]);
} catch (error) {
console.error(
'Failed to clear point events:',
error instanceof Error ? error.message : String(error),
);
Alert.alert(
'Error',
'Failed to clear point events. Please try again.',
[{ text: 'OK' }],
);
}
},
},
],
);
};
const handleResetBackupStatePress = () => {
Alert.alert(
'Reset Backup State',
'Are you sure you want to reset the backup state?\n\nThis will allow you to see and trigger the backup points flow again.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Reset',
style: 'destructive',
onPress: () => {
resetBackupForPoints();
Alert.alert('Success', 'Backup state reset successfully.', [
{ text: 'OK' },
]);
},
},
],
);
};
const handleClearBackupEventsPress = () => {
Alert.alert(
'Clear Backup Events',
'Are you sure you want to clear all backup point events from local storage?\n\nThis will remove backup events from your point history.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
try {
const events = usePointEventStore.getState().events;
const backupEvents = events.filter(
event => event.type === 'backup',
);
await Promise.all(
backupEvents.map(event =>
usePointEventStore.getState().removeEvent(event.id),
),
);
Alert.alert('Success', 'Backup events cleared successfully.', [
{ text: 'OK' },
]);
} catch (error) {
console.error(
'Failed to clear backup events:',
error instanceof Error ? error.message : String(error),
);
Alert.alert(
'Error',
'Failed to clear backup events. Please try again.',
[{ text: 'OK' }],
);
}
},
},
],
);
};
return {
handleClearSecretsPress,
handleClearDocumentCatalogPress,
handleClearPointEventsPress,
handleResetBackupStatePress,
handleClearBackupEventsPress,
};
};

View File

@@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback, useEffect, useState } from 'react';
import { Alert, AppState } from 'react-native';
import {
isNotificationSystemReady,
requestNotificationPermission,
subscribeToTopics,
unsubscribeFromTopics,
} from '@/services/notifications/notificationService';
import { useSettingStore } from '@/stores/settingStore';
export const useNotificationHandlers = () => {
const subscribedTopics = useSettingStore(state => state.subscribedTopics);
const [hasNotificationPermission, setHasNotificationPermission] =
useState(false);
const checkPermissions = useCallback(async () => {
const readiness = await isNotificationSystemReady();
setHasNotificationPermission(readiness.ready);
}, []);
// Check notification permissions on mount and when app regains focus
useEffect(() => {
checkPermissions();
const subscription = AppState.addEventListener('change', nextAppState => {
if (nextAppState === 'active') {
checkPermissions();
}
});
return () => subscription.remove();
}, [checkPermissions]);
const handleTopicToggle = async (topics: string[], topicLabel: string) => {
// Check permissions first
if (!hasNotificationPermission) {
Alert.alert(
'Permissions Required',
'Push notifications are not enabled. Would you like to enable them?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Enable',
onPress: async () => {
try {
const granted = await requestNotificationPermission();
if (granted) {
// Update permission state
setHasNotificationPermission(true);
Alert.alert(
'Success',
'Permissions granted! You can now subscribe to topics.',
[{ text: 'OK' }],
);
} else {
Alert.alert(
'Failed',
'Could not enable notifications. Please enable them in your device Settings.',
[{ text: 'OK' }],
);
}
} catch (error) {
Alert.alert(
'Error',
error instanceof Error
? error.message
: 'Failed to request permissions',
[{ text: 'OK' }],
);
}
},
},
],
);
return;
}
const isCurrentlySubscribed = topics.every(topic =>
subscribedTopics.includes(topic),
);
if (isCurrentlySubscribed) {
// Show confirmation dialog for unsubscribe
Alert.alert(
'Disable Notifications',
`Are you sure you want to disable push notifications for ${topicLabel}?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Disable',
style: 'destructive',
onPress: async () => {
try {
const result = await unsubscribeFromTopics(topics);
if (result.successes.length > 0) {
Alert.alert(
'Success',
`Disabled notifications for ${topicLabel}`,
[{ text: 'OK' }],
);
} else {
Alert.alert(
'Error',
`Failed to disable: ${result.failures.map(f => f.error).join(', ')}`,
[{ text: 'OK' }],
);
}
} catch (error) {
Alert.alert(
'Error',
error instanceof Error
? error.message
: 'Failed to unsubscribe',
[{ text: 'OK' }],
);
}
},
},
],
);
} else {
// Subscribe without confirmation
try {
const result = await subscribeToTopics(topics);
if (result.successes.length > 0) {
Alert.alert('✅ Success', `Enabled notifications for ${topicLabel}`, [
{ text: 'OK' },
]);
} else {
Alert.alert(
'Error',
`Failed to enable: ${result.failures.map(f => f.error).join(', ')}`,
[{ text: 'OK' }],
);
}
} catch (error) {
Alert.alert(
'Error',
error instanceof Error ? error.message : 'Failed to subscribe',
[{ text: 'OK' }],
);
}
}
};
return {
hasNotificationPermission,
subscribedTopics,
handleTopicToggle,
};
};

View File

@@ -0,0 +1,90 @@
// 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 from 'react';
import { Button, Text } from 'tamagui';
import {
red500,
slate500,
white,
yellow500,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import WarningIcon from '@/assets/icons/warning.svg';
import { ParameterSection } from '@/screens/dev/components/ParameterSection';
interface DangerZoneSectionProps {
onClearSecrets: () => void;
onClearDocumentCatalog: () => void;
onClearPointEvents: () => void;
onResetBackupState: () => void;
onClearBackupEvents: () => void;
}
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
onClearSecrets,
onClearDocumentCatalog,
onClearPointEvents,
onResetBackupState,
onClearBackupEvents,
}) => {
const dangerActions = [
{
label: 'Delete your private key',
onPress: onClearSecrets,
dangerTheme: true,
},
{
label: 'Clear document catalog',
onPress: onClearDocumentCatalog,
dangerTheme: true,
},
{
label: 'Clear point events',
onPress: onClearPointEvents,
dangerTheme: true,
},
{
label: 'Reset backup state',
onPress: onResetBackupState,
dangerTheme: true,
},
{
label: 'Clear backup events',
onPress: onClearBackupEvents,
dangerTheme: true,
},
];
return (
<ParameterSection
icon={<WarningIcon color={yellow500} />}
title="Danger Zone"
description="These actions are sensitive"
darkMode={true}
>
{dangerActions.map(({ label, onPress, dangerTheme }) => (
<Button
key={label}
style={{ backgroundColor: dangerTheme ? red500 : white }}
borderRadius="$2"
height="$5"
onPress={onPress}
flexDirection="row"
justifyContent="flex-start"
>
<Text
color={dangerTheme ? white : slate500}
fontSize="$5"
fontFamily={dinot}
>
{label}
</Text>
</Button>
))}
</ParameterSection>
);
};

View File

@@ -0,0 +1,108 @@
// 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 from 'react';
import { Button, Text, XStack, YStack } from 'tamagui';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ChevronRight } from '@tamagui/lucide-icons';
import { slate200, slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import BugIcon from '@/assets/icons/bug_icon.svg';
import type { RootStackParamList } from '@/navigation';
import { ParameterSection } from '@/screens/dev/components/ParameterSection';
import { ScreenSelector } from '@/screens/dev/components/ScreenSelector';
import { IS_DEV_MODE } from '@/utils/devUtils';
interface DebugShortcutsSectionProps {
navigation: NativeStackNavigationProp<RootStackParamList>;
}
export const DebugShortcutsSection: React.FC<DebugShortcutsSectionProps> = ({
navigation,
}) => {
return (
<ParameterSection
icon={<BugIcon />}
title="Debug Shortcuts"
description="Jump directly to any screen for testing"
>
<YStack gap="$2">
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => {
navigation.navigate('DevPrivateKey');
}}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
View Private Key
</Text>
<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' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => {
navigation.navigate('Home', { testReferralFlow: true });
}}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
Test Referral Flow
</Text>
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
)}
<ScreenSelector />
</YStack>
</ParameterSection>
);
};

View File

@@ -0,0 +1,61 @@
// 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 from 'react';
import { Alert, Platform } from 'react-native';
import BugIcon from '@/assets/icons/bug_icon.svg';
import { ParameterSection } from '@/screens/dev/components/ParameterSection';
import { TopicToggleButton } from '@/screens/dev/components/TopicToggleButton';
interface DevTogglesSectionProps {
kycEnabled: boolean;
setKycEnabled: (enabled: boolean) => void;
useStrongBox: boolean;
setUseStrongBox: (useStrongBox: boolean) => void;
}
export const DevTogglesSection: React.FC<DevTogglesSectionProps> = ({
kycEnabled,
setKycEnabled,
useStrongBox,
setUseStrongBox,
}) => {
const handleToggleStrongBox = () => {
Alert.alert(
useStrongBox ? 'Disable StrongBox' : 'Enable StrongBox',
useStrongBox
? 'New keys will be generated without StrongBox hardware backing. Existing keys will continue to work.'
: 'New keys will attempt to use StrongBox hardware backing for enhanced security.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: useStrongBox ? 'Disable' : 'Enable',
onPress: () => setUseStrongBox(!useStrongBox),
},
],
);
};
return (
<ParameterSection
icon={<BugIcon />}
title="Options"
description="Development and security options"
>
<TopicToggleButton
label="KYC Flow"
isSubscribed={kycEnabled}
onToggle={() => setKycEnabled(!kycEnabled)}
/>
{Platform.OS === 'android' && (
<TopicToggleButton
label="Use StrongBox"
isSubscribed={useStrongBox}
onToggle={handleToggleStrongBox}
/>
)}
</ParameterSection>
);
};

View File

@@ -0,0 +1,54 @@
// 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 from 'react';
import { YStack } from 'tamagui';
import BugIcon from '@/assets/icons/bug_icon.svg';
import { ParameterSection } from '@/screens/dev/components/ParameterSection';
import { TopicToggleButton } from '@/screens/dev/components/TopicToggleButton';
interface PushNotificationsSectionProps {
hasNotificationPermission: boolean;
subscribedTopics: string[];
onTopicToggle: (topics: string[], topicLabel: string) => void;
}
export const PushNotificationsSection: React.FC<
PushNotificationsSectionProps
> = ({ hasNotificationPermission, subscribedTopics, onTopicToggle }) => {
return (
<ParameterSection
icon={<BugIcon />}
title="Push Notifications"
description="Manage topic subscriptions"
>
<YStack gap="$2">
<TopicToggleButton
label="Starfall"
isSubscribed={
hasNotificationPermission && subscribedTopics.includes('nova')
}
onToggle={() => onTopicToggle(['nova'], 'Starfall')}
/>
<TopicToggleButton
label="General"
isSubscribed={
hasNotificationPermission && subscribedTopics.includes('general')
}
onToggle={() => onTopicToggle(['general'], 'General')}
/>
<TopicToggleButton
label="Both (Starfall + General)"
isSubscribed={
hasNotificationPermission &&
subscribedTopics.includes('nova') &&
subscribedTopics.includes('general')
}
onToggle={() => onTopicToggle(['nova', 'general'], 'both topics')}
/>
</YStack>
</ParameterSection>
);
};

View File

@@ -0,0 +1,8 @@
// 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 { DangerZoneSection } from '@/screens/dev/sections/DangerZoneSection';
export { DebugShortcutsSection } from '@/screens/dev/sections/DebugShortcutsSection';
export { DevTogglesSection } from '@/screens/dev/sections/DevTogglesSection';
export { PushNotificationsSection } from '@/screens/dev/sections/PushNotificationsSection';

View File

@@ -2,25 +2,35 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { XStack, YStack } from 'tamagui';
import React, { useCallback } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Button, XStack, YStack } from 'tamagui';
import type { RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Image } from '@tamagui/lucide-icons';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { BodyText, PrimaryButton } from '@selfxyz/mobile-sdk-alpha/components';
import { BodyText } from '@selfxyz/mobile-sdk-alpha/components';
import { AadhaarEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import {
black,
cyan300,
slate100,
slate200,
slate300,
slate500,
white,
} 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 { getErrorMessages } from '@selfxyz/mobile-sdk-alpha/onboarding/import-aadhaar';
import WarningIcon from '@/assets/images/warning.svg';
import { NavBar } from '@/components/navbar/BaseNavBar';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import { useSettingStore } from '@/stores/settingStore';
import { extraYPadding } from '@/utils/styleUtils';
type AadhaarUploadErrorRouteParams = {
@@ -32,80 +42,218 @@ type AadhaarUploadErrorRoute = RouteProp<
string
>;
const getErrorMessages = (
errorType: 'general' | 'expired',
): { title: string; description: string } => {
switch (errorType) {
case 'expired':
return {
title: 'Your Aadhaar document has expired',
description: 'Please upload a valid Aadhaar document',
};
case 'general':
default:
return {
title: 'There was a problem reading the code',
description: 'Make sure the QR code is valid and try again',
};
}
};
const AadhaarUploadErrorScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const paddingBottom = useSafeBottomPadding(extraYPadding + 35);
const navigation = useNavigation();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<AadhaarUploadErrorRoute>();
const { trackEvent } = useSelfClient();
const errorType = route.params?.errorType || 'general';
const kycEnabled = useSettingStore(state => state.kycEnabled);
const errorType = route.params?.errorType || 'general';
const { title, description } = getErrorMessages(errorType);
const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
{
countryCode: 'IND',
errorSource: 'mrz_scan_failed', // Use a compatible error source
onCancel: () => {
navigation.goBack();
},
onError: () => {
// Stay on this screen - user can try again
},
onSuccess: () => {
// Success - provider handles its own success UI
},
},
);
const handleClose = useCallback(() => {
buttonTap();
navigation.goBack();
}, [navigation]);
const handleTryAgain = useCallback(() => {
trackEvent(AadhaarEvents.RETRY_BUTTON_PRESSED, { errorType });
navigation.goBack();
}, [errorType, navigation, trackEvent]);
const handleTryAlternative = useCallback(async () => {
trackEvent(AadhaarEvents.HELP_BUTTON_PRESSED, { errorType });
await launchSumsubVerification();
}, [errorType, launchSumsubVerification, trackEvent]);
return (
<YStack flex={1} backgroundColor={slate100}>
<YStack flex={1} paddingHorizontal={20} paddingTop={20}>
<YStack
flex={1}
justifyContent="center"
{/* Header */}
<YStack backgroundColor={slate100}>
<NavBar.Container
backgroundColor={slate100}
barStyle="dark"
paddingHorizontal="$4"
paddingTop={insets.top + extraYPadding}
paddingBottom={10}
alignItems="center"
paddingVertical={20}
justifyContent="space-between"
>
<WarningIcon width={120} height={120} />
<NavBar.LeftAction
component="close"
color={black}
onPress={handleClose}
/>
<NavBar.Title style={{ fontFamily: dinot, fontSize: 17 }}>
AADHAAR REGISTRATION
</NavBar.Title>
{/* Invisible spacer to balance header */}
<YStack width={30} height={30} />
</NavBar.Container>
{/* Progress Bar - Step 2 for Aadhaar upload */}
<YStack paddingHorizontal={40} paddingBottom={14} paddingTop={4}>
<XStack gap={3} height={6}>
{[1, 2, 3, 4].map(step => (
<YStack
key={step}
flex={1}
backgroundColor={step === 2 ? cyan300 : slate300}
borderRadius={10}
/>
))}
</XStack>
</YStack>
</YStack>
{/* Main Content Area */}
<YStack
flex={1}
backgroundColor={slate100}
borderBottomWidth={1}
borderBottomColor={slate200}
>
{/* Warning Icon */}
<YStack flex={1} paddingHorizontal={20} paddingBottom={20}>
<YStack flex={1} justifyContent="center" alignItems="center">
<WarningIcon width={150} height={150} />
</YStack>
</YStack>
{/* Error Message and Retry Button */}
<YStack
paddingHorizontal={20}
paddingTop={20}
paddingBottom={20}
gap={20}
borderTopWidth={1}
borderTopColor={slate200}
>
<YStack alignItems="center" gap={4}>
<BodyText
style={{ fontSize: 18, textAlign: 'center', color: black }}
>
{title}
</BodyText>
<BodyText
style={{
fontSize: 16,
textAlign: 'center',
color: slate500,
}}
>
{description}
</BodyText>
</YStack>
{/* Retry Button - Primary style with icon */}
<Button
backgroundColor={black}
borderRadius={100}
height={52}
pressStyle={{ opacity: 0.8 }}
onPress={handleTryAgain}
disabled={isRetrying}
>
<XStack alignItems="center" gap={8}>
<Image size={20} color={white} />
<BodyText
style={{
fontSize: 17,
fontWeight: '500',
fontFamily: dinot,
color: white,
}}
>
Try upload again
</BodyText>
</XStack>
</Button>
</YStack>
</YStack>
{/* Bottom Section */}
<YStack
paddingHorizontal={20}
paddingTop={20}
alignItems="center"
paddingVertical={25}
borderBlockWidth={1}
borderBlockColor={slate200}
>
<BodyText style={{ fontSize: 19, textAlign: 'center', color: black }}>
{title}
</BodyText>
<BodyText
style={{
marginTop: 6,
fontSize: 17,
textAlign: 'center',
color: slate500,
}}
>
{description}
</BodyText>
</YStack>
<YStack
paddingHorizontal={25}
backgroundColor={white}
paddingBottom={paddingBottom}
paddingTop={25}
gap={10}
>
<XStack gap="$3" alignItems="stretch">
<YStack flex={1}>
<PrimaryButton
onPress={() => {
trackEvent(AadhaarEvents.RETRY_BUTTON_PRESSED, { errorType });
// Navigate back to upload screen to try again
navigation.goBack();
{kycEnabled && (
<>
{/* Secondary Button - White fill, black text, rounded */}
<Button
backgroundColor={white}
borderWidth={1}
borderColor={slate200}
borderRadius={100}
height={52}
pressStyle={{ opacity: 0.8 }}
onPress={handleTryAlternative}
disabled={isRetrying}
>
<BodyText
style={{
fontSize: 17,
fontWeight: '500',
fontFamily: dinot,
color: black,
}}
>
{isRetrying ? 'Loading...' : 'Try a different method'}
</BodyText>
</Button>
{/* Footer Text - Not italic */}
<BodyText
style={{
fontSize: 16,
textAlign: 'center',
color: slate500,
}}
>
Try Again
</PrimaryButton>
</YStack>
{/* <YStack flex={1}>
<SecondaryButton
onPress={() => {
trackEvent(AadhaarEvents.HELP_BUTTON_PRESSED, { errorType });
// TODO: Implement help functionality
}}
>
Need Help?
</SecondaryButton>
</YStack> */}
</XStack>
Registering with alternative methods may take longer to verify
your document.
</BodyText>
</>
)}
</YStack>
</YStack>
);

View File

@@ -109,7 +109,8 @@ const PassportDataSelector = () => {
try {
await setSelectedDocument(documentId);
navigation.navigate('ConfirmBelonging', {});
} catch {
} catch (error) {
console.error('Failed to navigate to registration:', error);
Alert.alert(
'Registration Error',
'Failed to prepare document for registration. Please try again.',

View File

@@ -2,10 +2,11 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { StyleSheet } from 'react-native';
import { View, XStack, YStack } from 'tamagui';
import { useIsFocused } from '@react-navigation/native';
import { useIsFocused, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
DelayedLottieView,
@@ -33,21 +34,45 @@ import {
import passportScanAnimation from '@/assets/animations/passport_scan.json';
import Scan from '@/assets/icons/passport_camera_scan.svg';
import { PassportCamera } from '@/components/native/PassportCamera';
import { useErrorInjection } from '@/hooks/useErrorInjection';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import { getDocumentScanPrompt } from '@/utils/documentAttributes';
const DocumentCameraScreen: React.FC = () => {
const isFocused = useIsFocused();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const selfClient = useSelfClient();
const selectedDocumentType = selfClient.useMRZStore(
state => state.documentType,
);
const countryCode = selfClient.useMRZStore(state => state.countryCode);
const { shouldInjectError } = useErrorInjection();
// Add a ref to track when the camera screen is mounted
const scanStartTimeRef = useRef(Date.now());
const { onPassportRead } = useReadMRZ(scanStartTimeRef);
// Dev-only: Auto-trigger MRZ error after short delay if error injection is enabled
useEffect(() => {
if (
shouldInjectError('mrz_invalid_format') ||
shouldInjectError('mrz_unknown_error')
) {
const timer = setTimeout(() => {
console.log(
'[DEV] Injecting MRZ error - navigating to fallback screen',
);
navigation.navigate('RegistrationFallbackMRZ', {
countryCode: countryCode || '',
});
}, 1500); // 1.5 second delay to show camera briefly
return () => clearTimeout(timer);
}
}, [shouldInjectError, navigation, countryCode]);
const scanPrompt = getDocumentScanPrompt(selectedDocumentType);
const navigateToHome = useHapticNavigation('Home', {

View File

@@ -3,9 +3,11 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useEffect } from 'react';
import { YStack } from 'tamagui';
import { Caption } from '@selfxyz/mobile-sdk-alpha/components';
import { slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { Caption, SecondaryButton } from '@selfxyz/mobile-sdk-alpha/components';
import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import Activity from '@/assets/icons/activity.svg';
import PassportCameraBulb from '@/assets/icons/passport_camera_bulb.svg';
@@ -15,8 +17,10 @@ import Star from '@/assets/icons/star.svg';
import type { TipProps } from '@/components/Tips';
import Tips from '@/components/Tips';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import { flush as flushAnalytics } from '@/services/analytics';
import { useSettingStore } from '@/stores/settingStore';
const tips: TipProps[] = [
{
@@ -48,6 +52,14 @@ const tips: TipProps[] = [
const DocumentCameraTroubleScreen: React.FC = () => {
const go = useHapticNavigation('DocumentCamera', { action: 'cancel' });
const selfClient = useSelfClient();
const { useMRZStore } = selfClient;
const { countryCode } = useMRZStore();
const kycEnabled = useSettingStore(state => state.kycEnabled);
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
countryCode,
errorSource: 'mrz_scan_failed',
});
// error screen, flush analytics
useEffect(() => {
@@ -64,10 +76,32 @@ const DocumentCameraTroubleScreen: React.FC = () => {
</Caption>
}
footer={
<Caption size="large" style={{ color: slate500 }}>
Following these steps should help your phone's camera capture the ID
page quickly and clearly!
</Caption>
<YStack gap="$3">
<Caption size="large" style={{ color: slate500 }}>
Following these steps should help your phone's camera capture the ID
page quickly and clearly!
</Caption>
{kycEnabled && (
<>
<Caption
size="large"
style={{ color: slate500, marginTop: 12, marginBottom: 8 }}
>
Or try an alternative verification method:
</Caption>
<SecondaryButton
onPress={launchSumsubVerification}
disabled={isLoading}
textColor={slate700}
style={{ marginBottom: 0 }}
>
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
</SecondaryButton>
</>
)}
</YStack>
}
>
<Tips items={tips} />

View File

@@ -4,7 +4,7 @@
import React, { useState } from 'react';
import { Platform, ScrollView } from 'react-native';
import { Input, YStack } from 'tamagui';
import { Input, Switch, XStack, YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
@@ -21,6 +21,7 @@ import { white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import { useSettingStore } from '@/stores/settingStore';
type NFCParams = {
skipPACE?: boolean;
@@ -111,6 +112,10 @@ const DocumentNFCMethodSelectionScreen: React.FC = () => {
const { useMRZStore } = selfClient;
const { update, passportNumber, dateOfBirth, dateOfExpiry } = useMRZStore();
const loggingSeverity = useSettingStore(state => state.loggingSeverity);
const setLoggingSeverity = useSettingStore(state => state.setLoggingSeverity);
const isDebugMode = loggingSeverity === 'debug';
const handleSelect = (key: string) => {
setSelectedMethod(key);
setError('');
@@ -153,6 +158,30 @@ const DocumentNFCMethodSelectionScreen: React.FC = () => {
<YStack paddingTop={20} gap={20}>
<Title>Choose NFC Scan Method</Title>
<XStack
alignItems="center"
justifyContent="space-between"
paddingVertical="$3"
paddingHorizontal="$2"
borderWidth={1}
borderColor="#ccc"
borderRadius={10}
backgroundColor="#fff"
>
<Description>Debug Logging</Description>
<Switch
size="$4"
checked={isDebugMode}
onCheckedChange={checked => {
setLoggingSeverity(checked ? 'debug' : 'warn');
}}
backgroundColor={isDebugMode ? '$green7Light' : '$gray4'}
style={{ minWidth: 48, minHeight: 36 }}
>
<Switch.Thumb animation="quick" backgroundColor="$white" />
</Switch>
</XStack>
{NFC_METHODS.filter(method =>
method.platform.includes(Platform.OS),
).map(method => (

View File

@@ -54,6 +54,7 @@ import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import passportVerifyAnimation from '@/assets/animations/passport_verify.json';
import NFC_IMAGE from '@/assets/images/nfc.png';
import { logNFCEvent } from '@/config/sentry';
import { useErrorInjection } from '@/hooks/useErrorInjection';
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import {
@@ -73,7 +74,11 @@ import {
setNfcScanningActive,
trackNfcEvent,
} from '@/services/analytics';
import { sendFeedbackEmail } from '@/services/email';
import {
openSupportForm,
SUPPORT_FORM_BUTTON_TEXT,
SUPPORT_FORM_MESSAGE,
} from '@/services/support';
const emitter =
Platform.OS === 'android'
@@ -102,8 +107,9 @@ const DocumentNFCScanScreen: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<DocumentNFCScanRoute>();
const { showModal } = useFeedback();
useFeedback();
useFeedbackAutoHide();
const { shouldInjectError } = useErrorInjection();
const {
passportNumber,
dateOfBirth,
@@ -170,10 +176,7 @@ const DocumentNFCScanScreen: React.FC = () => {
});
const onReportIssue = useCallback(() => {
sendFeedbackEmail({
message: 'User reported an issue from NFC scan screen',
origin: 'passport/nfc',
});
openSupportForm();
}, []);
const openErrorModal = useCallback(
@@ -188,22 +191,11 @@ const DocumentNFCScanScreen: React.FC = () => {
},
{ message: sanitizeErrorMessage(message) },
);
showModal({
titleText: 'NFC Scan Error',
bodyText: message,
buttonText: 'Report Issue',
secondaryButtonText: 'Help',
preventDismiss: false,
onButtonPress: () =>
sendFeedbackEmail({
message: sanitizeErrorMessage(message),
origin: 'passport/nfc',
}),
onSecondaryButtonPress: goToNFCTrouble,
onModalDismiss: () => {},
navigation.navigate('RegistrationFallbackNFC', {
countryCode,
});
},
[baseContext, showModal, goToNFCTrouble],
[baseContext, navigation, countryCode],
);
const checkNfcSupport = useCallback(async () => {
@@ -327,6 +319,18 @@ const DocumentNFCScanScreen: React.FC = () => {
}, 30000);
try {
// Dev-only: Check for injected timeout error
if (shouldInjectError('nfc_timeout')) {
console.log('[DEV] Injecting NFC timeout error');
throw new Error('Injected timeout error for testing');
}
// Dev-only: Check for injected module unavailable error
if (shouldInjectError('nfc_module_unavailable')) {
console.log('[DEV] Injecting NFC module unavailable error');
throw new Error('NFC scanning is currently unavailable');
}
const {
canNumber,
useCan,
@@ -379,6 +383,12 @@ const DocumentNFCScanScreen: React.FC = () => {
);
let passportData: PassportData | null = null;
try {
// Dev-only: Check for injected parse failure error
if (shouldInjectError('nfc_parse_failure')) {
console.log('[DEV] Injecting NFC parse failure error');
throw new Error('Failed to parse NFC response');
}
passportData = parseScanResponse(scanResponse);
} catch (e: unknown) {
console.error('Parsing NFC Response Unsuccessful');
@@ -426,7 +436,7 @@ const DocumentNFCScanScreen: React.FC = () => {
});
openErrorModal(message);
// We deliberately avoid opening any external feedback widgets here;
// users can send feedback via the email action in the modal.
// users can request support via the support form action in the modal.
} finally {
if (scanTimeoutRef.current) {
clearTimeout(scanTimeoutRef.current);
@@ -455,6 +465,7 @@ const DocumentNFCScanScreen: React.FC = () => {
navigation,
openErrorModal,
trackEvent,
shouldInjectError,
]);
const navigateToHome = useHapticNavigation('Home', {
@@ -612,6 +623,9 @@ const DocumentNFCScanScreen: React.FC = () => {
</BodyText>
</>
)}
<BodyText style={[styles.disclaimer, { marginTop: 12 }]}>
{SUPPORT_FORM_MESSAGE}
</BodyText>
</TextsContainer>
<ButtonsContainer>
<PrimaryButton
@@ -634,7 +648,7 @@ const DocumentNFCScanScreen: React.FC = () => {
Cancel
</SecondaryButton>
<SecondaryButton onPress={onReportIssue}>
Report Issue
{SUPPORT_FORM_BUTTON_TEXT}
</SecondaryButton>
</ButtonsContainer>
</>

View File

@@ -2,21 +2,26 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useEffect } from 'react';
import React, { useCallback, useEffect } from 'react';
import { View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { Caption, SecondaryButton } from '@selfxyz/mobile-sdk-alpha/components';
import { slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import type { TipProps } from '@/components/Tips';
import Tips from '@/components/Tips';
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import { selectionChange } from '@/integrations/haptics';
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
import { flushAllAnalytics } from '@/services/analytics';
import { sendFeedbackEmail } from '@/services/email';
import { openSupportForm, SUPPORT_FORM_BUTTON_TEXT } from '@/services/support';
import { useSettingStore } from '@/stores/settingStore';
const tips: TipProps[] = [
{
@@ -46,10 +51,22 @@ const tips: TipProps[] = [
];
const DocumentNFCTroubleScreen: React.FC = () => {
const go = useHapticNavigation('DocumentNFCScan', { action: 'cancel' });
const navigation = useNavigation();
const handleDismiss = useCallback(() => {
selectionChange();
navigation.goBack();
}, [navigation]);
const goToNFCMethodSelection = useHapticNavigation(
'DocumentNFCMethodSelection',
);
const selfClient = useSelfClient();
const { useMRZStore } = selfClient;
const { countryCode } = useMRZStore();
const kycEnabled = useSettingStore(state => state.kycEnabled);
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
countryCode,
errorSource: 'nfc_scan_failed',
});
useFeedbackAutoHide();
// error screen, flush analytics
@@ -67,23 +84,29 @@ const DocumentNFCTroubleScreen: React.FC = () => {
return (
<SimpleScrolledTitleLayout
title="Having trouble verifying your ID?"
onDismiss={go}
onDismiss={handleDismiss}
secondaryButtonText="Open NFC Options"
onSecondaryButtonPress={goToNFCMethodSelection}
footer={
// Add top padding before buttons and normalize spacing
<YStack marginTop={16} marginBottom={0} gap={10}>
<YStack gap="$3">
<SecondaryButton
onPress={() =>
sendFeedbackEmail({
message: 'User reported an issue from NFC trouble screen',
origin: 'passport/nfc-trouble',
})
}
onPress={openSupportForm}
textColor={slate700}
style={{ marginBottom: 0 }}
>
Report Issue
{SUPPORT_FORM_BUTTON_TEXT}
</SecondaryButton>
{kycEnabled && (
<SecondaryButton
onPress={launchSumsubVerification}
disabled={isLoading}
textColor={slate700}
style={{ marginBottom: 0 }}
>
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
</SecondaryButton>
)}
</YStack>
}
>

View File

@@ -0,0 +1,262 @@
// 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 } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Button, XStack, YStack } from 'tamagui';
import type { RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { BodyText } from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
cyan300,
slate100,
slate200,
slate300,
slate500,
white,
} 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 WarningIcon from '@/assets/images/warning.svg';
import { NavBar } from '@/components/navbar/BaseNavBar';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import { useSettingStore } from '@/stores/settingStore';
import { extraYPadding } from '@/utils/styleUtils';
type RegistrationFallbackMRZRouteParams = {
countryCode: string;
};
type RegistrationFallbackMRZRoute = RouteProp<
Record<string, RegistrationFallbackMRZRouteParams>,
string
>;
const getHeaderTitle = (documentType: string): string => {
switch (documentType) {
case 'p':
return 'PASSPORT REGISTRATION';
case 'i':
return 'ID CARD REGISTRATION';
default:
return 'DOCUMENT REGISTRATION';
}
};
const RegistrationFallbackMRZScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const paddingBottom = useSafeBottomPadding(extraYPadding + 35);
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RegistrationFallbackMRZRoute>();
const selfClient = useSelfClient();
const { trackEvent, useMRZStore } = selfClient;
const storeCountryCode = useMRZStore(state => state.countryCode);
const documentType = useMRZStore(state => state.documentType);
const kycEnabled = useSettingStore(state => state.kycEnabled);
// Use country code from route params, or fall back to MRZ store
const countryCode = route.params?.countryCode || storeCountryCode || '';
const headerTitle = getHeaderTitle(documentType);
const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
{
countryCode,
errorSource: 'mrz_scan_failed',
onCancel: () => {
navigation.goBack();
},
onError: (_error, _result) => {
// Stay on this screen - user can try again
// Error is already logged in the hook
},
onSuccess: () => {
// Success - provider handles its own success UI
// The screen will be navigated away by the provider's flow
},
},
);
const handleClose = useCallback(() => {
buttonTap();
navigation.goBack();
}, [navigation]);
const handleTryAlternative = useCallback(async () => {
trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', {
errorSource: 'mrz_scan_failed',
});
await launchSumsubVerification();
}, [launchSumsubVerification, trackEvent]);
const handleRetryOriginal = useCallback(() => {
trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', {
errorSource: 'mrz_scan_failed',
});
navigation.navigate('DocumentCamera');
}, [navigation, trackEvent]);
return (
<YStack flex={1} backgroundColor={slate100}>
{/* Header */}
<YStack backgroundColor={slate100}>
<NavBar.Container
backgroundColor={slate100}
barStyle="dark"
paddingHorizontal="$4"
paddingTop={insets.top + extraYPadding}
paddingBottom={10}
alignItems="center"
justifyContent="space-between"
>
<NavBar.LeftAction
component="close"
color={black}
onPress={handleClose}
/>
<NavBar.Title style={{ fontFamily: dinot, fontSize: 17 }}>
{headerTitle}
</NavBar.Title>
{/* Invisible spacer to balance header */}
<YStack width={30} height={30} />
</NavBar.Container>
{/* Progress Bar - Step 2 for MRZ */}
<YStack paddingHorizontal={40} paddingBottom={14} paddingTop={4}>
<XStack gap={3} height={6}>
{[1, 2, 3, 4].map(step => (
<YStack
key={step}
flex={1}
backgroundColor={step === 2 ? cyan300 : slate300}
borderRadius={10}
/>
))}
</XStack>
</YStack>
</YStack>
{/* Main Content Area */}
<YStack
flex={1}
backgroundColor={slate100}
borderBottomWidth={1}
borderBottomColor={slate200}
>
{/* Warning Icon */}
<YStack flex={1} paddingHorizontal={20} paddingBottom={20}>
<YStack flex={1} justifyContent="center" alignItems="center">
<WarningIcon width={150} height={150} />
</YStack>
</YStack>
{/* Error Message and Retry Button */}
<YStack
paddingHorizontal={20}
paddingTop={20}
paddingBottom={20}
gap={20}
borderTopWidth={1}
borderTopColor={slate200}
>
<YStack alignItems="center" gap={4}>
<BodyText
style={{ fontSize: 18, textAlign: 'center', color: black }}
>
We couldn't read your document's MRZ
</BodyText>
<BodyText
style={{
fontSize: 16,
textAlign: 'center',
color: slate500,
}}
>
Make sure the machine-readable zone at the bottom is clearly
visible and try again
</BodyText>
</YStack>
{/* Retry Button - Primary style with very rounded corners */}
<Button
backgroundColor={black}
borderRadius={100}
height={52}
pressStyle={{ opacity: 0.8 }}
onPress={handleRetryOriginal}
disabled={isRetrying}
>
<BodyText
style={{
fontSize: 17,
fontWeight: '500',
fontFamily: dinot,
color: white,
}}
>
Try scanning again
</BodyText>
</Button>
</YStack>
</YStack>
{/* Bottom Section */}
<YStack
paddingHorizontal={20}
paddingTop={20}
paddingBottom={paddingBottom}
gap={10}
>
{kycEnabled && (
<>
{/* Secondary Button - White fill, black text, rounded */}
<Button
backgroundColor={white}
borderWidth={1}
borderColor={slate200}
borderRadius={100}
height={52}
pressStyle={{ opacity: 0.8 }}
onPress={handleTryAlternative}
disabled={isRetrying}
>
<BodyText
style={{
fontSize: 17,
fontWeight: '500',
fontFamily: dinot,
color: black,
}}
>
{isRetrying ? 'Loading...' : 'Try a different method'}
</BodyText>
</Button>
{/* Footer Text - Not italic */}
<BodyText
style={{
fontSize: 16,
textAlign: 'center',
color: slate500,
}}
>
Registering with alternative methods may take longer to verify
your document.
</BodyText>
</>
)}
</YStack>
</YStack>
);
};
export default RegistrationFallbackMRZScreen;

View File

@@ -0,0 +1,288 @@
// 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 } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Button, XStack, YStack } from 'tamagui';
import type { RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { BodyText } from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
blue600,
cyan300,
slate100,
slate200,
slate300,
slate500,
white,
} 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 WarningIcon from '@/assets/images/warning.svg';
import { NavBar } from '@/components/navbar/BaseNavBar';
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import { useSettingStore } from '@/stores/settingStore';
import { extraYPadding } from '@/utils/styleUtils';
type RegistrationFallbackNFCRouteParams = {
countryCode: string;
};
type RegistrationFallbackNFCRoute = RouteProp<
Record<string, RegistrationFallbackNFCRouteParams>,
string
>;
const getHeaderTitle = (documentType: string): string => {
switch (documentType) {
case 'p':
return 'PASSPORT REGISTRATION';
case 'i':
return 'ID CARD REGISTRATION';
default:
return 'DOCUMENT REGISTRATION';
}
};
const RegistrationFallbackNFCScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const paddingBottom = useSafeBottomPadding(extraYPadding + 35);
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RegistrationFallbackNFCRoute>();
const selfClient = useSelfClient();
const { trackEvent, useMRZStore } = selfClient;
const storeCountryCode = useMRZStore(state => state.countryCode);
const documentType = useMRZStore(state => state.documentType);
const kycEnabled = useSettingStore(state => state.kycEnabled);
// Use country code from route params, or fall back to MRZ store
const countryCode = route.params?.countryCode || storeCountryCode || '';
const headerTitle = getHeaderTitle(documentType);
const { launchSumsubVerification, isLoading: isRetrying } = useSumsubLauncher(
{
countryCode,
errorSource: 'nfc_scan_failed',
onCancel: () => {
navigation.goBack();
},
onError: (_error, _result) => {
// Stay on this screen - user can try again
// Error is already logged in the hook
},
onSuccess: () => {
// Success - provider handles its own success UI
// The screen will be navigated away by the provider's flow
},
},
);
const handleClose = useCallback(() => {
buttonTap();
navigation.goBack();
}, [navigation]);
const handleHelp = useCallback(() => {
buttonTap();
navigation.navigate('DocumentNFCTrouble');
}, [navigation]);
const handleTryAlternative = useCallback(async () => {
trackEvent('REGISTRATION_FALLBACK_TRY_ALTERNATIVE', {
errorSource: 'nfc_scan_failed',
});
await launchSumsubVerification();
}, [launchSumsubVerification, trackEvent]);
const handleRetryOriginal = useCallback(() => {
trackEvent('REGISTRATION_FALLBACK_RETRY_ORIGINAL', {
errorSource: 'nfc_scan_failed',
});
navigation.navigate('DocumentNFCScan', {});
}, [navigation, trackEvent]);
return (
<YStack flex={1} backgroundColor={slate100}>
{/* Header */}
<YStack backgroundColor={slate100}>
<NavBar.Container
backgroundColor={slate100}
barStyle="dark"
paddingHorizontal="$4"
paddingTop={insets.top + extraYPadding}
paddingBottom={10}
alignItems="center"
justifyContent="space-between"
>
<NavBar.LeftAction
component="close"
color={black}
onPress={handleClose}
/>
<NavBar.Title style={{ fontFamily: dinot, fontSize: 17 }}>
{headerTitle}
</NavBar.Title>
<Button unstyled onPress={handleHelp} aria-label="Help" hitSlop={8}>
<YStack
width={26}
height={26}
borderRadius={13}
backgroundColor={blue600}
alignItems="center"
justifyContent="center"
>
<BodyText
style={{
color: white,
fontSize: 16,
fontWeight: '900',
lineHeight: 18,
textAlign: 'center',
includeFontPadding: false,
}}
>
?
</BodyText>
</YStack>
</Button>
</NavBar.Container>
{/* Progress Bar - Step 3 for NFC */}
<YStack paddingHorizontal={40} paddingBottom={14} paddingTop={4}>
<XStack gap={3} height={6}>
{[1, 2, 3, 4].map(step => (
<YStack
key={step}
flex={1}
backgroundColor={step === 3 ? cyan300 : slate300}
borderRadius={10}
/>
))}
</XStack>
</YStack>
</YStack>
{/* Main Content Area */}
<YStack
flex={1}
backgroundColor={slate100}
borderBottomWidth={1}
borderBottomColor={slate200}
>
{/* Warning Icon */}
<YStack flex={1} paddingHorizontal={20} paddingBottom={20}>
<YStack flex={1} justifyContent="center" alignItems="center">
<WarningIcon width={150} height={150} />
</YStack>
</YStack>
{/* Error Message and Retry Button */}
<YStack
paddingHorizontal={20}
paddingTop={20}
paddingBottom={20}
gap={20}
borderTopWidth={1}
borderTopColor={slate200}
>
<YStack alignItems="center" gap={4}>
<BodyText
style={{ fontSize: 18, textAlign: 'center', color: black }}
>
There was a problem reading the chip
</BodyText>
<BodyText
style={{
fontSize: 16,
textAlign: 'center',
color: slate500,
}}
>
Make sure NFC is enabled and try again
</BodyText>
</YStack>
{/* Retry Button - Primary style with very rounded corners */}
<Button
backgroundColor={black}
borderRadius={100}
height={52}
pressStyle={{ opacity: 0.8 }}
onPress={handleRetryOriginal}
disabled={isRetrying}
>
<BodyText
style={{
fontSize: 17,
fontWeight: '500',
fontFamily: dinot,
color: white,
}}
>
Try reading again
</BodyText>
</Button>
</YStack>
</YStack>
{/* Bottom Section */}
<YStack
paddingHorizontal={20}
paddingTop={20}
paddingBottom={paddingBottom}
gap={10}
>
{kycEnabled && (
<>
{/* Secondary Button - White fill, black text, rounded */}
<Button
backgroundColor={white}
borderWidth={1}
borderColor={slate200}
borderRadius={100}
height={52}
pressStyle={{ opacity: 0.8 }}
onPress={handleTryAlternative}
disabled={isRetrying}
>
<BodyText
style={{
fontSize: 17,
fontWeight: '500',
fontFamily: dinot,
color: black,
}}
>
{isRetrying ? 'Loading...' : 'Try a different method'}
</BodyText>
</Button>
{/* Footer Text - Not italic */}
<BodyText
style={{
fontSize: 16,
textAlign: 'center',
color: slate500,
}}
>
Registering with alternative methods may take longer to verify
your document.
</BodyText>
</>
)}
</YStack>
</YStack>
);
};
export default RegistrationFallbackNFCScreen;

View File

@@ -5,7 +5,6 @@
import LottieView from 'lottie-react-native';
import React, { useEffect, useRef } from 'react';
import { StyleSheet } from 'react-native';
import { SystemBars } from 'react-native-edge-to-edge';
import { useNavigation } from '@react-navigation/native';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
@@ -58,7 +57,6 @@ const DocumentOnboardingScreen: React.FC = () => {
return (
<ExpandableBottomLayout.Layout backgroundColor={black}>
<SystemBars style="light" />
<ExpandableBottomLayout.TopSection roundTop backgroundColor={black}>
<LottieView
ref={animationRef}

Some files were not shown because too many files have changed in this diff Show More