Merge pull request #1802 from selfxyz/release/staging-2026-03-03

Release to Staging v2.9.16 - 2026-03-03
This commit is contained in:
Justin Hernandez
2026-03-02 18:37:19 -08:00
committed by GitHub
159 changed files with 4329 additions and 1406 deletions

View File

@@ -1304,6 +1304,16 @@ jobs:
with:
node-version-file: .nvmrc
- name: Copy version-manager from build branch
run: |
BUILD_REF="${{ github.ref_name }}"
echo "📋 Using version-manager.cjs from build branch: $BUILD_REF"
SCRIPT_CONTENT=$(git show "origin/${BUILD_REF}:app/scripts/version-manager.cjs") || {
echo "❌ Failed to read version-manager.cjs from origin/${BUILD_REF}"
exit 1
}
echo "$SCRIPT_CONTENT" > "${{ env.APP_PATH }}/scripts/version-manager.cjs"
- name: Apply version bump from outputs
run: |
cd ${{ env.APP_PATH }}
@@ -1314,56 +1324,39 @@ jobs:
IOS_SUCCESS="${{ needs.build-ios.result }}"
ANDROID_SUCCESS="${{ needs.build-android.result }}"
echo "📝 Applying version bump: $VERSION (iOS: $IOS_BUILD, Android: $ANDROID_BUILD)"
echo "📝 Applying version bump: $VERSION (iOS: $IOS_BUILD [$IOS_SUCCESS], Android: $ANDROID_BUILD [$ANDROID_SUCCESS])"
# Update package.json version
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = '$VERSION';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
console.log('✅ Updated package.json');
"
# Update package.json, version.json, and platform build files
# Pass build results so the script only updates platforms that succeeded
node scripts/version-manager.cjs apply "$VERSION" "$IOS_BUILD" "$ANDROID_BUILD" "$IOS_SUCCESS" "$ANDROID_SUCCESS"
# Update version.json build numbers and deployment timestamps
node -e "
const fs = require('fs');
const version = JSON.parse(fs.readFileSync('version.json', 'utf8'));
const timestamp = new Date().toISOString();
- name: Load version-managed files
run: |
cd ${{ env.APP_PATH }}
// Only bump build numbers for successful builds
if ('$IOS_SUCCESS' === 'success') {
version.ios.build = $IOS_BUILD;
version.ios.lastDeployed = timestamp;
console.log('✅ Updated iOS build number to $IOS_BUILD and lastDeployed timestamp');
} else {
console.log('⏭️ Skipped iOS build number bump (build did not succeed)');
}
VERSION_MANAGED_FILES="$(node scripts/version-manager.cjs files --shell)"
if [ -z "$VERSION_MANAGED_FILES" ]; then
echo "❌ Failed to load version-managed files"
exit 1
fi
if ('$ANDROID_SUCCESS' === 'success') {
version.android.build = $ANDROID_BUILD;
version.android.lastDeployed = timestamp;
console.log('✅ Updated Android build number to $ANDROID_BUILD and lastDeployed timestamp');
} else {
console.log('⏭️ Skipped Android build number bump (build did not succeed)');
}
fs.writeFileSync('version.json', JSON.stringify(version, null, 2) + '\n');
console.log('✅ Updated version.json');
"
echo "✅ Versions applied successfully"
{
echo "VERSION_MANAGED_FILES<<EOF"
echo "$VERSION_MANAGED_FILES"
echo "EOF"
} >> $GITHUB_ENV
echo "🗂️ Version-managed files: $VERSION_MANAGED_FILES"
- name: Verify version changes
run: |
cd ${{ env.APP_PATH }}
# Check that version files actually changed
if ! git diff --quiet package.json version.json; then
# Check that version-managed files actually changed
if ! git diff --quiet -- $VERSION_MANAGED_FILES; then
echo "✅ Version changes detected"
git diff package.json version.json
git diff -- $VERSION_MANAGED_FILES
else
echo "⚠️ No version changes detected in package.json or version.json"
echo "⚠️ No version changes detected in version-managed files"
echo "This may indicate a problem with version application"
exit 1
fi
@@ -1430,14 +1423,14 @@ jobs:
# Commit the version changes
cd ${{ env.APP_PATH }}
git add package.json version.json
git add $VERSION_MANAGED_FILES
if git diff --cached --quiet; then
echo "⚠️ No version changes to commit"
exit 0
fi
git commit -m "chore: bump mobile app version to $VERSION" -m "Update build numbers and deployment timestamps after successful deployment."
git commit -m "chore: bump mobile app version to $VERSION" -m "Update build numbers, platform build files, and deployment timestamps after successful deployment."
# Create new branch from current HEAD (bump target branch with version bump)
git checkout -b ${BRANCH_NAME}
@@ -1462,6 +1455,7 @@ jobs:
This PR updates:
- Build numbers for deployed platforms
- Platform build files (Android `versionCode`, iOS `CURRENT_PROJECT_VERSION` and `MARKETING_VERSION`)
- Deployment timestamps (\`lastDeployed\`) for successful builds
This PR was automatically created by the mobile deployment workflow." \

View File

@@ -3,7 +3,7 @@ name: Mobile E2E
env:
# Build environment versions
JAVA_VERSION: 17
ANDROID_API_LEVEL: 33
ANDROID_API_LEVEL: 34
ANDROID_NDK_VERSION: 27.0.12077973
XCODE_VERSION: 26
# Cache versions
@@ -41,14 +41,15 @@ on:
workflow_dispatch:
jobs:
android-build-test:
# Currently build-only for Android with private repos. E2E steps are preserved but skipped (if: false).
# To re-enable full E2E: change `if: false` to `if: true` on Maestro and emulator steps.
e2e-android:
concurrency:
group: ${{ github.workflow }}-android-${{ github.ref }}
cancel-in-progress: true
timeout-minutes: 120
runs-on: ubuntu-latest
runs-on:
- "self-hosted"
- "selfxyz-org"
- "ubuntu-24-04"
steps:
- uses: actions/checkout@v6
- name: Read and sanitize Node.js version
@@ -109,36 +110,45 @@ jobs:
max_attempts: 3
retry_wait_seconds: 5
command: yarn install --immutable --silent
- name: Validate Maestro test file
if: false # Skip for build-only test - keep logic for future E2E
run: |
[ -f app/tests/e2e/launch.android.flow.yaml ] || { echo "❌ Android E2E test file missing"; exit 1; }
- name: Cache Maestro
if: false # Skip for build-only test - keep logic for future E2E
id: cache-maestro
uses: actions/cache@v4
with:
path: ~/.maestro
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}
- name: Install Maestro
if: false # Skip for build-only test - keep logic for future E2E
run: curl -Ls "https://get.maestro.mobile.dev" | bash
- name: Add Maestro to path
if: false # Skip for build-only test - keep logic for future E2E
run: echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Free up disk space
uses: ./.github/actions/free-disk-space
- name: Check Java installation
run: |
echo "INSTALL_JAVA=false" >> "$GITHUB_ENV"
if command -v java &> /dev/null && java -version &> /dev/null; then
echo "Java already installed: $(java -version 2>&1 | head -n 1)"
else
echo "Java not found or not working, will install..."
echo "INSTALL_JAVA=true" >> "$GITHUB_ENV"
fi
- name: Setup Java environment
if: env.INSTALL_JAVA == 'true'
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Install Maestro
run: curl -Ls "https://get.maestro.mobile.dev" | bash
- name: Add Maestro to path
run: echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Free up disk space
if: false # We should be fine for self-hosted
uses: ./.github/actions/free-disk-space
- name: Cache Gradle packages
uses: ./.github/actions/cache-gradle
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
accept-android-sdk-licenses: true
- name: Install NDK
uses: nick-fields/retry@v3
with:
@@ -146,6 +156,7 @@ jobs:
max_attempts: 3
retry_wait_seconds: 10
command: sdkmanager "ndk;${{ env.ANDROID_NDK_VERSION }}"
- name: Build dependencies (outside emulator)
run: |
echo "Building dependencies..."
@@ -165,8 +176,12 @@ jobs:
run: |
echo "Building Android APK..."
chmod +x app/android/gradlew
(cd app/android && ./gradlew assembleDebug --quiet --parallel --build-cache --no-configuration-cache) || { echo "❌ Android build failed"; exit 1; }
(
cd app/android &&
E2E_BUILD=true ./gradlew assembleDebug -PbundleInDebug=true --quiet --parallel --build-cache --no-configuration-cache
) || { echo "❌ Android build failed"; exit 1; }
echo "✅ Android build succeeded"
- name: Clean up Gradle build artifacts
uses: ./.github/actions/cleanup-gradle-artifacts
- name: Verify APK and android-passport-nfc-reader integration
@@ -208,32 +223,80 @@ jobs:
fi
echo "🎉 Build verification completed successfully!"
echo " Emulator testing is temporarily disabled - build testing only"
- name: Check KVM access
id: kvm
run: |
if [ ! -e /dev/kvm ]; then
echo "::warning::/dev/kvm not found — emulator will run in software mode (slow). Enable nested virtualization for better performance."
echo "available=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -w /dev/kvm ]; then
echo "✅ /dev/kvm is already writable"
elif [ -d /etc/udev/rules.d ]; then
echo "Setting KVM permissions via udev..."
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
else
echo "No udev available, setting /dev/kvm permissions directly..."
sudo chmod 666 /dev/kvm
fi
ls -la /dev/kvm
echo "available=true" >> "$GITHUB_OUTPUT"
- name: Install emulator runtime dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
libx11-6 libgl1 libpulse0 libnss3 \
libxcomposite1 libxcursor1 libxi6 libxtst6 \
libxrandr2 libxss1 libxdamage1 libxfixes3 \
libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libgbm1 libgtk-3-0 libpango-1.0-0 libcairo2 \
> /dev/null 2>&1
- name: Create E2E test script
run: |
cat > /tmp/run-e2e.sh << 'EOF'
#!/bin/bash
set -euo pipefail
APP_ID="com.proofofpassportapp"
echo "Installing app on emulator..."
APK_PATH="app/android/app/build/outputs/apk/debug/app-debug.apk"
[ -f "$APK_PATH" ] || { echo "❌ APK not found at $APK_PATH"; exit 1; }
adb uninstall "$APP_ID" >/dev/null 2>&1 || true
adb install -r "$APK_PATH" || { echo "❌ App installation failed"; exit 1; }
adb shell pm clear "$APP_ID" >/dev/null 2>&1 || true
echo "✅ App installed successfully"
echo "⏰ Giving the emulator a moment to settle..."
sleep 5
echo "🎭 Running Maestro tests..."
export MAESTRO_DRIVER_STARTUP_TIMEOUT=180000
export LAUNCH_WAIT_MS=180000
if ! maestro test app/tests/e2e/launch.android.flow.yaml --format junit --output app/maestro-results.xml; then
echo "❌ Maestro failed - dumping Android diagnostics"
adb shell dumpsys activity activities | sed -n '1,240p' || true
adb logcat -d | tail -n 400 || true
exit 1
fi
EOF
chmod +x /tmp/run-e2e.sh
- name: Install and Test on Android
if: false # Skip emulator/E2E for build-only test - keep logic for future E2E
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ env.ANDROID_API_LEVEL }}
arch: x86_64
target: google_apis
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -memory 8192 -cores 4 -accel on
target: default
channel: stable
force-avd-creation: true
emulator-options: >-
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
-camera-back none -camera-front none -memory 8192 -cores 4
${{ steps.kvm.outputs.available == 'true' && '-accel on' || '-accel off' }}
disable-animations: true
script: |
echo "Installing app on emulator..."
APK_PATH="app/android/app/build/outputs/apk/debug/app-debug.apk"
[ -f "$APK_PATH" ] || { echo "❌ APK not found at $APK_PATH"; exit 1; }
adb install -r "$APK_PATH" || { echo "❌ App installation failed"; exit 1; }
echo "✅ App installed successfully"
script: /tmp/run-e2e.sh
echo "⏰ Giving the emulator a moment to settle..."
sleep 5
echo "🎭 Running Maestro tests..."
export MAESTRO_DRIVER_STARTUP_TIMEOUT=180000
maestro test app/tests/e2e/launch.android.flow.yaml --format junit --output app/maestro-results.xml
- name: Upload test results
if: false # Skip for build-only test - keep logic for future E2E
if: always()
uses: actions/upload-artifact@v4
with:
name: maestro-results-android

2
.nvmrc
View File

@@ -1 +1 @@
22.12.0
22.22.0

View File

@@ -42,7 +42,7 @@ Before creating a PR, ensure:
#### AI Review Preparation
- [ ] Clear commit messages following conventional format
- [ ] Clear, imperative commit messages (e.g. `Fix address validation`)
- [ ] PR description includes context for AI reviewers
- [ ] Complex changes have inline comments explaining intent
- [ ] Security-sensitive changes flagged for special review

View File

@@ -16,9 +16,19 @@ nvm use && corepack enable && yarn install
## Key Rules
- **Package manager:** Yarn (never npm or pnpm)
- **Keep the codebase DRY.** Before writing new code, search for existing utilities/components/flows and reuse or refactor to shared modules. Create new code only if a reusable option does not exist.
- **Extract repeated UI.** If the same UI sub-structure appears in 2+ places, extract a shared component.
- **Reusable UI belongs in shared libraries.** If a UI primitive is broadly reusable, add it to a shared library (e.g., `@selfxyz/euclid` or another shared package) instead of duplicating in feature code.
- **Keep files small.** Aim for <800 LOC per file. If a file approaches 800 LOC, split it into smaller modules.
- **Move static data out of UI.** Large static maps/lookups/constants do not belong in screen/components; move them to `utils/` or `data/` modules.
- **Prefer design tokens over hex.** Use shared color/font/spacing tokens instead of raw hex values in UI code.
- **No `react-native` imports in SDK core.** `packages/mobile-sdk-alpha/src/` must be platform-agnostic outside of `src/adapters/react-native/`.
- **Native handlers are thin wrappers.** No business logic in Kotlin or Swift. All logic lives in TypeScript.
- **Keychain is always native-managed.** No web fallbacks for secure storage. This is a security boundary.
- **No “slop comments.”** Only add comments when they convey non-obvious intent or constraints. Never add generic or chatty comments.
- **Test value over mock wiring.** Prefer tests that validate behavior. Avoid tests that only assert mocks were called unless that is the behavior being validated.
- **PR size target:** 1k3k LOC changed. Smaller is fine for focused fixes. If >3k, add a brief justification for why it cant be split.
- **No generated artifacts in source PRs.** Do not commit build outputs or generated assets unless the build system requires them for runtime or distribution.
## Specs & Planning
@@ -61,13 +71,13 @@ Quick-start prompts for creating new specs are in [SPEC-GUIDE.md](./specs/SPEC-G
```bash
# SDK core
cd packages/mobile-sdk-alpha && npx vitest run && npx tsc --noEmit
cd packages/mobile-sdk-alpha && yarn test && yarn types
# Bridge
cd packages/webview-bridge && yarn build && yarn vitest run
cd packages/webview-bridge && yarn build && yarn test
# WebView app
cd packages/webview-app && npx tsc --noEmit && npx vite build
cd packages/webview-app && yarn build
# KMP
cd packages/kmp-sdk && ./gradlew :shared:jvmTest

View File

@@ -1 +1 @@
3.2.7
3.2.8

View File

@@ -15,7 +15,7 @@ GEM
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.8)
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
@@ -23,7 +23,7 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1216.0)
aws-partitions (1.1220.0)
aws-sdk-core (3.242.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@@ -133,7 +133,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.232.1)
fastlane (2.232.2)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
@@ -205,7 +205,7 @@ GEM
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.60.0)
google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
@@ -244,7 +244,8 @@ GEM
mini_magick (4.13.2)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (6.0.1)
minitest (6.0.2)
drb (~> 2.0)
prism (~> 1.5)
molinillo (0.8.0)
multi_json (1.19.1)
@@ -329,7 +330,7 @@ DEPENDENCIES
nokogiri (~> 1.18)
RUBY VERSION
ruby 3.2.7p253
ruby 3.2.8p263
BUNDLED WITH
2.6.9

View File

@@ -24,6 +24,10 @@ react {
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
if (System.env.E2E_BUILD == "true") {
// For E2E/CI builds: bundle JS into the debug APK (no Metro server available)
debuggableVariants = []
}
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
@@ -221,7 +225,7 @@ dependencies {
implementation jscFlavor
}
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation("net.java.dev.jna:jna:5.16.0@aar")
implementation("net.java.dev.jna:jna:5.16.0")
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android'
implementation 'com.google.code.gson:gson:2.8.9'

View File

@@ -24,6 +24,7 @@ buildscript {
classpath('com.android.tools.build:gradle:8.11.2')
classpath("com.facebook.react:react-native-gradle-plugin")
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlinVersion"
classpath 'com.google.gms:google-services:4.4.0'
// Removed firebase-crashlytics (no usages)—add back 3.x if still applied in modules.
// Removed rust-android-gradle plugin; keep only if you re-enable Rust integration.
@@ -60,6 +61,9 @@ allprojects {
subprojects {
afterEvaluate { project ->
if (project.hasProperty('android') && project.android.buildFeatures.compose) {
project.apply plugin: 'org.jetbrains.kotlin.plugin.compose'
}
if (project.hasProperty('android')) {
android {
def manifestFile = project.file('src/main/AndroidManifest.xml')

View File

@@ -30,7 +30,7 @@ require File.join(File.dirname(`node --print "require.resolve('expo/package.json
project "Self.xcodeproj"
# Define consistent iOS deployment target
IOS_DEPLOYMENT_TARGET = "15.1"
IOS_DEPLOYMENT_TARGET = "15.4"
platform :ios, IOS_DEPLOYMENT_TARGET if !ENV["ACT"]
prepare_react_native_project!

View File

@@ -8,6 +8,28 @@ PODS:
- boost (1.84.0)
- BVLinearGradient (2.8.3):
- React-Core
- dotlottie-react-native (0.5.0):
- DoubleConversion
- glog
- hermes-engine
- LottieFiles-dotLottie-iOS (~> 0.9)
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- DoubleConversion (1.1.6)
- EXApplication (6.0.2):
- ExpoModulesCore
@@ -49,7 +71,7 @@ PODS:
- Yoga
- fast_float (6.1.4)
- FBLazyVector (0.77.0)
- FingerprintPro (2.12.0)
- FingerprintPro (2.13.0)
- Firebase (10.24.0):
- Firebase/Core (= 10.24.0)
- Firebase/Core (10.24.0):
@@ -196,29 +218,8 @@ PODS:
- IdensicMobileSDK/Fisherman (1.40.2):
- FingerprintPro (~> 2.11)
- IdensicMobileSDK/Core
- lottie-ios (4.5.0)
- lottie-react-native (7.2.2):
- DoubleConversion
- glog
- hermes-engine
- lottie-ios (= 4.5.0)
- RCT-Folly (= 2024.11.18.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- lottie-ios (4.6.0)
- LottieFiles-dotLottie-iOS (0.11.1)
- Mixpanel-swift (5.0.0):
- Mixpanel-swift/Complete (= 5.0.0)
- Mixpanel-swift/Complete (5.0.0)
@@ -2182,6 +2183,7 @@ PODS:
DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
- "dotlottie-react-native (from `../node_modules/@lottiefiles/dotlottie-react-native`)"
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- EXApplication (from `../node_modules/expo-application/ios`)
- EXConstants (from `../node_modules/expo-constants/ios`)
@@ -2202,7 +2204,6 @@ DEPENDENCIES:
- GoogleUtilities
- 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
- "NFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`, commit `9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b`)"
- QKMRZScanner
@@ -2319,6 +2320,7 @@ SPEC REPOS:
- GoogleDataTransport
- GoogleUtilities
- lottie-ios
- LottieFiles-dotLottie-iOS
- Mixpanel-swift
- nanopb
- OpenSSL-Universal
@@ -2334,6 +2336,8 @@ EXTERNAL SOURCES:
:podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
BVLinearGradient:
:path: "../node_modules/react-native-linear-gradient"
dotlottie-react-native:
:path: "../node_modules/@lottiefiles/dotlottie-react-native"
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
EXApplication:
@@ -2363,8 +2367,6 @@ EXTERNAL SOURCES:
hermes-engine:
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-2024-11-25-RNv0.77.0-d4f25d534ab744866448b36ca3bf3d97c08e638c
lottie-react-native:
:path: "../node_modules/lottie-react-native"
NFCPassportReader:
:commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b
:git: "git@github.com:selfxyz/NFCPassportReader.git"
@@ -2559,6 +2561,7 @@ SPEC CHECKSUMS:
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70
dotlottie-react-native: 056445614fe969f8d8d90a744944089261e6a620
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
EXApplication: 4c72f6017a14a65e338c5e74fca418f35141e819
EXConstants: fcfc75800824ac2d5c592b5bc74130bad17b146b
@@ -2570,7 +2573,7 @@ SPEC CHECKSUMS:
ExpoModulesCore: bcee92d3a2c68c408b2d8da43e3094109340dc17
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 2bc03a5cf64e29c611bbc5d7eb9d9f7431f37ee6
FingerprintPro: 035517a1b4e3e4fc073486b53b9956509010f8db
FingerprintPro: 2f419138022451a72f783db9c94967f5a68e9977
Firebase: 91fefd38712feb9186ea8996af6cbdef41473442
FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe
FirebaseAnalytics: b5efc493eb0f40ec560b04a472e3e1a15d39ca13
@@ -2589,8 +2592,8 @@ SPEC CHECKSUMS:
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
hermes-engine: 1f783c3d53940aed0d2c84586f0b7a85ab7827ef
IdensicMobileSDK: 00b13320e1b1e0574e68475bd0fbc7cd30fce26e
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: 6cb05b7b4ea463afe657e3b46784f067858e1a5d
lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3
LottieFiles-dotLottie-iOS: e9b34e7cff6d04f5affd97336c2dab934b86e6fb
Mixpanel-swift: e9bef28a9648faff384d5ba6f48ecc2787eb24c0
nanopb: 438bc412db1928dac798aa6fd75726007be04262
NFCPassportReader: 48873f856f91215dbfa1eaaec20eae639672862e
@@ -2691,6 +2694,6 @@ SPEC CHECKSUMS:
SwiftyTesseract: 1f3d96668ae92dc2208d9842c8a59bea9fad2cbb
Yoga: c34725819ab0a5962e85455b9e56679b306910ee
PODFILE CHECKSUM: 819cc2c73ef2429fda27c9f3e3ac757539b52c75
PODFILE CHECKSUM: 63b07a2aa49988e11d648524199e37c6a96de7aa
COCOAPODS: 1.16.2

View File

@@ -569,7 +569,7 @@
INFOPLIST_FILE = OpenPassport/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OpenPassport;
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Needed only if you want to upload QRcodes";
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -712,7 +712,7 @@
INFOPLIST_KEY_CFBundleDisplayName = OpenPassport;
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Needed only if you want to upload QRcodes";
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -812,7 +812,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
);
INFOPLIST_KEY_NSCameraUsageDescription = "Needed to scan your passport MRZ, you can however enter it manually.";
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = (
@@ -909,7 +909,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
);
INFOPLIST_KEY_NSCameraUsageDescription = "Needed to scan your passport MRZ, you can however enter it manually.";
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
IPHONEOS_DEPLOYMENT_TARGET = 15.4;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = (

View File

@@ -31,7 +31,8 @@ module.exports = {
moduleNameMapper: {
'^@env$': '<rootDir>/tests/__setup__/@env.js',
'\\.svg$': '<rootDir>/tests/__setup__/svgMock.js',
'\\.(png|jpg|jpeg|gif|webp)$': '<rootDir>/tests/__setup__/imageMock.js',
'\\.(png|jpg|jpeg|gif|webp|lottie)$':
'<rootDir>/tests/__setup__/imageMock.js',
'^@/(.*)$': '<rootDir>/src/$1',
'^@$': '<rootDir>/src',
'^@tests/(.*)$': '<rootDir>/tests/src/$1',
@@ -50,6 +51,8 @@ module.exports = {
'<rootDir>/../packages/mobile-sdk-alpha/dist/cjs/flows/disclosing/$1.cjs',
'^@selfxyz/mobile-sdk-alpha/(.*)\\.json$':
'<rootDir>/../packages/mobile-sdk-alpha/dist/$1.json',
'^@selfxyz/mobile-sdk-alpha/(.*)\\.lottie$':
'<rootDir>/tests/__setup__/imageMock.js',
'^@selfxyz/mobile-sdk-alpha/(.*)$':
'<rootDir>/../packages/mobile-sdk-alpha/dist/cjs/$1.cjs',
// Fix snarkjs resolution for @anon-aadhaar/core

View File

@@ -68,7 +68,7 @@ const config = {
new RegExp('packages/mobile-sdk-alpha/node_modules/react-dom(/|$)'),
new RegExp('packages/mobile-sdk-alpha/node_modules/react-native(/|$)'),
new RegExp(
'packages/mobile-sdk-alpha/node_modules/lottie-react-native(/|$)',
'packages/mobile-sdk-alpha/node_modules/@lottiefiles/dotlottie-react-native(/|$)',
),
new RegExp('packages/mobile-sdk-alpha/node_modules/scheduler(/|$)'),
new RegExp(
@@ -117,8 +117,8 @@ const config = {
// Support package exports with conditions
unstable_conditionNames: ['react-native', 'import', 'require'],
// SVG support
assetExts: assetExts.filter(ext => ext !== 'svg'),
// SVG support + dotLottie binary assets
assetExts: [...assetExts.filter(ext => ext !== 'svg'), 'lottie'],
sourceExts: [...sourceExts, 'svg'],
// Custom resolver to handle both .js imports in TypeScript and Node.js modules
@@ -133,6 +133,41 @@ const config = {
'packages/mobile-sdk-alpha',
);
// Deduplicate SDK animation imports — resolve to app's single copy when possible
if (
/\.(json|lottie)$/.test(moduleName) &&
context.originModulePath?.includes('mobile-sdk-alpha')
) {
// Extract the animation-relative path from either bare or relative specifiers
let animRelPath;
if (moduleName.startsWith('src/animations/')) {
animRelPath = moduleName.replace('src/animations/', '');
} else if (/\/animations\//.test(moduleName)) {
animRelPath = moduleName.split('/animations/').pop();
}
if (animRelPath) {
// Try app's animations first (deduplication)
const appAnimPath = path.resolve(
projectRoot,
'src/assets/animations',
animRelPath,
);
if (fs.existsSync(appAnimPath)) {
return { type: 'assetFiles', filePaths: [appAnimPath] };
}
// Fall back to SDK's own copy (for SDK-only animations like loading/*)
const sdkAnimPath = path.resolve(
sdkAlphaPath,
'src/animations',
animRelPath,
);
if (fs.existsSync(sdkAnimPath)) {
return { type: 'assetFiles', filePaths: [sdkAnimPath] };
}
}
}
// Custom resolver to handle Node.js modules and dynamic flow imports
if (moduleName.startsWith('@selfxyz/mobile-sdk-alpha/')) {
const subPath = moduleName.replace('@selfxyz/mobile-sdk-alpha/', '');

View File

@@ -6,6 +6,7 @@
"scripts": {
"analyze:bundle:android": "yarn build:deps && node ./scripts/bundle-analyze-ci.cjs android",
"analyze:bundle:ios": "yarn build:deps && node ./scripts/bundle-analyze-ci.cjs ios",
"animations:convert": "node ./scripts/convert-to-dotlottie.mjs",
"analyze:tree-shaking": "node ./scripts/analyze-tree-shaking.cjs imports",
"analyze:tree-shaking:web": "yarn web:build && node ./scripts/analyze-tree-shaking.cjs web",
"android": "yarn build:deps && yarn setup:android-deps && react-native run-android",
@@ -87,6 +88,8 @@
"dependencies": {
"@babel/runtime": "^7.28.6",
"@ethersproject/shims": "^5.8.0",
"@lottiefiles/dotlottie-react": "^0.17.15",
"@lottiefiles/dotlottie-react-native": "0.5.0",
"@noble/hashes": "^1.5.0",
"@openpassport/zk-kit-imt": "^0.0.5",
"@openpassport/zk-kit-lean-imt": "^0.0.6",
@@ -135,7 +138,6 @@
"js-sha256": "^0.11.1",
"js-sha512": "^0.9.0",
"lottie-react": "^2.4.1",
"lottie-react-native": "7.2.2",
"node-forge": "^1.3.3",
"pkijs": "^3.3.3",
"poseidon-lite": "^0.2.0",
@@ -185,6 +187,7 @@
"@babel/plugin-transform-private-methods": "^7.28.6",
"@babel/preset-env": "^7.28.6",
"@babel/preset-react": "^7.28.5",
"@dotlottie/dotlottie-js": "^1.6.2",
"@react-native-community/cli": "^16.0.3",
"@react-native/babel-preset": "0.77.0",
"@react-native/eslint-config": "0.77.0",

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
// Convert large Lottie JSON animations to compressed dotLottie format.
// Usage: node scripts/convert-to-dotlottie.mjs
//
// Web compatibility: @lottiefiles/dotlottie-react-native supports .lottie
// natively, but lottie-web does NOT. If these animations are ever used on
// the web, use
// @lottiefiles/dotlottie-web (or its React/Vue wrappers) instead of lottie-web.
import { readFileSync, writeFileSync, statSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { DotLottie } from '@dotlottie/dotlottie-js';
const files = process.argv.slice(2);
if (files.length === 0) {
console.error(
'Usage: node convert-to-dotlottie.mjs <file1.json> [file2.json ...]',
);
process.exit(1);
}
for (const file of files) {
const jsonData = readFileSync(file, 'utf-8');
const animName = basename(file, '.json');
const dir = dirname(file);
const outFile = join(dir, `${animName}.lottie`);
const dotlottie = new DotLottie();
dotlottie.addAnimation({
id: animName,
data: JSON.parse(jsonData),
});
const buffer = await dotlottie.build();
writeFileSync(outFile, Buffer.from(await buffer.toArrayBuffer()));
const jsonSize = statSync(file).size;
const lottieSize = statSync(outFile).size;
const pct = ((1 - lottieSize / jsonSize) * 100).toFixed(1);
console.log(
`${basename(file)}${basename(outFile)}: ${(jsonSize / 1024).toFixed(0)}KB → ${(lottieSize / 1024).toFixed(0)}KB (${pct}% smaller)`,
);
}

View File

@@ -4,11 +4,17 @@
set -e
trap 'err "Setup failed at line $LINENO: $BASH_COMMAND"' ERR
# Config
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
APP_DIR="$REPO_ROOT/app"
RUBY_VERSION=$(cat "$APP_DIR/.ruby-version" 2>/dev/null | tr -d '[:space:]')
NODE_MAJOR=22
NODE_VERSION=$(cat "$REPO_ROOT/.nvmrc" 2>/dev/null | tr -d '[:space:]')
NODE_VERSION=${NODE_VERSION:-22.22.0}
NODE_MAJOR=${NODE_VERSION%%.*}
COCOAPODS_VERSION=$(grep -E '^ cocoapods \(' "$APP_DIR/Gemfile.lock" 2>/dev/null | head -1 | sed -E 's/.*\(([^)]+)\).*/\1/')
BUNDLER_VERSION=$(grep -A 1 '^BUNDLED WITH$' "$APP_DIR/Gemfile.lock" 2>/dev/null | tail -n 1 | tr -d '[:space:]')
# Args (can be overridden interactively)
CHECK_ONLY=false; AUTO_YES=false
@@ -34,32 +40,126 @@ confirm() {
[[ ! $REPLY =~ ^[Nn]$ ]]
}
init_rbenv_shell() {
command -v rbenv &>/dev/null || return 0
# Avoid failing when the project's Ruby version is not installed yet.
eval "$(rbenv init - --no-rehash 2>/dev/null)" || true
}
load_shell_env() {
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
init_rbenv_shell
}
# Check functions - return "ok:version" or "missing" or "wrong:version"
chk_brew() { command -v brew &>/dev/null && echo "ok:$(brew --version | head -1 | cut -d' ' -f2)" || echo "missing"; }
chk_nvm() { [[ -s "$HOME/.nvm/nvm.sh" ]] && echo "ok" || echo "missing"; }
chk_node() { command -v node &>/dev/null && { v=$(node -v 2>/dev/null | tr -d 'v'); [[ -n "$v" && "${v%%.*}" -ge $NODE_MAJOR ]] && echo "ok:$v" || echo "wrong:$v"; } || echo "missing"; }
chk_node() {
command -v node &>/dev/null && {
v=$(node -v 2>/dev/null | tr -d 'v')
if [[ "$NODE_VERSION" == *.* ]]; then
[[ -n "$v" && "$v" == "$NODE_VERSION" ]] && echo "ok:$v" || echo "wrong:$v"
else
[[ -n "$v" && "${v%%.*}" -ge $NODE_MAJOR ]] && echo "ok:$v" || echo "wrong:$v"
fi
} || echo "missing"
}
chk_watch() { command -v watchman &>/dev/null && echo "ok:$(watchman --version 2>/dev/null)" || echo "missing"; }
chk_rbenv() { command -v rbenv &>/dev/null && echo "ok" || echo "missing"; }
chk_ruby() { command -v ruby &>/dev/null && { v=$(ruby -v 2>/dev/null | cut -d' ' -f2); [[ "$v" == "$RUBY_VERSION"* ]] && echo "ok:$v" || echo "wrong:$v"; } || echo "missing"; }
chk_pods() { command -v pod &>/dev/null && echo "ok:$(pod --version 2>/dev/null)" || echo "missing"; }
chk_bundler() { command -v bundle &>/dev/null && echo "ok" || echo "missing"; }
chk_ruby() {
if command -v rbenv &>/dev/null; then
rbenv versions --bare 2>/dev/null | grep -q "^${RUBY_VERSION}" && echo "ok:$RUBY_VERSION" || echo "missing"
else
command -v ruby &>/dev/null && { v=$(ruby -v 2>/dev/null | cut -d' ' -f2); [[ "$v" == "$RUBY_VERSION"* ]] && echo "ok:$v" || echo "wrong:$v"; } || echo "missing"
fi
}
chk_pods() {
command -v pod &>/dev/null || { echo "missing"; return; }
v=$(pod --version 2>/dev/null) || { echo "missing"; return; }
[[ -n "$v" ]] || { echo "missing"; return; }
[[ -n "$COCOAPODS_VERSION" && "$v" != "$COCOAPODS_VERSION" ]] && echo "wrong:$v" || echo "ok:$v"
}
chk_bundler() {
command -v bundle &>/dev/null || { echo "missing"; return; }
v=$(bundle -v 2>/dev/null | awk '{print $3}') || { echo "missing"; return; }
[[ -n "$v" ]] || { echo "missing"; return; }
[[ -n "$BUNDLER_VERSION" && "$v" != "$BUNDLER_VERSION" ]] && echo "wrong:$v" || echo "ok:$v"
}
chk_java() { command -v java &>/dev/null && { v=$(java -version 2>&1 | head -1 | cut -d'"' -f2); [[ "$v" == 17* ]] && echo "ok:$v" || echo "wrong:$v"; } || echo "missing"; }
chk_xcode() { xcode-select -p &>/dev/null && [[ "$(xcode-select -p)" == *Xcode.app* ]] && echo "ok" || echo "missing"; }
chk_studio() { [[ -d "/Applications/Android Studio.app" ]] && echo "ok" || echo "missing"; }
chk_sdk() { [[ -d "${ANDROID_HOME:-$HOME/Library/Android/sdk}" ]] && echo "ok" || echo "missing"; }
chk_ndk() { [[ -d "${ANDROID_HOME:-$HOME/Library/Android/sdk}/ndk/28.0.13004108" ]] && echo "ok" || echo "missing"; }
chk_shell() { local rc=~/.zshrc; [[ "$SHELL" == *bash* ]] && rc=~/.bashrc; grep -q "ANDROID_HOME" "$rc" 2>/dev/null && echo "ok" || echo "missing"; }
chk_yarn() {
command -v yarn &>/dev/null || { echo "missing"; return; }
v=$(yarn -v 2>/dev/null)
[[ -n "$v" && "${v%%.*}" -ge 4 ]] && echo "ok:$v" || echo "wrong:$v"
}
chk_swiftlint() { command -v swiftlint &>/dev/null && echo "ok:$(swiftlint version 2>/dev/null | head -1)" || echo "missing"; }
# Install functions
inst_brew() { /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; }
inst_nvm() { curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash; export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; }
inst_node() { export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; nvm install $NODE_MAJOR; }
inst_nvm() { curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash; export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; }
inst_node() {
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
if ! command -v nvm &>/dev/null; then
err "nvm not available after install"
return 1
fi
nvm install "$NODE_VERSION"
nvm alias default "$NODE_VERSION" >/dev/null 2>&1 || true
}
inst_watch() { brew install watchman; }
inst_rbenv() { brew install rbenv; eval "$(rbenv init -)"; }
inst_ruby() { eval "$(rbenv init -)" 2>/dev/null; rbenv install "$RUBY_VERSION"; rbenv rehash; }
inst_pods() { gem install cocoapods; }
inst_bundler() { gem install bundler; }
inst_rbenv() { brew install rbenv; init_rbenv_shell; }
inst_ruby() { init_rbenv_shell; rbenv install -s "$RUBY_VERSION"; rbenv global "$RUBY_VERSION"; rbenv rehash; }
inst_pods() {
if command -v rbenv &>/dev/null; then
init_rbenv_shell
rbenv shell "$RUBY_VERSION" 2>/dev/null || true
if [[ -n "$COCOAPODS_VERSION" ]]; then
rbenv exec gem install cocoapods -v "$COCOAPODS_VERSION"
else
rbenv exec gem install cocoapods
fi
else
if [[ -n "$COCOAPODS_VERSION" ]]; then
gem install cocoapods -v "$COCOAPODS_VERSION"
else
gem install cocoapods
fi
fi
}
inst_bundler() {
if command -v rbenv &>/dev/null; then
init_rbenv_shell
rbenv shell "$RUBY_VERSION" 2>/dev/null || true
if [[ -n "$BUNDLER_VERSION" ]]; then
rbenv exec gem install bundler -v "$BUNDLER_VERSION"
else
rbenv exec gem install bundler
fi
else
if [[ -n "$BUNDLER_VERSION" ]]; then
gem install bundler -v "$BUNDLER_VERSION"
else
gem install bundler
fi
fi
}
inst_java() { brew install openjdk@17; sudo ln -sfn "$(brew --prefix openjdk@17)/libexec/openjdk.jdk" /Library/Java/JavaVirtualMachines/openjdk-17.jdk 2>/dev/null || true; }
inst_yarn() {
if command -v corepack &>/dev/null; then
corepack enable
corepack prepare yarn@stable --activate
else
err "corepack not available; ensure Node.js is installed and on PATH"
return 1
fi
}
inst_swiftlint() { brew install swiftlint; }
inst_shell() {
local rc=~/.zshrc
@@ -71,16 +171,20 @@ inst_shell() {
return 0
fi
local jdk_path
jdk_path="$(brew --prefix openjdk@17 2>/dev/null || echo /opt/homebrew/opt/openjdk@17)"
info "Adding environment to $rc..."
cat >> "$rc" << 'EOF'
cat >> "$rc" << EOF
# Self.xyz Dev Environment
export JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || echo "")
export JAVA_HOME=\$(/usr/libexec/java_home -v 17 2>/dev/null || echo "")
export PATH="$jdk_path/bin:\$PATH"
export ANDROID_HOME=~/Library/Android/sdk
export ANDROID_SDK_ROOT=$ANDROID_HOME
export PATH=$PATH:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools
command -v rbenv &>/dev/null && eval "$(rbenv init -)"
export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
export ANDROID_SDK_ROOT=\$ANDROID_HOME
export PATH=\$PATH:\$ANDROID_HOME/emulator:\$ANDROID_HOME/platform-tools
command -v rbenv &>/dev/null && eval "\$(rbenv init - --no-rehash)"
export NVM_DIR="\$HOME/.nvm"; [ -s "\$NVM_DIR/nvm.sh" ] && . "\$NVM_DIR/nvm.sh"
EOF
ok "Shell configured. Run: source $rc"
}
@@ -110,7 +214,8 @@ fi
DEPS=(
"Homebrew|chk_brew|inst_brew|"
"nvm|chk_nvm|inst_nvm|"
"Node.js $NODE_MAJOR|chk_node|inst_node|"
"Node.js $NODE_VERSION|chk_node|inst_node|"
"Yarn|chk_yarn|inst_yarn|"
"Watchman|chk_watch|inst_watch|"
"rbenv|chk_rbenv|inst_rbenv|"
"Ruby $RUBY_VERSION|chk_ruby|inst_ruby|"
@@ -121,19 +226,29 @@ DEPS=(
"Android Studio|chk_studio||Download: https://developer.android.com/studio"
"Android SDK|chk_sdk||Open Android Studio → SDK Manager"
"Android NDK|chk_ndk||SDK Manager → SDK Tools → NDK 28.0.13004108"
"SwiftLint|chk_swiftlint|inst_swiftlint|"
"Shell Config|chk_shell|inst_shell|"
)
MISSING=()
MANUAL=()
FAILED=()
info "Checking dependencies...\n"
load_shell_env
for dep in "${DEPS[@]}"; do
IFS='|' read -r name chk inst manual <<< "$dep"
status=$($chk)
if [[ "$status" == ok* ]]; then
ver="${status#ok:}"; [[ -n "$ver" && "$ver" != "ok" ]] && ok "$name ($ver)" || ok "$name"
elif [[ "$status" == wrong* ]]; then
ver="${status#wrong:}"; [[ -n "$ver" ]] && err "$name - wrong version ($ver)" || err "$name - wrong version"
if [[ -n "$inst" ]]; then
MISSING+=("$name|$inst")
elif [[ -n "$manual" ]]; then
MANUAL+=("$name|$manual")
fi
elif [[ -n "$manual" ]]; then
warn "$name - manual install required"
MANUAL+=("$name|$manual")
@@ -155,7 +270,12 @@ if [[ ${#MISSING[@]} -gt 0 ]]; then
for m in "${MISSING[@]}"; do
IFS='|' read -r name fn <<< "$m"
info "Installing $name..."
$fn && ok "$name installed" || err "Failed: $name"
if $fn; then
ok "$name installed"
else
err "Failed: $name"
FAILED+=("$name")
fi
done
fi
fi
@@ -166,19 +286,34 @@ fi
# Yarn install
echo ""
if confirm "Run 'yarn install' in repo root?"; then
info "Running yarn install..."
set +e # Temporarily disable exit-on-error
cd "$REPO_ROOT" && yarn install
yarn_exit=$?
set -e # Re-enable exit-on-error
if [[ $yarn_exit -eq 0 ]]; then
ok "Done!"
if ! command -v yarn &>/dev/null; then
err "yarn not available; run 'corepack enable && corepack prepare yarn@stable --activate' and retry"
else
err "Yarn install failed (exit code: $yarn_exit)"
warn "This may be due to network issues or registry timeouts"
info "Try running manually: cd $REPO_ROOT && yarn install"
info "Running yarn install..."
set +e # Temporarily disable exit-on-error
cd "$REPO_ROOT" && yarn install
yarn_exit=$?
set -e # Re-enable exit-on-error
if [[ $yarn_exit -eq 0 ]]; then
ok "Done!"
else
err "Yarn install failed (exit code: $yarn_exit)"
warn "This may be due to network issues or registry timeouts"
info "Try running manually: cd $REPO_ROOT && yarn install"
fi
fi
fi
echo -e "\n${G}${BOLD}Setup complete!${NC} Open a new terminal, then: cd $APP_DIR && yarn ios\n"
if [[ ${#FAILED[@]} -gt 0 ]]; then
echo -e "\n${R}${BOLD}Setup finished with failures:${NC} ${FAILED[*]}"
echo -e "Please fix the above issues and re-run the script."
exit 1
fi
echo -e "\n${G}${BOLD}Setup complete!${NC}"
echo -e "Next steps:"
echo -e " 1) Open a new terminal and run: source ~/.zshrc (or ~/.bashrc)"
echo -e " 2) Install dependencies: cd $REPO_ROOT && yarn install"
echo -e " 3) Run the app: cd $APP_DIR && yarn ios (or yarn android)"
echo -e " 4) If Metro cache issues: cd $APP_DIR && yarn start:clean\n"

View File

@@ -146,7 +146,7 @@ function usingHTTPSGitAuth() {
}
}
function clonePrivateRepo(repoName, localPath) {
function clonePrivateRepo(repoName, localPath, commit) {
log(`Setting up ${repoName}...`, 'info');
let cloneUrl;
@@ -181,7 +181,16 @@ function clonePrivateRepo(repoName, localPath) {
const isCredentialedUrl = isCI && (appToken || repoToken);
const quietFlag = isCredentialedUrl ? '--quiet' : '';
const targetDir = path.basename(localPath);
const cloneCommand = `git clone --branch ${BRANCH} --single-branch --depth 1 ${quietFlag} "${cloneUrl}" "${targetDir}"`;
// If commit is specified, clone without branch restriction and checkout commit
// Otherwise, clone the branch as before
let cloneCommand;
if (commit) {
log(`Using specific commit: ${commit}`, 'info');
cloneCommand = `git clone ${quietFlag} "${cloneUrl}" "${targetDir}"`;
} else {
cloneCommand = `git clone --branch ${BRANCH} --single-branch --depth 1 ${quietFlag} "${cloneUrl}" "${targetDir}"`;
}
try {
if (isCredentialedUrl) {
@@ -190,6 +199,18 @@ function clonePrivateRepo(repoName, localPath) {
} else {
runCommand(cloneCommand);
}
// If commit is specified, checkout that commit
if (commit) {
const checkoutCommand = `cd "${targetDir}" && git checkout ${commit}`;
if (isCredentialedUrl) {
runCommand(checkoutCommand, { stdio: 'pipe' });
} else {
runCommand(checkoutCommand);
}
log(`Checked out commit ${commit}`, 'success');
}
log(`Successfully cloned ${repoName}`, 'success');
return true; // Return true to indicate successful clone
} catch (error) {
@@ -220,14 +241,14 @@ function validateSetup(modulePath, validationFiles, repoName) {
}
function setupPrivateModule(module) {
const { repoName, localPath, validationFiles } = module;
const { repoName, localPath, validationFiles, commit } = module;
log(`Starting setup of ${repoName}...`, 'info');
// Remove existing module
removeExistingModule(localPath, repoName);
// Clone the private repository
const cloneSuccessful = clonePrivateRepo(repoName, localPath);
const cloneSuccessful = clonePrivateRepo(repoName, localPath, commit);
// If clone was skipped (e.g., in forked PRs), exit gracefully
if (cloneSuccessful === false) {

View File

@@ -28,8 +28,29 @@ const fs = require('fs');
const path = require('path');
const APP_DIR = path.resolve(__dirname, '..');
const PACKAGE_JSON_PATH = path.join(APP_DIR, 'package.json');
const VERSION_JSON_PATH = path.join(APP_DIR, 'version.json');
const VERSION_MANAGED_RELATIVE_PATHS = [
'package.json',
'version.json',
path.join('android', 'app', 'build.gradle'),
path.join('ios', 'Self.xcodeproj', 'project.pbxproj'),
];
const [
PACKAGE_JSON_REL_PATH,
VERSION_JSON_REL_PATH,
ANDROID_GRADLE_REL_PATH,
IOS_PBXPROJ_REL_PATH,
] = VERSION_MANAGED_RELATIVE_PATHS;
const PACKAGE_JSON_PATH = path.join(APP_DIR, PACKAGE_JSON_REL_PATH);
const VERSION_JSON_PATH = path.join(APP_DIR, VERSION_JSON_REL_PATH);
const ANDROID_GRADLE_PATH = path.join(APP_DIR, ANDROID_GRADLE_REL_PATH);
const IOS_PBXPROJ_PATH = path.join(APP_DIR, IOS_PBXPROJ_REL_PATH);
/**
* Get the list of files managed by applyVersions()
*/
function getVersionManagedFiles() {
return [...VERSION_MANAGED_RELATIVE_PATHS];
}
/**
* Read package.json
@@ -83,6 +104,30 @@ function writeVersionJson(data) {
}
}
/**
* Update a file with a regex replacement, ensuring at least one match.
*/
function updateFileWithRegex(filePath, regex, replacement) {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found at ${filePath}`);
}
const contents = fs.readFileSync(filePath, 'utf8');
const matches = contents.match(regex);
if (!matches) {
throw new Error(`No matches for ${regex} in ${filePath}`);
}
const updated = contents.replace(regex, replacement);
if (updated !== contents) {
fs.writeFileSync(filePath, updated);
}
return matches.length;
}
/**
* Get current version information
*/
@@ -202,8 +247,20 @@ function bumpVersion(bumpType, platform = 'both') {
/**
* Apply version changes to files
*
* @param {string} version - Semantic version (X.Y.Z)
* @param {number|string} iosBuild - iOS build number
* @param {number|string} androidBuild - Android build number
* @param {object} [options] - Optional settings
* @param {boolean} [options.iosSuccess=true] - Whether the iOS build succeeded
* @param {boolean} [options.androidSuccess=true] - Whether the Android build succeeded
*/
function applyVersions(version, iosBuild, androidBuild) {
function applyVersions(
version,
iosBuild,
androidBuild,
{ iosSuccess = true, androidSuccess = true } = {},
) {
// Validate version format (semver X.Y.Z)
if (
!version ||
@@ -229,8 +286,12 @@ function applyVersions(version, iosBuild, androidBuild) {
console.log(`📝 Applying versions to files...`);
console.log(` Version: ${version}`);
console.log(` iOS Build: ${iosNum}`);
console.log(` Android Build: ${androidNum}`);
console.log(
` iOS Build: ${iosNum} (${iosSuccess ? 'succeeded' : 'skipped'})`,
);
console.log(
` Android Build: ${androidNum} (${androidSuccess ? 'succeeded' : 'skipped'})`,
);
// Update package.json
const pkg = readPackageJson();
@@ -238,12 +299,81 @@ function applyVersions(version, iosBuild, androidBuild) {
writePackageJson(pkg);
console.log(`✅ Updated package.json`);
// Update version.json
// Update version.json (conditionally per platform)
const versionData = readVersionJson();
versionData.ios.build = iosNum;
versionData.android.build = androidNum;
const timestamp = new Date().toISOString();
if (iosSuccess) {
versionData.ios.build = iosNum;
versionData.ios.lastDeployed = timestamp;
console.log(
`✅ Updated iOS build number to ${iosNum} and lastDeployed timestamp`,
);
} else {
console.log(`⏭️ Skipped iOS version.json update (build did not succeed)`);
}
if (androidSuccess) {
versionData.android.build = androidNum;
versionData.android.lastDeployed = timestamp;
console.log(
`✅ Updated Android build number to ${androidNum} and lastDeployed timestamp`,
);
} else {
console.log(
`⏭️ Skipped Android version.json update (build did not succeed)`,
);
}
writeVersionJson(versionData);
console.log(`✅ Updated version.json`);
// Update Android build.gradle versionCode
if (androidSuccess) {
const androidMatches = updateFileWithRegex(
ANDROID_GRADLE_PATH,
/versionCode\s+\d+/g,
`versionCode ${androidNum}`,
);
console.log(
`✅ Updated Android versionCode (${androidMatches} occurrence${
androidMatches === 1 ? '' : 's'
})`,
);
} else {
console.log(
`⏭️ Skipped Android build.gradle update (build did not succeed)`,
);
}
// Update iOS project version and marketing version
if (iosSuccess) {
const iosBuildMatches = updateFileWithRegex(
IOS_PBXPROJ_PATH,
/CURRENT_PROJECT_VERSION = \d+;/g,
`CURRENT_PROJECT_VERSION = ${iosNum};`,
);
console.log(
`✅ Updated iOS CURRENT_PROJECT_VERSION (${iosBuildMatches} occurrence${
iosBuildMatches === 1 ? '' : 's'
})`,
);
} else {
console.log(
`⏭️ Skipped iOS CURRENT_PROJECT_VERSION update (build did not succeed)`,
);
}
// Always update MARKETING_VERSION to keep it in sync with package.json
const iosMarketingMatches = updateFileWithRegex(
IOS_PBXPROJ_PATH,
/MARKETING_VERSION = \d+\.\d+\.\d+;/g,
`MARKETING_VERSION = ${version};`,
);
console.log(
`✅ Updated iOS MARKETING_VERSION (${iosMarketingMatches} occurrence${
iosMarketingMatches === 1 ? '' : 's'
})`,
);
}
/**
@@ -295,20 +425,57 @@ function main() {
}
case 'apply': {
// Apply version: apply <version> <iosBuild> <androidBuild>
// Apply version: apply <version> <iosBuild> <androidBuild> [iosResult] [androidResult]
const version = args[1];
const iosBuild = parseInt(args[2], 10);
const androidBuild = parseInt(args[3], 10);
if (!version || isNaN(iosBuild) || isNaN(androidBuild)) {
throw new Error('Usage: apply <version> <iosBuild> <androidBuild>');
throw new Error(
'Usage: apply <version> <iosBuild> <androidBuild> [iosResult] [androidResult]',
);
}
applyVersions(version, iosBuild, androidBuild);
// Optional platform result args: "success" means succeeded, anything else means skipped
const iosSuccess = args[4] ? args[4] === 'success' : true;
const androidSuccess = args[5] ? args[5] === 'success' : true;
applyVersions(version, iosBuild, androidBuild, {
iosSuccess,
androidSuccess,
});
console.log(`\n✅ Versions applied successfully`);
break;
}
case 'files': {
// List files managed by applyVersions()
const format = args[1];
if (format && !['--shell', '--json'].includes(format)) {
throw new Error('Usage: files [--shell|--json]');
}
const files = getVersionManagedFiles();
if (format === '--shell') {
console.log(files.join(' '));
} else if (format === '--json') {
console.log(JSON.stringify(files));
} else {
console.log(files.join('\n'));
}
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(
process.env.GITHUB_OUTPUT,
`version_managed_files=${files.join(' ')}\n`,
);
}
break;
}
default:
console.log(`
Mobile Version Manager
@@ -321,13 +488,22 @@ Commands:
bump <type> <platform> Bump version and calculate new build numbers
type: major|minor|patch|build (default: build)
platform: ios|android|both (default: both)
apply <version> <ios> <android> Apply specific version and build numbers
apply <version> <ios> <android> [iosResult] [androidResult]
Apply specific version and build numbers
iosResult/androidResult: "success" to update,
any other value to skip (default: "success")
files [--shell|--json] List files managed by applyVersions()
default output: one path per line
--shell: space-separated paths
--json: JSON array
Examples:
node version-manager.cjs get
node version-manager.cjs bump build both
node version-manager.cjs bump patch ios
node version-manager.cjs apply 2.7.0 180 109
node version-manager.cjs apply 2.7.0 180 109 success failure
node version-manager.cjs files --shell
`);
process.exit(command ? 1 : 0);
}
@@ -346,6 +522,7 @@ if (require.main === module) {
module.exports = {
applyVersions,
bumpVersion,
getVersionManagedFiles,
getVersionInfo,
readPackageJson,
readVersionJson,

View File

@@ -24,6 +24,16 @@ const mockVersionJson = {
ios: { build: 100, lastDeployed: '2024-01-01T00:00:00Z' },
android: { build: 200, lastDeployed: '2024-01-01T00:00:00Z' },
};
const mockBuildGradle = `android {
defaultConfig {
versionCode 200
versionName "1.2.3"
}
}`;
const mockPbxproj = `buildSettings = {
CURRENT_PROJECT_VERSION = 100;
MARKETING_VERSION = 1.2.3;
};`;
// Use manual mocking instead of jest.mock to avoid hoisting issues
const fs = require('fs');
@@ -43,6 +53,12 @@ function setupMocks() {
if (filePath.includes('version.json')) {
return JSON.stringify(mockVersionJson);
}
if (filePath.includes('build.gradle')) {
return mockBuildGradle;
}
if (filePath.includes('project.pbxproj')) {
return mockPbxproj;
}
return originalReadFileSync(filePath, encoding);
};
@@ -255,8 +271,11 @@ describe('version-manager', () => {
versionManager.applyVersions('2.0.0', 150, 250);
// Verify writes occurred
expect(writeCalls.length).toBe(2);
// Verify writes occurred:
// 1. package.json, 2. version.json,
// 3. build.gradle (versionCode), 4. pbxproj (CURRENT_PROJECT_VERSION),
// 5. pbxproj (MARKETING_VERSION)
expect(writeCalls.length).toBe(5);
// Find and verify package.json write
const packageWrite = writeCalls.find(call =>
@@ -274,6 +293,53 @@ describe('version-manager', () => {
const updatedVersion = JSON.parse(versionWrite.content);
expect(updatedVersion.ios.build).toBe(150);
expect(updatedVersion.android.build).toBe(250);
// Find and verify build.gradle write
const gradleWrite = writeCalls.find(call =>
call.filePath.includes('build.gradle'),
);
expect(gradleWrite).toBeDefined();
expect(gradleWrite.content).toContain('versionCode 250');
// Find and verify pbxproj writes
const pbxprojWrites = writeCalls.filter(call =>
call.filePath.includes('project.pbxproj'),
);
expect(pbxprojWrites.length).toBe(2);
expect(pbxprojWrites[0].content).toContain(
'CURRENT_PROJECT_VERSION = 150;',
);
expect(pbxprojWrites[1].content).toContain('MARKETING_VERSION = 2.0.0;');
// Ensure every managed file is touched by applyVersions.
// (pbxproj is written twice due to two replacements.)
const managedFiles = versionManager.getVersionManagedFiles();
for (const managedFile of managedFiles) {
const touched = writeCalls.some(call =>
call.filePath.includes(managedFile),
);
expect(touched).toBe(true);
}
});
});
describe('getVersionManagedFiles', () => {
it('should return the expected managed file paths', () => {
expect(versionManager.getVersionManagedFiles()).toEqual([
'package.json',
'version.json',
path.join('android', 'app', 'build.gradle'),
path.join('ios', 'Self.xcodeproj', 'project.pbxproj'),
]);
});
it('should return a new array each time', () => {
const files = versionManager.getVersionManagedFiles();
files.push('unexpected-file');
expect(versionManager.getVersionManagedFiles()).not.toContain(
'unexpected-file',
);
});
});

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,8 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export const loadMiscAnimation = () =>
import('@selfxyz/mobile-sdk-alpha/animations/loading/misc.json');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
Promise.resolve(require('@selfxyz/mobile-sdk-alpha/animations/loading/misc.lottie'));
export const loadPassportAnimation = () =>
import('@/assets/animations/passport_verify.json');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
Promise.resolve(require('@/assets/animations/passport_verify.lottie'));

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -2,12 +2,12 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type LottieView from 'lottie-react-native';
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text, View, XStack, YStack } from 'tamagui';
import { DelayedLottieView } from '@selfxyz/mobile-sdk-alpha';
import type { DotLottieSource } from '@selfxyz/mobile-sdk-alpha';
import { LottieAnimation } from '@selfxyz/mobile-sdk-alpha';
import {
black,
cyan300,
@@ -24,7 +24,7 @@ import Plus from '@/assets/icons/plus_slate600.svg';
import { extraYPadding } from '@/utils/styleUtils';
interface LoadingUIProps {
animationSource: LottieView['props']['source'];
animationSource: DotLottieSource;
shouldLoopAnimation: boolean;
actionText: string;
actionSubText: string;
@@ -117,7 +117,7 @@ const LoadingUI: React.FC<LoadingUIProps> = ({
elevation={8}
>
<YStack alignItems="center" paddingHorizontal={10} flex={1}>
<DelayedLottieView
<LottieAnimation
autoPlay
loop={shouldLoopAnimation}
source={animationSource}

View File

@@ -39,7 +39,7 @@ export const PointsNavBar = (props: NativeStackHeaderProps) => {
color={black}
fontSize={15}
fontWeight="500"
fontFamily="DIN OT"
fontFamily="DINOT-Medium"
textAlign="center"
style={{
letterSpacing: 0.6,

View File

@@ -93,6 +93,7 @@ const scanAndroid = async (
useCan: inputs.useCan ?? false,
sessionId: inputs.sessionId,
skipReselect: inputs.skipReselect ?? false,
skipPACE: inputs.skipPACE ?? false,
});
};

View File

@@ -93,6 +93,7 @@ if (Platform.OS === 'android') {
useCan = false,
quality = 1,
skipReselect = false,
skipPACE = false,
sessionId,
} = options;
@@ -104,6 +105,7 @@ if (Platform.OS === 'android') {
useCan,
quality,
skipReselect,
skipPACE,
sessionId,
});
};

View File

@@ -15,8 +15,7 @@ import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { X } from '@tamagui/lucide-icons';
import { DelayedLottieView } from '@selfxyz/mobile-sdk-alpha';
import youWinAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/youWin.json';
import { LottieAnimation } from '@selfxyz/mobile-sdk-alpha';
import { PrimaryButton } from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
@@ -30,6 +29,9 @@ import SelfLogo from '@/assets/logos/self.svg';
import { SystemBars } from '@/components/SystemBars';
import type { RootStackParamList } from '@/navigation';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const youWinAnimation = require('@selfxyz/mobile-sdk-alpha/animations/loading/youWin.lottie');
const GratificationScreen: React.FC = () => {
const { top, bottom } = useSafeAreaInsets();
const navigation =
@@ -66,7 +68,7 @@ const GratificationScreen: React.FC = () => {
alignItems="center"
justifyContent="center"
>
<DelayedLottieView
<LottieAnimation
autoPlay
loop={false}
source={youWinAnimation}

View File

@@ -2,22 +2,22 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type LottieView from 'lottie-react-native';
import { useCallback, useEffect, useState } from 'react';
import { StyleSheet } from 'react-native';
import type { StaticScreenProps } from '@react-navigation/native';
import { useFocusEffect, useIsFocused } from '@react-navigation/native';
import type { DocumentCategory } from '@selfxyz/common/utils/types';
import type { ProvingStateType } from '@selfxyz/mobile-sdk-alpha';
import type {
DotLottieSource,
ProvingStateType,
} from '@selfxyz/mobile-sdk-alpha';
import {
advercase,
dinot,
loadSelectedDocument,
useSelfClient,
} from '@selfxyz/mobile-sdk-alpha';
import failAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/fail.json';
import proveLoadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/prove.json';
import {
black,
slate400,
@@ -31,6 +31,11 @@ import { getLoadingScreenText } from '@/proving/loadingScreenStateText';
import { setupNotifications } from '@/services/notifications/notificationService';
import { useSettingStore } from '@/stores/settingStore';
/* eslint-disable @typescript-eslint/no-require-imports -- binary assets loaded by Metro */
const failAnimation = require('@selfxyz/mobile-sdk-alpha/animations/loading/fail.lottie');
const proveLoadingAnimation = require('@selfxyz/mobile-sdk-alpha/animations/loading/prove.lottie');
/* eslint-enable @typescript-eslint/no-require-imports */
type LoadingScreenParams = {
documentCategory?: DocumentCategory;
signatureAlgorithm?: string;
@@ -38,7 +43,6 @@ type LoadingScreenParams = {
};
type LoadingScreenProps = StaticScreenProps<LoadingScreenParams>;
// Define all terminal states that should stop animations and haptics
const terminalStates: ProvingStateType[] = [
'completed',
@@ -55,9 +59,9 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({ route }) => {
const [isInitializing, setIsInitializing] = useState(false);
// Animation states
const [animationSource, setAnimationSource] = useState<
LottieView['props']['source']
>(proveLoadingAnimation);
const [animationSource, setAnimationSource] = useState<DotLottieSource>(
proveLoadingAnimation,
);
// Loading text state
const [loadingText, setLoadingText] = useState<{

View File

@@ -8,13 +8,12 @@ import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
DelayedLottieView,
hasAnyValidRegisteredDocument,
LottieAnimation,
useSelfClient,
} from '@selfxyz/mobile-sdk-alpha';
import { black } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import splashAnimation from '@/assets/animations/splash.json';
import { impactLight } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import {
@@ -32,6 +31,9 @@ import {
import { useSettingStore } from '@/stores/settingStore';
import { IS_DEV_MODE } from '@/utils/devUtils';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const splashAnimation = require('@/assets/animations/splash.lottie');
const SplashScreen: React.FC = ({}) => {
const selfClient = useSelfClient();
const navigation =
@@ -104,6 +106,16 @@ const SplashScreen: React.FC = ({}) => {
}
}, [checkBiometricsAvailable, setBiometricsAvailable, selfClient]);
useEffect(() => {
const timeout = setTimeout(() => {
setIsAnimationFinished(prev => {
if (!prev) console.warn('SplashScreen: animation timeout, proceeding');
return true;
});
}, 5000);
return () => clearTimeout(timeout);
}, []);
const handleAnimationFinish = useCallback(() => {
impactLight();
setIsAnimationFinished(true);
@@ -124,7 +136,7 @@ const SplashScreen: React.FC = ({}) => {
}, [isAnimationFinished, nextScreen, queuedDeepLink, navigation, selfClient]);
return (
<DelayedLottieView
<LottieAnimation
autoPlay
loop={false}
source={splashAnimation}

View File

@@ -2,23 +2,26 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type LottieView from 'lottie-react-native';
import React, { useEffect, useMemo, useState } from 'react';
import { Adapt, Button, Select, Sheet, Text, XStack, YStack } from 'tamagui';
import { Check, ChevronDown } from '@tamagui/lucide-icons';
import type {
DotLottieSource,
provingMachineCircuitType,
ProvingStateType,
} from '@selfxyz/mobile-sdk-alpha';
import failAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/fail.json';
import proveLoadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/prove.json';
import { slate200, slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import LoadingUI from '@/components/LoadingUI';
import { getLoadingScreenText } from '@/proving/loadingScreenStateText';
/* eslint-disable @typescript-eslint/no-require-imports -- binary assets loaded by Metro */
const failAnimation = require('@selfxyz/mobile-sdk-alpha/animations/loading/fail.lottie');
const proveLoadingAnimation = require('@selfxyz/mobile-sdk-alpha/animations/loading/prove.lottie');
/* eslint-enable @typescript-eslint/no-require-imports */
const allProvingStates = [
'idle',
'parsing_id_document',
@@ -41,9 +44,9 @@ const DevLoadingScreen: React.FC = () => {
const [currentState, setCurrentState] = useState<ProvingStateType>('idle');
const [documentType, setDocumentType] =
useState<provingMachineCircuitType>('dsc');
const [animationSource, setAnimationSource] = useState<
LottieView['props']['source']
>(proveLoadingAnimation);
const [animationSource, setAnimationSource] = useState<DotLottieSource>(
proveLoadingAnimation,
);
const [loadingText, setLoadingText] = useState<{
actionText: string;
actionSubText: string;

View File

@@ -9,8 +9,8 @@ import { useIsFocused, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import {
DelayedLottieView,
dinot,
LottieAnimation,
useSelfClient,
} from '@selfxyz/mobile-sdk-alpha';
import {
@@ -31,7 +31,6 @@ import {
useReadMRZ,
} from '@selfxyz/mobile-sdk-alpha/onboarding/read-mrz';
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';
@@ -40,6 +39,9 @@ import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import { getDocumentScanPrompt } from '@/utils/documentAttributes';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const passportScanAnimation = require('@/assets/animations/passport_scan.lottie');
const DocumentCameraScreen: React.FC = () => {
const isFocused = useIsFocused();
const navigation =
@@ -87,7 +89,7 @@ const DocumentCameraScreen: React.FC = () => {
<ExpandableBottomLayout.Layout backgroundColor={white}>
<ExpandableBottomLayout.TopSection roundTop backgroundColor={black}>
<PassportCamera onPassportRead={onPassportRead} isMounted={isFocused} />
<DelayedLottieView
<LottieAnimation
autoPlay
loop
source={passportScanAnimation}

View File

@@ -54,16 +54,6 @@ const NFC_METHODS = [
platform: ['ios'],
params: { usePacePolling: true },
},
{
// We try PACE first, but if it fails, we try BAC authentication.
// Some chips will invalidate the session if PACE fails.
key: 'skipPACE',
label: 'Skip PACE',
description:
'Skip PACE protocol during NFC scan. Useful if your passport does not support PACE.',
platform: ['ios'],
params: { skipPACE: true },
},
{
key: 'can',
label: 'CAN Authentication',
@@ -107,6 +97,7 @@ const DocumentNFCMethodSelectionScreen: React.FC = () => {
const [selectedMethod, setSelectedMethod] = useState('standard');
const [canValue, setCanValue] = useState('');
const [error, setError] = useState('');
const [skipPACE, setSkipPACE] = useState(false);
const selfClient = useSelfClient();
const { useMRZStore } = selfClient;
@@ -147,6 +138,10 @@ const DocumentNFCMethodSelectionScreen: React.FC = () => {
if (selectedMethod === 'can') {
params.canNumber = canValue;
}
if (skipPACE) {
params.skipPACE = true;
}
// Type assertion needed because static navigation doesn't infer optional params
navigation.navigate('DocumentNFCScan', params as never);
};
@@ -158,6 +153,28 @@ 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>Skip PACE</Description>
<Switch
size="$4"
checked={skipPACE}
onCheckedChange={setSkipPACE}
backgroundColor={skipPACE ? '$green7Light' : '$gray4'}
style={{ minWidth: 48, minHeight: 36 }}
>
<Switch.Thumb animation="quick" backgroundColor="$white" />
</Switch>
</XStack>
<XStack
alignItems="center"
justifyContent="space-between"

View File

@@ -2,7 +2,6 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import LottieView from 'lottie-react-native';
import React, {
useCallback,
useEffect,
@@ -22,6 +21,7 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import NfcManager from 'react-native-nfc-manager';
import { Button, Image, XStack } from 'tamagui';
import { v4 as uuidv4 } from 'uuid';
import type { Dotlottie } from '@lottiefiles/dotlottie-react-native';
import type { RouteProp } from '@react-navigation/native';
import {
useFocusEffect,
@@ -32,7 +32,11 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { CircleHelp } from '@tamagui/lucide-icons';
import type { PassportData } from '@selfxyz/common/types';
import { sanitizeErrorMessage, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import {
LottieAnimation,
sanitizeErrorMessage,
useSelfClient,
} from '@selfxyz/mobile-sdk-alpha';
import {
BodyText,
ButtonsContainer,
@@ -51,7 +55,6 @@ import {
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
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';
@@ -100,6 +103,9 @@ type DocumentNFCScanRoute = RouteProp<
string
>;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const passportVerifyAnimation = require('@/assets/animations/passport_verify.lottie');
const DocumentNFCScanScreen: React.FC = () => {
const selfClient = useSelfClient();
const { trackEvent, useMRZStore } = selfClient;
@@ -136,7 +142,7 @@ const DocumentNFCScanScreen: React.FC = () => {
[route.params?.useCan],
);
const animationRef = useRef<LottieView>(null);
const animationRef = useRef<Dotlottie | null>(null);
useEffect(() => {
animationRef.current?.play();
@@ -541,7 +547,7 @@ const DocumentNFCScanScreen: React.FC = () => {
return (
<ExpandableBottomLayout.Layout backgroundColor={black}>
<ExpandableBottomLayout.TopSection roundTop backgroundColor={slate100}>
<LottieView
<LottieAnimation
ref={animationRef}
autoPlay={false}
loop={false}

View File

@@ -2,12 +2,12 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import LottieView from 'lottie-react-native';
import React, { useEffect, useRef } from 'react';
import { StyleSheet } from 'react-native';
import type { Dotlottie } from '@lottiefiles/dotlottie-react-native';
import { useNavigation } from '@react-navigation/native';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { LottieAnimation, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import {
Additional,
ButtonsContainer,
@@ -24,12 +24,14 @@ import {
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import passportOnboardingAnimation from '@/assets/animations/passport_onboarding.json';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { impactLight } from '@/integrations/haptics';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import { getDocumentScanPrompt } from '@/utils/documentAttributes';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const passportOnboardingAnimation = require('@/assets/animations/passport_onboarding.lottie');
const DocumentOnboardingScreen: React.FC = () => {
const navigation = useNavigation();
const selfClient = useSelfClient();
@@ -37,7 +39,7 @@ const DocumentOnboardingScreen: React.FC = () => {
state => state.documentType,
);
const handleCameraPress = useHapticNavigation('DocumentCamera');
const animationRef = useRef<LottieView>(null);
const animationRef = useRef<Dotlottie | null>(null);
const scanPrompt = getDocumentScanPrompt(selectedDocumentType);
@@ -58,7 +60,7 @@ const DocumentOnboardingScreen: React.FC = () => {
return (
<ExpandableBottomLayout.Layout backgroundColor={black}>
<ExpandableBottomLayout.TopSection roundTop backgroundColor={black}>
<LottieView
<LottieAnimation
ref={animationRef}
autoPlay={false}
loop={false}

View File

@@ -241,7 +241,12 @@ const HomeScreen: React.FC = () => {
}
return (
<YStack backgroundColor={'#F8FAFC'} flex={1} alignItems="center">
<YStack
backgroundColor={'#F8FAFC'}
flex={1}
alignItems="center"
testID="home-screen-root"
>
<ScrollView
showsVerticalScrollIndicator={false}
flex={1}

View File

@@ -11,8 +11,7 @@ import type { StaticScreenProps } from '@react-navigation/native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { DelayedLottieView, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import loadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json';
import { LottieAnimation, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import {
AbstractButton,
Description,
@@ -32,6 +31,9 @@ import {
} from '@/services/notifications/notificationService';
import { useSettingStore } from '@/stores/settingStore';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const loadingAnimation = require('@selfxyz/mobile-sdk-alpha/animations/loading/misc.lottie');
type KycSuccessRouteParams = StaticScreenProps<
| {
userId?: string;
@@ -112,7 +114,7 @@ const KycSuccessScreen: React.FC<KycSuccessRouteParams> = ({
<View style={[styles.container, { paddingBottom: insets.bottom }]}>
<View style={styles.centerSection}>
<View style={styles.animationContainer}>
<DelayedLottieView
<LottieAnimation
autoPlay
loop={true}
source={loadingAnimation}

View File

@@ -7,7 +7,7 @@ import { YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { DelayedLottieView } from '@selfxyz/mobile-sdk-alpha';
import { LottieAnimation } from '@selfxyz/mobile-sdk-alpha';
import {
Description,
PrimaryButton,
@@ -16,12 +16,14 @@ import {
import { BackupEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import proofSuccessAnimation from '@/assets/animations/proof_success.json';
import { buttonTap } from '@/integrations/haptics';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import { styles } from '@/screens/verification/ProofRequestStatusScreen';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const proofSuccessAnimation = require('@/assets/animations/proof_success.lottie');
const AccountVerifiedSuccessScreen: React.FC = ({}) => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
@@ -29,7 +31,7 @@ const AccountVerifiedSuccessScreen: React.FC = ({}) => {
return (
<ExpandableBottomLayout.Layout backgroundColor={white}>
<ExpandableBottomLayout.TopSection backgroundColor={black} roundTop>
<DelayedLottieView
<LottieAnimation
autoPlay
loop={false}
source={proofSuccessAnimation}

View File

@@ -8,7 +8,7 @@ import { YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { DelayedLottieView } from '@selfxyz/mobile-sdk-alpha';
import { LottieAnimation } from '@selfxyz/mobile-sdk-alpha';
import {
Caution,
PrimaryButton,
@@ -17,12 +17,14 @@ import {
import { AppEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import warningAnimation from '@/assets/animations/warning.json';
import { confirmTap, notificationWarning } from '@/integrations/haptics';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import { useSettingStore } from '@/stores/settingStore';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const warningAnimation = require('@/assets/animations/warning.lottie');
const DisclaimerScreen: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
@@ -35,7 +37,7 @@ const DisclaimerScreen: React.FC = () => {
return (
<ExpandableBottomLayout.Layout backgroundColor={black}>
<ExpandableBottomLayout.TopSection backgroundColor={black}>
<DelayedLottieView
<LottieAnimation
autoPlay
loop={false}
source={warningAnimation}

View File

@@ -2,16 +2,14 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { LottieViewProps } from 'lottie-react-native';
import LottieView from 'lottie-react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Linking, StyleSheet, View } from 'react-native';
import { ScrollView, Spinner } from 'tamagui';
import { useIsFocused, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import loadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json';
import type { DotLottieSource } from '@selfxyz/mobile-sdk-alpha';
import { LottieAnimation, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import {
BodyText,
Description,
@@ -22,8 +20,6 @@ import {
import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import failAnimation from '@/assets/animations/proof_failed.json';
import succesAnimation from '@/assets/animations/proof_success.json';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import {
buttonTap,
@@ -36,6 +32,13 @@ import { getWhiteListedDisclosureAddresses } from '@/services/points/utils';
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
import { ProofStatus } from '@/stores/proofTypes';
/* eslint-disable @typescript-eslint/no-require-imports -- binary assets loaded by Metro */
const loadingAnimation = require('@selfxyz/mobile-sdk-alpha/animations/loading/misc.lottie');
const failAnimation = require('@/assets/animations/proof_failed.lottie');
const succesAnimation = require('@/assets/animations/proof_success.lottie');
/* eslint-enable @typescript-eslint/no-require-imports */
const SuccessScreen: React.FC = () => {
const selfClient = useSelfClient();
const { trackEvent } = selfClient;
@@ -56,7 +59,7 @@ const SuccessScreen: React.FC = () => {
const isFocused = useIsFocused();
const [animationSource, setAnimationSource] =
useState<LottieViewProps['source']>(loadingAnimation);
useState<DotLottieSource>(loadingAnimation);
const [countdown, setCountdown] = useState<number | null>(null);
const [countdownStarted, setCountdownStarted] = useState(false);
const [whitelistedPoints, setWhitelistedPoints] = useState<number | null>(
@@ -214,7 +217,7 @@ const SuccessScreen: React.FC = () => {
marginTop={20}
backgroundColor={black}
>
<LottieView
<LottieAnimation
autoPlay
loop={animationSource === loadingAnimation}
source={animationSource}

View File

@@ -2,7 +2,6 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import LottieView from 'lottie-react-native';
import React, { useCallback, useState } from 'react';
import { StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -14,7 +13,7 @@ import {
} from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { LottieAnimation, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import {
Additional,
Description,
@@ -27,7 +26,6 @@ import {
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import qrScanAnimation from '@/assets/animations/qr_scan.json';
import QRScan from '@/assets/icons/qr_code.svg';
import type { QRCodeScannerViewProps } from '@/components/native/QRCodeScanner';
import { QRCodeScannerView } from '@/components/native/QRCodeScanner';
@@ -39,6 +37,9 @@ import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import { parseAndValidateUrlParams } from '@/navigation/deeplinks';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const qrScanAnimation = require('@/assets/animations/qr_scan.lottie');
const QRCodeViewFinderScreen: React.FC = () => {
const selfClient = useSelfClient();
const { trackEvent } = selfClient;
@@ -164,7 +165,7 @@ const QRCodeViewFinderScreen: React.FC = () => {
{shouldRenderCamera && (
<>
<QRCodeScannerView onQRData={onQRData} isMounted={isFocused} />
<LottieView
<LottieAnimation
autoPlay
loop
source={qrScanAnimation}

View File

@@ -3,5 +3,5 @@ appId: com.proofofpassportapp
- launchApp
- extendedWaitUntil:
visible:
id: "earn-points-button"
timeout: ${LAUNCH_WAIT_MS:60000}
id: "home-screen-root"
timeout: ${LAUNCH_WAIT_MS:120000}

View File

@@ -3,5 +3,5 @@ appId: com.warroom.proofofpassport
- launchApp
- extendedWaitUntil:
visible:
id: "earn-points-button"
timeout: ${LAUNCH_WAIT_MS:60000}
id: "home-screen-root"
timeout: ${LAUNCH_WAIT_MS:120000}

View File

@@ -82,7 +82,7 @@ jest.mock('tamagui', () => {
});
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
DelayedLottieView: ({ onAnimationFinish }: any) => {
LottieAnimation: ({ onAnimationFinish }: any) => {
// Simulate animation finishing immediately
setTimeout(() => {
onAnimationFinish?.();

View File

@@ -102,7 +102,7 @@ jest.mock('@selfxyz/mobile-sdk-alpha/constants/analytics', () => ({
},
}));
jest.mock('@selfxyz/mobile-sdk-alpha/animations/loading/misc.json', () => ({}));
jest.mock('@selfxyz/mobile-sdk-alpha/animations/loading/misc.lottie', () => 1);
jest.mock('@/integrations/haptics', () => ({
buttonTap: jest.fn(),
@@ -125,7 +125,7 @@ jest.mock('@/services/analytics', () => ({
}));
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
DelayedLottieView: () => null,
LottieAnimation: () => null,
useSelfClient: jest.fn(),
}));

View File

@@ -1,10 +1,10 @@
{
"ios": {
"build": 215,
"lastDeployed": "2026-02-16T09:17:41.176Z"
"build": 216,
"lastDeployed": "2026-02-20T11:17:57.338Z"
},
"android": {
"build": 144,
"lastDeployed": "2026-02-16T09:17:41.176Z"
"build": 145,
"lastDeployed": "2026-02-20T11:17:57.338Z"
}
}

View File

@@ -17,6 +17,7 @@ export default defineConfig({
root: 'web',
publicDir: 'web',
envDir: '..', // This is the directory where Vite will look for .env files relative to the root
assetsInclude: ['**/*.lottie'],
resolve: {
extensions: [
'.web.tsx',
@@ -34,7 +35,7 @@ export default defineConfig({
'@': resolve(__dirname, 'src'),
'@/package.json': resolve(__dirname, 'package.json'),
'react-native-svg': 'react-native-svg-web',
'lottie-react-native': 'lottie-react',
'@lottiefiles/dotlottie-react-native': '@lottiefiles/dotlottie-react',
'@react-native-community/blur': resolve(
__dirname,
'src/devtools/mocks/react-native-community-blur.ts',
@@ -164,7 +165,10 @@ export default defineConfig({
'vendor-analytics-sentry': ['@sentry/react', '@sentry/react-native'],
// Animations
'vendor-animations-lottie': ['lottie-react-native', 'lottie-react'],
'vendor-animations-lottie': [
'@lottiefiles/dotlottie-react-native',
'@lottiefiles/dotlottie-react',
],
// WebSocket and Socket.IO
'vendor-websocket': ['socket.io-client'],
@@ -192,7 +196,7 @@ export default defineConfig({
// Large animations - split out heavy Lottie files
'animations-passport-onboarding': [
'./src/assets/animations/passport_onboarding.json',
'./src/assets/animations/passport_onboarding.lottie',
],
// Other screens

View File

@@ -6,7 +6,7 @@
- Install nargo via noirup (pin the version used in CI for reproducibility):
- curl -L https://raw.githubusercontent.com/noir-lang/noirup/main/install | bash
- noirup -v <noir_version> # e.g., noirup -v v0.30.0
- noirup -v <noir_version> # e.g., noirup -v v0.31.0 (>= 0.31.0 required for --package flag)
- Verify nargo is on the expected version:
- nargo --version
- Ensure Rust toolchain is installed and up to date (required by nargo).

View File

@@ -108,8 +108,8 @@ actual class SelfSdk private constructor(
) {
when (resultCode) {
Activity.RESULT_OK -> {
// Success
val resultDataJson = data?.getStringExtra(SelfVerificationActivity.EXTRA_RESULT_DATA)
val resultType = data?.getStringExtra(SelfVerificationActivity.EXTRA_RESULT_TYPE)
if (resultDataJson != null) {
try {
val result = deserializeResult(resultDataJson)
@@ -122,6 +122,10 @@ actual class SelfSdk private constructor(
),
)
}
} else if (resultType != null) {
callback.onSuccess(
VerificationResult(success = true, type = resultType),
)
} else {
callback.onFailure(
SelfSdkError(

View File

@@ -11,6 +11,7 @@ import kotlinx.serialization.json.jsonPrimitive
import xyz.self.sdk.bridge.BridgeDomain
import xyz.self.sdk.bridge.BridgeHandler
import xyz.self.sdk.bridge.BridgeHandlerException
import xyz.self.sdk.webview.SelfVerificationActivity
/**
* Android implementation of lifecycle bridge handler.
@@ -72,16 +73,16 @@ class LifecycleBridgeHandler(
if (type != null) {
// Flat lifecycle payload (e.g. { type: "proofRequested" }) — treat as success
intent.putExtra("xyz.self.sdk.RESULT_TYPE", type)
intent.putExtra(SelfVerificationActivity.EXTRA_RESULT_TYPE, type)
activity.setResult(Activity.RESULT_OK, intent)
} else if (success && data != null) {
// Success result
intent.putExtra("xyz.self.sdk.RESULT_DATA", data)
intent.putExtra(SelfVerificationActivity.EXTRA_RESULT_DATA, data)
activity.setResult(Activity.RESULT_OK, intent)
} else if (!success && errorCode != null) {
// Error result
intent.putExtra("xyz.self.sdk.ERROR_CODE", errorCode)
intent.putExtra("xyz.self.sdk.ERROR_MESSAGE", errorMessage ?: "Unknown error")
intent.putExtra(SelfVerificationActivity.EXTRA_ERROR_CODE, errorCode)
intent.putExtra(SelfVerificationActivity.EXTRA_ERROR_MESSAGE, errorMessage ?: "Unknown error")
activity.setResult(Activity.RESULT_FIRST_USER, intent)
} else {
// Cancelled or invalid result

View File

@@ -121,6 +121,7 @@ class SelfVerificationActivity : AppCompatActivity() {
// Result extras
const val EXTRA_RESULT_DATA = "xyz.self.sdk.RESULT_DATA"
const val EXTRA_RESULT_TYPE = "xyz.self.sdk.RESULT_TYPE"
const val EXTRA_ERROR_CODE = "xyz.self.sdk.ERROR_CODE"
const val EXTRA_ERROR_MESSAGE = "xyz.self.sdk.ERROR_MESSAGE"
}

View File

@@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class VerificationResult(
val success: Boolean,
val type: String? = null,
val userId: String? = null,
val verificationId: String? = null,
val proof: String? = null,

View File

@@ -5,11 +5,14 @@
package xyz.self.sdk.api
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.runBlocking
import platform.UIKit.UIApplication
import platform.UIKit.UIModalPresentationFullScreen
import platform.UIKit.UIViewController
import platform.UIKit.UIWindow
import platform.UIKit.UIWindowScene
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue
import xyz.self.sdk.bridge.MessageRouter
import xyz.self.sdk.handlers.AnalyticsBridgeHandler
import xyz.self.sdk.handlers.BiometricBridgeHandler
@@ -72,9 +75,24 @@ actual class SelfSdk private constructor(
// Create lifecycle handler with callback and dismiss wiring
val lifecycleHandler = LifecycleBridgeHandler()
lifecycleHandler.pendingCallback = callback
lifecycleHandler.dismissAction = {
pendingCallback = null
var dismissViewController: UIViewController? = null
runBlocking {
lifecycleHandler.configure(
callback = callback,
dismiss = {
dispatch_async(dispatch_get_main_queue()) {
val viewController = dismissViewController
if (viewController == null) {
pendingCallback = null
return@dispatch_async
}
viewController.dismissViewControllerAnimated(true) {
pendingCallback = null
}
}
},
)
}
// Register all iOS bridge handlers
@@ -91,13 +109,7 @@ actual class SelfSdk private constructor(
?: throw IllegalStateException("WebView provider not configured. Call SelfSdkSwift.configure() first.")
).getViewController()
sdkVC.setModalPresentationStyle(UIModalPresentationFullScreen)
// Wire up dismiss action to dismiss the VC
lifecycleHandler.dismissAction = {
sdkVC.dismissViewControllerAnimated(true) {
pendingCallback = null
}
}
dismissViewController = sdkVC
val topVC = findTopViewController()
if (topVC == null) {

View File

@@ -4,6 +4,8 @@
package xyz.self.sdk.handlers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonPrimitive
@@ -17,8 +19,24 @@ import xyz.self.sdk.bridge.BridgeHandlerException
class LifecycleBridgeHandler : BridgeHandler {
override val domain = BridgeDomain.LIFECYCLE
internal var pendingCallback: SelfSdkCallback? = null
internal var dismissAction: (() -> Unit)? = null
private val mutex = Mutex()
private var pendingCallback: SelfSdkCallback? = null
private var dismissAction: (() -> Unit)? = null
private data class LifecycleState(
val callback: SelfSdkCallback?,
val dismiss: (() -> Unit)?,
)
internal suspend fun configure(
callback: SelfSdkCallback?,
dismiss: (() -> Unit)?,
) {
mutex.withLock {
pendingCallback = callback
dismissAction = dismiss
}
}
override suspend fun handle(
method: String,
@@ -36,14 +54,27 @@ class LifecycleBridgeHandler : BridgeHandler {
private fun ready(): JsonElement? = null
private fun dismiss(): JsonElement? {
pendingCallback?.onCancelled()
pendingCallback = null
dismissAction?.invoke()
private suspend fun consumeLifecycleState(): LifecycleState =
mutex.withLock {
val state =
LifecycleState(
callback = pendingCallback,
dismiss = dismissAction,
)
pendingCallback = null
dismissAction = null
state
}
private suspend fun dismiss(): JsonElement? {
val state = consumeLifecycleState()
state.callback?.onCancelled()
state.dismiss?.invoke()
return null
}
private fun setResult(params: Map<String, JsonElement>): JsonElement? {
private suspend fun setResult(params: Map<String, JsonElement>): JsonElement? {
val state = consumeLifecycleState()
val type = params["type"]?.jsonPrimitive?.content
val success = params["success"]?.jsonPrimitive?.content?.toBoolean() ?: false
val data = params["data"]?.toString()
@@ -51,16 +82,17 @@ class LifecycleBridgeHandler : BridgeHandler {
val errorMessage = params["errorMessage"]?.jsonPrimitive?.content
if (type != null) {
// Flat lifecycle payload (e.g. { type: "proofRequested" }) — treat as success
pendingCallback?.onSuccess(
VerificationResult(success = true),
// Flat lifecycle payload is a protocol-level success signal.
// `type` communicates what completed (e.g. proofRequested).
state.callback?.onSuccess(
VerificationResult(success = true, type = type),
)
} else if (success && data != null) {
try {
val result = Json.decodeFromString(VerificationResult.serializer(), data)
pendingCallback?.onSuccess(result)
state.callback?.onSuccess(result)
} catch (e: Exception) {
pendingCallback?.onFailure(
state.callback?.onFailure(
SelfSdkError(
code = "PARSE_ERROR",
message = "Failed to parse verification result: ${e.message}",
@@ -68,18 +100,17 @@ class LifecycleBridgeHandler : BridgeHandler {
)
}
} else if (!success && errorCode != null) {
pendingCallback?.onFailure(
state.callback?.onFailure(
SelfSdkError(
code = errorCode,
message = errorMessage ?: "Unknown error",
),
)
} else {
pendingCallback?.onCancelled()
state.callback?.onCancelled()
}
pendingCallback = null
dismissAction?.invoke()
state.dismiss?.invoke()
return null
}
}

View File

@@ -98,11 +98,21 @@
"import": "./dist/svgs/icons/*.svg",
"require": "./dist/svgs/icons/*.svg"
},
"./animations/*.lottie": {
"react-native": "./dist/animations/*.lottie",
"import": "./dist/animations/*.lottie",
"require": "./dist/animations/*.lottie"
},
"./animations/*.json": {
"react-native": "./dist/animations/*.json",
"import": "./dist/animations/*.json",
"require": "./dist/animations/*.json"
},
"./animations/loading/*.lottie": {
"react-native": "./dist/animations/loading/*.lottie",
"import": "./dist/animations/loading/*.lottie",
"require": "./dist/animations/loading/*.lottie"
},
"./animations/loading/*.json": {
"react-native": "./dist/animations/loading/*.json",
"import": "./dist/animations/loading/*.json",
@@ -170,6 +180,7 @@
"zustand": "^4.5.2"
},
"devDependencies": {
"@lottiefiles/dotlottie-react-native": "0.5.0",
"@openpassport/zk-kit-lean-imt": "^0.0.6",
"@react-native-async-storage/async-storage": "^2.1.2",
"@testing-library/react": "^14.1.2",
@@ -187,7 +198,6 @@
"eslint-plugin-sort-exports": "^0.9.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^25.0.1",
"lottie-react-native": "7.2.2",
"poseidon-lite": "^0.3.0",
"prettier": "^3.5.3",
"react": "^18.3.1",
@@ -206,8 +216,8 @@
"vitest": "^2.1.8"
},
"peerDependencies": {
"@lottiefiles/dotlottie-react-native": "*",
"@react-native-async-storage/async-storage": ">=1.0.0",
"lottie-react-native": "7.2.2",
"react": "^18.3.1",
"react-native": "0.76.9",
"react-native-blur-effect": "^1.1.3",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,50 +0,0 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { LottieViewProps } from 'lottie-react-native';
import LottieView from 'lottie-react-native';
import type React from 'react';
import { forwardRef, useEffect, useRef } from 'react';
/**
* Wrapper around LottieView that fixes iOS native module initialization timing.
*
* On iOS, the Lottie native module isn't always fully initialized when components
* first render during app startup. This causes animations to not appear until
* after navigating to another screen that triggers native module initialization.
*
* This component adds a 100ms delay before starting autoPlay animations, giving
* the native module time to initialize properly.
*
* Usage: Drop-in replacement for LottieView
* @example
* <DelayedLottieView autoPlay loop source={animation} style={styles.animation} />
*/
export const DelayedLottieView = forwardRef<LottieView, LottieViewProps>((props, forwardedRef) => {
// If LottieView is undefined (peer dependency not installed), return null
if (typeof LottieView === 'undefined') {
return null;
}
const internalRef = useRef<LottieView>(null);
const ref = (forwardedRef as React.RefObject<LottieView>) || internalRef;
useEffect(() => {
// Only auto-trigger for autoPlay animations
if (props.autoPlay) {
const timer = setTimeout(() => {
ref.current?.play();
}, 100);
return () => clearTimeout(timer);
}
}, [props.autoPlay, ref]);
// For autoPlay animations, disable native autoPlay and control it ourselves
const modifiedProps = props.autoPlay ? { ...props, autoPlay: false } : props;
return <LottieView ref={ref} {...modifiedProps} />;
});
DelayedLottieView.displayName = 'DelayedLottieView';

View File

@@ -0,0 +1,129 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { MutableRefObject } from 'react';
import { forwardRef, useCallback, useMemo, useRef } from 'react';
import { Image, type ViewStyle } from 'react-native';
import type { Dotlottie, Mode } from '@lottiefiles/dotlottie-react-native';
import { DotLottie } from '@lottiefiles/dotlottie-react-native';
/**
* Wrapper around DotLottie with legacy LottieView prop compatibility.
*
* DotLottie loads .lottie sources asynchronously on the native side, so its
* built-in autoplay already waits for load — no manual delay needed.
*
* @example
* <LottieAnimation autoPlay loop source={animation} style={styles.animation} />
*/
export type DotLottieSource = string | number | { uri: string };
type DotLottieEvents = {
onLoad?: () => void;
onComplete?: () => void;
onLoadError?: () => void;
onPlay?: () => void;
onLoop?: (loopCount: number) => void;
onDestroy?: () => void;
onUnFreeze?: () => void;
onFreeze?: () => void;
onPause?: () => void;
onFrame?: () => void;
onStop?: () => void;
onRender?: () => void;
onTransition?: (state: { previousState: string; newState: string }) => void;
onStateExit?: (state: { leavingState: string }) => void;
onStateEntered?: (state: { enteringState: string }) => void;
};
type LottieAnimationProps = DotLottieEvents & {
source: DotLottieSource;
style?: ViewStyle;
loop?: boolean;
autoplay?: boolean;
speed?: number;
themeId?: string;
marker?: string;
segment?: number[];
playMode?: Mode;
// Legacy LottieView prop kept for mechanical migration
autoPlay?: boolean;
// Legacy lifecycle callbacks
onAnimationLoaded?: () => void;
onAnimationFinish?: (isCancelled: boolean) => void;
// Legacy compatibility props (ignored by DotLottie)
cacheComposition?: boolean;
progress?: number;
renderMode?: 'AUTOMATIC' | 'HARDWARE' | 'SOFTWARE';
resizeMode?: 'cover' | 'contain' | 'center';
};
export const LottieAnimation = forwardRef<Dotlottie, LottieAnimationProps>((props, forwardedRef) => {
const {
autoPlay,
autoplay,
source,
onAnimationLoaded,
onAnimationFinish,
onLoad,
onComplete,
cacheComposition: _cacheComposition,
progress: _progress,
renderMode: _renderMode,
resizeMode: _resizeMode,
style,
...rest
} = props;
const internalRef = useRef<Dotlottie | null>(null);
const shouldAutoPlay = useMemo(() => Boolean(autoPlay ?? autoplay), [autoPlay, autoplay]);
// Metro require() returns a number asset ID — resolve it to a { uri } for DotLottie
const resolvedSource = useMemo((): string | { uri: string } => {
if (typeof source === 'number') {
const asset = Image.resolveAssetSource(source);
return { uri: asset.uri };
}
return source;
}, [source]);
const handleRef = useCallback(
(instance: unknown) => {
const lottieInstance = instance as Dotlottie | null;
internalRef.current = lottieInstance;
if (typeof forwardedRef === 'function') {
forwardedRef(lottieInstance);
return;
}
if (forwardedRef) {
(forwardedRef as MutableRefObject<Dotlottie | null>).current = lottieInstance;
}
},
[forwardedRef],
);
const handleLoad = useCallback(() => {
onAnimationLoaded?.();
onLoad?.();
}, [onAnimationLoaded, onLoad]);
const handleComplete = useCallback(() => {
onAnimationFinish?.(false);
onComplete?.();
}, [onAnimationFinish, onComplete]);
return (
<DotLottie
ref={handleRef}
{...rest}
source={resolvedSource}
style={style ?? {}}
autoplay={shouldAutoPlay}
onLoad={handleLoad}
onComplete={handleComplete}
/>
);
});
LottieAnimation.displayName = 'LottieAnimation';

View File

@@ -3,8 +3,8 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/**
* DelayedLottieView for web placeholder component.
* LottieAnimation web placeholder component.
*/
export const DelayedLottieView = () => {
export const LottieAnimation = () => {
return <div />;
};

View File

@@ -7,9 +7,8 @@ import { StyleSheet } from 'react-native';
import type { DocumentCategory } from '@selfxyz/common/utils/types';
import successAnimation from '../../animations/loading/success.json';
import { PrimaryButton } from '../../components';
import { DelayedLottieView } from '../../components/DelayedLottieView';
import { LottieAnimation } from '../../components/LottieAnimation';
import Description from '../../components/typography/Description';
import { Title } from '../../components/typography/Title';
import { PassportEvents, ProofEvents } from '../../constants/analytics';
@@ -22,6 +21,9 @@ import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
import { SdkEvents } from '../../types/events';
import type { SelfClient } from '../../types/public';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const successAnimation = require('../../animations/loading/success.lottie');
/*
Screen to confirm identification ownership
props:
@@ -46,7 +48,7 @@ export const ConfirmIdentificationScreen = ({ onBeforeConfirm }: { onBeforeConfi
return (
<ExpandableBottomLayout.Layout backgroundColor={black}>
<ExpandableBottomLayout.TopSection backgroundColor={black}>
<DelayedLottieView
<LottieAnimation
autoPlay
loop={false}
source={successAnimation}

View File

@@ -7,9 +7,8 @@ import { StyleSheet } from 'react-native';
import type { MRZInfo } from 'src/types/public';
import Scan from '../../../svgs/icons/passport_camera_scan.svg';
import passportScanAnimation from '../../animations/passport_scan.json';
import { Additional, Description, SecondaryButton, Title, View, XStack, YStack } from '../../components';
import { DelayedLottieView } from '../../components/DelayedLottieView';
import { LottieAnimation } from '../../components/LottieAnimation';
import { MRZScannerView } from '../../components/MRZScannerView';
import { PassportEvents } from '../../constants/analytics';
import { black, slate400, slate800, white } from '../../constants/colors';
@@ -20,6 +19,9 @@ import type { SafeAreaInsets } from '../../layouts/ExpandableBottomLayout';
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
import { SdkEvents } from '../../types/events';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const passportScanAnimation = require('../../animations/passport_scan.lottie');
type Props = {
onBack?: () => void;
onSuccess?: () => void;
@@ -55,7 +57,7 @@ export const DocumentCameraScreen = ({ onBack, onSuccess, safeAreaInsets }: Prop
>
<ExpandableBottomLayout.TopSection backgroundColor={black} safeAreaTop={safeAreaInsets?.top}>
<MRZScannerView onMRZDetected={handleMRZDetected} onError={handleScannerError} />
<DelayedLottieView
<LottieAnimation
autoPlay
loop
source={passportScanAnimation}

View File

@@ -2,14 +2,12 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type LottieView from 'lottie-react-native';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Image, Linking, NativeEventEmitter, NativeModules, Platform, StyleSheet } from 'react-native';
import NfcManager from 'react-native-nfc-manager';
import passportVerifyAnimation from 'src/animations/passport_verify.json';
import { BodyText, PrimaryButton, SecondaryButton, Title, View, XStack } from 'src/components';
import ButtonsContainer from 'src/components/ButtonsContainer';
import { DelayedLottieView } from 'src/components/DelayedLottieView';
import { LottieAnimation } from 'src/components/LottieAnimation';
import TextsContainer from 'src/components/TextsContainer';
import { PassportEvents } from 'src/constants/analytics';
import { black, slate100, slate400, slate500, white } from 'src/constants/colors';
@@ -27,6 +25,11 @@ import { v4 as uuidv4 } from 'uuid';
import type { PassportData } from '@selfxyz/common';
import type { Dotlottie } from '@lottiefiles/dotlottie-react-native';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- binary asset loaded by Metro
const passportVerifyAnimation = require('../../animations/passport_verify.lottie');
const emitter = Platform.OS === 'android' ? new NativeEventEmitter(NativeModules.nativeModule) : null;
type DocumentNFCScreenProps = {
@@ -65,7 +68,7 @@ export const DocumentNFCScreen: React.FC<DocumentNFCScreenProps> = (props: Docum
[props.useCan],
);
const animationRef = useRef<LottieView>(null);
const animationRef = useRef<Dotlottie | null>(null);
useEffect(() => {
animationRef.current?.play();
@@ -386,7 +389,7 @@ export const DocumentNFCScreen: React.FC<DocumentNFCScreenProps> = (props: Docum
safeAreaBottom={props.safeAreaInsets?.bottom}
>
<ExpandableBottomLayout.TopSection backgroundColor={slate100} safeAreaTop={props.safeAreaInsets?.top}>
<DelayedLottieView
<LottieAnimation
ref={animationRef}
autoPlay={false}
loop={false}

View File

@@ -48,6 +48,8 @@ export type { DocumentAttributes } from './documents/validation';
export type { DocumentData, DocumentMetadata, PassportCameraProps, ScreenProps } from './types/ui';
export type { DotLottieSource } from './components/LottieAnimation';
export type { HapticOptions, HapticType } from './haptic/shared';
export type { MRZScanOptions } from './mrz';
@@ -62,8 +64,6 @@ export type { SdkErrorCategory } from './errors';
export type { provingMachineCircuitType } from './proving/provingMachine';
export { DelayedLottieView } from './components/DelayedLottieView';
export { ExpandableBottomLayout } from './layouts/ExpandableBottomLayout';
export {
@@ -79,6 +79,8 @@ export {
export { default as LogoConfirmationScreen } from './flows/onboarding/logo-confirmation-screen';
export { LottieAnimation } from './components/LottieAnimation';
export { NFCScannerScreen } from './components/screens/NFCScannerScreen';
export { QRCodeScreen } from './components/screens/QRCodeScreen';

View File

@@ -0,0 +1,171 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/* @vitest-environment jsdom */
import { createRef, type Ref } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { LottieAnimation } from '../src/components/LottieAnimation';
import { render } from '@testing-library/react';
// Shared mutable state — vi.hoisted ensures it exists before the hoisted vi.mock runs
const state = vi.hoisted(() => ({
capturedProps: {} as Record<string, any>,
capturedRef: null as Ref<any> | null,
}));
vi.mock('@lottiefiles/dotlottie-react-native', async () => {
const { forwardRef } = await import('react');
const MockDotLottie = forwardRef((props: any, ref: any) => {
state.capturedProps = { ...props };
state.capturedRef = ref;
return null;
});
MockDotLottie.displayName = 'MockDotLottie';
return {
DotLottie: MockDotLottie,
Mode: { FORWARD: 0, REVERSE: 1, BOUNCE: 2, REVERSE_BOUNCE: 3 },
};
});
beforeEach(() => {
state.capturedProps = {};
state.capturedRef = null;
});
describe('LottieAnimation', () => {
describe('autoPlay → autoplay mapping', () => {
it('maps legacy autoPlay={true} to autoplay={true}', () => {
render(<LottieAnimation autoPlay source="test.lottie" />);
expect(state.capturedProps.autoplay).toBe(true);
});
it('maps legacy autoPlay={false} to autoplay={false}', () => {
render(<LottieAnimation autoPlay={false} source="test.lottie" />);
expect(state.capturedProps.autoplay).toBe(false);
});
it('passes native autoplay prop through', () => {
render(<LottieAnimation autoplay source="test.lottie" />);
expect(state.capturedProps.autoplay).toBe(true);
});
it('prefers autoPlay over autoplay when both are set', () => {
render(<LottieAnimation autoPlay={false} autoplay={true} source="test.lottie" />);
expect(state.capturedProps.autoplay).toBe(false);
});
it('defaults to false when neither is set', () => {
render(<LottieAnimation source="test.lottie" />);
expect(state.capturedProps.autoplay).toBe(false);
});
});
describe('onAnimationLoaded → onLoad mapping', () => {
it('calls onAnimationLoaded when DotLottie fires onLoad', () => {
const onAnimationLoaded = vi.fn();
render(<LottieAnimation source="test.lottie" onAnimationLoaded={onAnimationLoaded} />);
state.capturedProps.onLoad();
expect(onAnimationLoaded).toHaveBeenCalledOnce();
});
it('calls both onAnimationLoaded and onLoad when both are set', () => {
const onAnimationLoaded = vi.fn();
const onLoad = vi.fn();
render(<LottieAnimation source="test.lottie" onAnimationLoaded={onAnimationLoaded} onLoad={onLoad} />);
state.capturedProps.onLoad();
expect(onAnimationLoaded).toHaveBeenCalledOnce();
expect(onLoad).toHaveBeenCalledOnce();
});
});
describe('onAnimationFinish → onComplete mapping', () => {
it('calls onAnimationFinish with isCancelled=false on complete', () => {
const onAnimationFinish = vi.fn();
render(<LottieAnimation source="test.lottie" onAnimationFinish={onAnimationFinish} />);
state.capturedProps.onComplete();
expect(onAnimationFinish).toHaveBeenCalledWith(false);
});
it('always passes false for isCancelled (cancellation signal is lost)', () => {
const onAnimationFinish = vi.fn();
render(<LottieAnimation source="test.lottie" onAnimationFinish={onAnimationFinish} />);
state.capturedProps.onComplete();
state.capturedProps.onComplete();
expect(onAnimationFinish).toHaveBeenCalledTimes(2);
expect(onAnimationFinish).toHaveBeenNthCalledWith(1, false);
expect(onAnimationFinish).toHaveBeenNthCalledWith(2, false);
});
it('calls both onAnimationFinish and onComplete when both are set', () => {
const onAnimationFinish = vi.fn();
const onComplete = vi.fn();
render(<LottieAnimation source="test.lottie" onAnimationFinish={onAnimationFinish} onComplete={onComplete} />);
state.capturedProps.onComplete();
expect(onAnimationFinish).toHaveBeenCalledWith(false);
expect(onComplete).toHaveBeenCalledOnce();
});
});
describe('legacy props are dropped', () => {
it('does not forward cacheComposition, progress, renderMode, or resizeMode', () => {
render(
<LottieAnimation
source="test.lottie"
cacheComposition={true}
progress={0.5}
renderMode="HARDWARE"
resizeMode="cover"
/>,
);
expect(state.capturedProps.cacheComposition).toBeUndefined();
expect(state.capturedProps.progress).toBeUndefined();
expect(state.capturedProps.renderMode).toBeUndefined();
expect(state.capturedProps.resizeMode).toBeUndefined();
});
it('forwards non-legacy props like loop and speed', () => {
render(<LottieAnimation source="test.lottie" loop speed={2} />);
expect(state.capturedProps.loop).toBe(true);
expect(state.capturedProps.speed).toBe(2);
});
});
describe('ref forwarding', () => {
it('forwards function ref with the DotLottie instance', () => {
const refFn = vi.fn();
render(<LottieAnimation ref={refFn} source="test.lottie" />);
// Simulate the native side calling the ref callback
const fakeInstance = { play: vi.fn() };
if (typeof state.capturedRef === 'function') {
state.capturedRef(fakeInstance);
}
expect(refFn).toHaveBeenCalledWith(fakeInstance);
});
it('forwards object ref with the DotLottie instance', () => {
const ref = createRef<any>();
render(<LottieAnimation ref={ref} source="test.lottie" />);
const fakeInstance = { play: vi.fn() };
if (typeof state.capturedRef === 'function') {
state.capturedRef(fakeInstance);
}
expect(ref.current).toBe(fakeInstance);
});
});
describe('style handling', () => {
it('passes through provided style', () => {
const style = { width: 100, height: 100 };
render(<LottieAnimation source="test.lottie" style={style} />);
expect(state.capturedProps.style).toEqual(style);
});
it('defaults to empty object when no style is provided', () => {
render(<LottieAnimation source="test.lottie" />);
expect(state.capturedProps.style).toEqual({});
});
});
});

View File

@@ -245,11 +245,17 @@ vi.mock('react-native-svg-circle-country-flags', () => ({
default: {},
}));
// Mock lottie-react-native
// Mock lottie-react-native (legacy, kept for transitive imports)
vi.mock('lottie-react-native', () => ({
default: 'div',
}));
// Mock @lottiefiles/dotlottie-react-native (native module unavailable in Node)
vi.mock('@lottiefiles/dotlottie-react-native', () => ({
DotLottie: 'div',
Mode: { FORWARD: 0, REVERSE: 1, BOUNCE: 2, REVERSE_BOUNCE: 3 },
}));
// Mock react-native-haptic-feedback
vi.mock('react-native-haptic-feedback', () => ({
default: {

View File

@@ -76,7 +76,7 @@ export default defineConfig([
'ethers',
// React Native dependencies
'react-native-svg-circle-country-flags',
'lottie-react-native',
'@lottiefiles/dotlottie-react-native',
'react-native-haptic-feedback',
'react-native-localize',
// Optional RN adapter peer dependencies
@@ -87,6 +87,8 @@ export default defineConfig([
/^@noble\/hashes\/.*/,
// SVG files should be handled by React Native's SVG transformer
/\.svg$/,
// Externalize animation files so Metro can deduplicate them
/\/animations\/.*\.(json|lottie)$/,
],
esbuildOptions(options) {
options.supported = {
@@ -133,7 +135,7 @@ export default defineConfig([
'ethers',
// React Native dependencies
'react-native-svg-circle-country-flags',
'lottie-react-native',
'@lottiefiles/dotlottie-react-native',
'react-native-haptic-feedback',
'react-native-localize',
// Optional RN adapter peer dependencies
@@ -144,6 +146,8 @@ export default defineConfig([
/^@noble\/hashes\/.*/,
// SVG files should be handled by React Native's SVG transformer
/\.svg$/,
// Externalize animation files so Metro can deduplicate them
/\/animations\/.*\.(json|lottie)$/,
],
outExtension: ({ format }) => ({ js: format === 'cjs' ? '.cjs' : '.js' }),
esbuildOptions(options) {

View File

@@ -18,8 +18,8 @@ Host App
├─ LifecycleHandler (init, ready, close, error, success)
├─ BiometricHandler (authenticate, isAvailable)
├─ KeychainHandler (get, set, remove)
├─ NfcHandler (scan, cancelScan, isSupported)
└─ CameraHandler (isAvailable, scanMRZ — stub)
├─ NfcHandler (scan + APDU exchange, cancelScan, isSupported)
└─ CameraHandler (isAvailable, scanMRZ via native module)
```
### File List
@@ -32,12 +32,12 @@ Host App
| `src/handlers/LifecycleHandler.ts` | ~70 | App lifecycle + verification callbacks |
| `src/handlers/BiometricHandler.ts` | ~60 | Biometric auth via react-native-biometrics |
| `src/handlers/KeychainHandler.ts` | ~65 | Secure storage via react-native-keychain |
| `src/handlers/NfcHandler.ts` | ~130 | NFC tag reading via react-native-nfc-manager |
| `src/handlers/CameraHandler.ts` | ~25 | Camera stub (isAvailable, scanMRZ not yet impl) |
| `src/handlers/NfcHandler.ts` | ~180 | NFC tag reading + APDU exchange via react-native-nfc-manager |
| `src/handlers/CameraHandler.ts` | ~90 | MRZ scanning via native SelfMRZScannerModule / MRZScannerModule |
| `src/handlers/index.ts` | ~30 | Handler factory (createHandlers) |
| `src/index.ts` | ~5 | Public exports |
| **Total source** | **~715** | |
| **Tests (8 files)** | **~880** | 59 tests |
| **Tests (8 files)** | **~950** | 64 tests |
### Dependencies
@@ -85,31 +85,40 @@ into their platform build:
### NFC Scan Return Shape
The webview-bridge spec expects `nfc.scan` to return raw APDU response
bytes. Our implementation returns a higher-level object:
The NFC handler returns tag metadata plus optional APDU exchange results:
```typescript
{ connected: true, tagId: string | null, techType: string, params: {...} }
{
connected: true,
tagId: string | null,
techType: string,
params: {...},
apduResponses?: string[] // hex-encoded responses when apduCommands are provided
}
```
**Justification:** `react-native-nfc-manager` provides tag discovery and
technology negotiation but does not expose raw APDU transceive at the
`getTag()` level without additional low-level calls. The current shape
gives the web layer enough information to confirm a tag was found and
proceed with the verification flow. When APDU command exchange is needed,
a `transceive` method should be added to `NfcHandler` that wraps
`NfcManager.transceive()`.
When `params.apduCommands` (array of hex strings) is provided, the handler
iterates through each command, calls `NfcManager.transceive()`, and returns
hex-encoded response bytes in `apduResponses`. Progress events are emitted
at `apdu_exchange` (70%) and `apdu_complete` (90%).
---
## Deferred Decision
## Camera / MRZ Implementation
**Camera / MRZ scanning**`CameraHandler.scanMRZ` throws
`NOT_IMPLEMENTED`. A full implementation requires choosing a camera
library (`react-native-vision-camera` is the modern choice) plus an
OCR/MRZ parsing layer. `isAvailable` currently returns `true`
unconditionally. This should be wired to a real permission check once
a camera library is chosen.
`CameraHandler` loads the native MRZ scanner module at init time,
checking for `SelfMRZScannerModule` (preferred) or `MRZScannerModule`
(fallback) from React Native's `NativeModules`.
- `isAvailable()` returns whether a native MRZ module was found.
- `scanMRZ()` calls `scanner.startScanning()`, normalizes the result
(extracts `documentNumber`, `dateOfBirth`, `dateOfExpiry`, plus optional
`documentType` and `countryCode`), and throws `MRZ_SCAN_FAILED` on
scanner errors or `MRZ_SCAN_INVALID_RESULT` if required fields are missing.
- If no native module is present, `scanMRZ()` throws `NOT_AVAILABLE`.
The host app must provide a native MRZ scanner module (e.g., via
`react-native-vision-camera` + OCR) that exposes `startScanning()`.
---
@@ -119,7 +128,7 @@ a camera library is chosen.
```bash
cd packages/rn-sdk
npx vitest run # 59 tests across 8 files
npx vitest run # 64 tests across 8 files
```
### Device Testing Checklist
@@ -180,16 +189,15 @@ import { SelfVerification } from '@selfxyz/rn-sdk';
| Lifecycle handler | Done | init, ready, close, error, success |
| Biometric handler | Done | authenticate, isAvailable via react-native-biometrics |
| Keychain handler | Done | get, set, remove via react-native-keychain |
| NFC handler | Done | scan, cancelScan, isSupported via react-native-nfc-manager |
| NFC handler | Done | scan + APDU exchange, cancelScan, isSupported via react-native-nfc-manager |
| iOS asset path | Done | Absolute path via react-native-fs, relative fallback |
| Android asset path | Done | `file:///android_asset/` |
| Dev server override | Done | `devServerUrl` prop |
| Camera / MRZ scan | Stub | `isAvailable` hardcoded true, `scanMRZ` throws NOT_IMPLEMENTED |
| Camera / MRZ scan | Done | scanMRZ via native SelfMRZScannerModule with result normalization |
## Known Limitations
- Camera `scanMRZ` is a stub — needs camera library + OCR (see Deferred Decision)
- NFC returns tag metadata, not raw APDU bytes (see Spec Deviation)
- `CameraHandler.isAvailable` returns `true` unconditionally
- Camera/MRZ requires host app to provide a native MRZ scanner module (`SelfMRZScannerModule` or `MRZScannerModule`)
- No retry/reconnect logic for WebView crashes
- Asset bundling requires manual platform setup by the host app
- Physical-device validation breadth for NFC/APDU and camera across host apps is still limited

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
@font-face{font-family:Advercase-Regular;src:url(../fonts/Advercase-Regular.otf) format("opentype");font-display:swap}@font-face{font-family:DINOT-Bold;src:url(../fonts/DINOT-Bold.otf) format("opentype");font-weight:700;font-display:swap}@font-face{font-family:DINOT-Medium;src:url(../fonts/DINOT-Medium.otf) format("opentype");font-weight:500;font-display:swap}@font-face{font-family:IBMPlexMono-Regular;src:url(../fonts/IBMPlexMono-Regular.otf) format("opentype");font-display:swap}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{height:100%;width:100%;overflow:hidden}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}@keyframes spin{to{transform:rotate(360deg)}}

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