diff --git a/.cursor/mcp.json b/.cursor/mcp.json index b2657c17c..7b71337c5 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,16 +1,4 @@ { - "mcpServers": { - "giga": { - "command": "npx", - "args": [ - "-y", - "mcp-remote@latest", - "https://mcp.gigamind.dev/mcp" - ] - } - }, - "settings": { - "disableAutoPRAnalysis": true, - "manualReviewEnabled": true - } + "mcpServers": {}, + "settings": {} } diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..55e4e90fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug Report +about: Report a bug or unexpected behavior +title: '[Bug] ' +labels: bug +assignees: '' +--- + +> **โš ๏ธ Security Issues**: If you've discovered a security vulnerability, **do not** open a public issue. Please report it responsibly by emailing **team@self.xyz** instead. + +## Description + +_A clear and concise description of what the bug is._ + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + +_What you expected to happen._ + +## Actual Behavior + +_What actually happened._ + +## Environment (optional) + +- Workspace: _e.g., app, circuits, contracts, sdk/core, etc._ +- Platform: _e.g., iOS, Android, Web_ +- Version: _if applicable_ + +## Additional Context + +_Any other context, logs, or screenshots that might help._ diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..d035793d5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,29 @@ +--- +name: Feature Request / Contribution +about: Suggest a new feature or propose a contribution +title: '[Feature] ' +labels: enhancement +assignees: '' +--- + +> **๐Ÿ’ก For Complex Features**: If your contribution targets core components or introduces complex features, please open an issue first to discuss your implementation plan before starting development. See [contribute.md](https://github.com/selfxyz/self/blob/dev/contribute.md) for guidelines. + +## Description + +_A clear description of what you want to build or contribute._ + +## Motivation + +_Why is this feature useful? What problem does it solve?_ + +## Proposed Solution (optional) + +_If you have ideas on how to implement this, describe them here._ + +## Workspace (optional) + +_Which workspace(s) would this affect? (e.g., app, circuits, contracts, sdk/core, etc.)_ + +## Additional Context + +_Any other context, mockups, or examples._ diff --git a/.github/workflows/circuits.yml b/.github/workflows/circuits.yml index 021753870..7f34ced8a 100644 --- a/.github/workflows/circuits.yml +++ b/.github/workflows/circuits.yml @@ -24,6 +24,8 @@ jobs: echo "Running for ${{ github.base_ref }} - no path filter" else # For dev branch, check if circuits files changed + # Fetch the base branch to ensure it's available for comparison + git fetch origin ${{ github.base_ref }} --depth=1 CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || { echo "Error: Failed to diff against base branch" exit 1 diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 2c41d18a8..86dbf375a 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -29,6 +29,8 @@ jobs: echo "Running for ${{ github.base_ref }} - no path filter" else # For dev branch, check if contracts or common files changed + # Fetch the base branch to ensure it's available for comparison + git fetch origin ${{ github.base_ref }} --depth=1 CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || { echo "Error: Failed to diff against base branch" exit 1 diff --git a/.github/workflows/core-sdk-ci.yml b/.github/workflows/core-sdk-ci.yml index 5d9166588..180bc0e95 100644 --- a/.github/workflows/core-sdk-ci.yml +++ b/.github/workflows/core-sdk-ci.yml @@ -2,14 +2,48 @@ name: Core SDK CI on: pull_request: - paths: - - "sdk/core/**" - - "common/**" - - ".github/workflows/core-sdk-ci.yml" - - ".github/actions/**" + branches: + - dev + - staging + - main jobs: + check_changes: + runs-on: ubuntu-slim + outputs: + should_run: ${{ steps.filter.outputs.should_run }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check if should run + id: filter + run: | + set -e + if [[ "${{ github.base_ref }}" == "main" ]] || [[ "${{ github.base_ref }}" == "staging" ]]; then + echo "should_run=true" >> $GITHUB_OUTPUT + echo "Running for ${{ github.base_ref }} - no path filter" + else + # For dev branch, check if relevant files changed + # Fetch the base branch to ensure it's available for comparison + git fetch origin ${{ github.base_ref }} --depth=1 + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || { + echo "Error: Failed to diff against base branch" + exit 1 + } + if echo "$CHANGED_FILES" | grep -qE "^(sdk/core/|common/|\.github/workflows/core-sdk-ci\.yml|\.github/actions/)"; then + echo "should_run=true" >> $GITHUB_OUTPUT + echo "Running for dev - relevant files changed" + else + echo "should_run=false" >> $GITHUB_OUTPUT + echo "Skipping for dev - no relevant files changed" + fi + fi + build: + needs: check_changes + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -34,7 +68,8 @@ jobs: lint: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' permissions: contents: read steps: @@ -63,7 +98,8 @@ jobs: types: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' permissions: contents: read steps: @@ -92,7 +128,8 @@ jobs: test: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' permissions: contents: read steps: diff --git a/.github/workflows/mobile-deploy-auto.yml b/.github/workflows/mobile-deploy-auto.yml index 6554a954d..f3aa90929 100644 --- a/.github/workflows/mobile-deploy-auto.yml +++ b/.github/workflows/mobile-deploy-auto.yml @@ -71,8 +71,34 @@ jobs: echo "๐Ÿ“ˆ Version bump: build only" fi - # Always deploy both platforms for now (can be enhanced later) - echo 'platforms=["ios", "android"]' >> $GITHUB_OUTPUT + # Determine platforms based on deploy labels + # If both deploy:ios and deploy:android labels exist, deploy both + # If neither label exists, deploy both (default behavior) + # If only one label exists, deploy only that platform + has_ios_label=false + has_android_label=false + + if [[ "$labels" =~ deploy:ios ]]; then + has_ios_label=true + fi + if [[ "$labels" =~ deploy:android ]]; then + has_android_label=true + fi + + if [[ "$has_ios_label" == "true" && "$has_android_label" == "true" ]]; then + echo 'platforms=["ios", "android"]' >> $GITHUB_OUTPUT + echo "๐Ÿ“ฑ Deploying both iOS and Android (both labels present)" + elif [[ "$has_ios_label" == "true" ]]; then + echo 'platforms=["ios"]' >> $GITHUB_OUTPUT + echo "๐Ÿ“ฑ Deploying iOS only (deploy:ios label present)" + elif [[ "$has_android_label" == "true" ]]; then + echo 'platforms=["android"]' >> $GITHUB_OUTPUT + echo "๐Ÿ“ฑ Deploying Android only (deploy:android label present)" + else + echo 'platforms=["ios", "android"]' >> $GITHUB_OUTPUT + echo "๐Ÿ“ฑ Deploying both iOS and Android (no platform labels, default behavior)" + fi + echo "should_deploy=true" >> $GITHUB_OUTPUT - name: Log deployment info diff --git a/.github/workflows/mobile-deploy.yml b/.github/workflows/mobile-deploy.yml index cef3ccdd3..2d273176d 100644 --- a/.github/workflows/mobile-deploy.yml +++ b/.github/workflows/mobile-deploy.yml @@ -16,7 +16,7 @@ name: Mobile Deploy # - This allows testing from feature branches before merging to dev/staging # # Version Bump PR: After successful build, creates PR to bump version -# - Default target: 'dev' branch (can be overridden with bump_target_branch input) +# - Target: 'dev' branch # - Workflow checks out the target branch, applies version changes, and creates PR # - This separates the build source from the version bump destination # @@ -25,8 +25,6 @@ name: Mobile Deploy # - Merge PR to staging โ†’ builds from staging โ†’ creates version bump PR to dev # 2. Testing from feature branch: # - Manually trigger from feature branch โ†’ builds from feature branch โ†’ creates version bump PR to dev -# 3. Custom version bump target: -# - Set bump_target_branch input โ†’ creates version bump PR to specified branch instead of dev env: # Build environment versions @@ -99,11 +97,6 @@ on: required: false type: boolean default: false - bump_target_branch: - description: "Target branch for version bump PR (default: dev). NOTE: This is where the version bump PR will be created, NOT the branch to build from. The workflow always builds from the triggering branch." - required: false - type: string - default: "dev" pull_request: types: [closed] @@ -1301,8 +1294,8 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # Checkout target branch for version bump PR (default: dev, override with bump_target_branch input) - ref: ${{ inputs.bump_target_branch || 'dev' }} + # Checkout target branch for version bump PR (always dev) + ref: "dev" - name: Setup Node.js uses: actions/setup-node@v4 @@ -1414,7 +1407,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ needs.bump-version.outputs.version }}" - TARGET_BRANCH="${{ inputs.bump_target_branch || 'dev' }}" + TARGET_BRANCH="dev" # Add timestamp to branch name to avoid collisions TIMESTAMP=$(date +%s%N | cut -b1-13) # Milliseconds since epoch (13 digits) BRANCH_NAME="ci/bump-mobile-version-${VERSION}-${TIMESTAMP}" @@ -1490,8 +1483,8 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - # Checkout target branch for tagging (usually dev) - ref: ${{ inputs.bump_target_branch || 'dev' }} + # Checkout target branch for tagging (always dev) + ref: "dev" token: ${{ secrets.GITHUB_TOKEN }} - name: Configure Git diff --git a/.github/workflows/qrcode-sdk-ci.yml b/.github/workflows/qrcode-sdk-ci.yml index 10202c07e..1d5353af5 100644 --- a/.github/workflows/qrcode-sdk-ci.yml +++ b/.github/workflows/qrcode-sdk-ci.yml @@ -14,15 +14,45 @@ on: - dev - staging - main - paths: - - "sdk/qrcode/**" - - "common/**" - - ".github/workflows/qrcode-sdk-ci.yml" - - ".github/actions/**" jobs: + check_changes: + runs-on: ubuntu-slim + outputs: + should_run: ${{ steps.filter.outputs.should_run }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check if should run + id: filter + run: | + set -e + if [[ "${{ github.base_ref }}" == "main" ]] || [[ "${{ github.base_ref }}" == "staging" ]]; then + echo "should_run=true" >> $GITHUB_OUTPUT + echo "Running for ${{ github.base_ref }} - no path filter" + else + # For dev branch, check if relevant files changed + # Fetch the base branch to ensure it's available for comparison + git fetch origin ${{ github.base_ref }} --depth=1 + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) || { + echo "Error: Failed to diff against base branch" + exit 1 + } + if echo "$CHANGED_FILES" | grep -qE "^(sdk/qrcode/|common/|\.github/workflows/qrcode-sdk-ci\.yml|\.github/actions/)"; then + echo "should_run=true" >> $GITHUB_OUTPUT + echo "Running for dev - relevant files changed" + else + echo "should_run=false" >> $GITHUB_OUTPUT + echo "Skipping for dev - no relevant files changed" + fi + fi + # Build dependencies once and cache for other jobs build: + needs: check_changes + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -83,7 +113,8 @@ jobs: # Quality checks job quality-checks: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' steps: - uses: actions/checkout@v6 - name: Read and sanitize Node.js version @@ -151,7 +182,8 @@ jobs: # Build verification job build-verification: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' steps: - uses: actions/checkout@v6 - name: Read and sanitize Node.js version @@ -213,7 +245,8 @@ jobs: # Integration test job integration-test: runs-on: ubuntu-latest - needs: build + needs: [check_changes, build] + if: github.event.pull_request.draft == false && needs.check_changes.outputs.should_run == 'true' steps: - uses: actions/checkout@v6 - name: Read and sanitize Node.js version diff --git a/app/Gemfile b/app/Gemfile index b5b13667f..7e41c8b9c 100644 --- a/app/Gemfile +++ b/app/Gemfile @@ -8,7 +8,7 @@ gem "cocoapods", ">= 1.13", "!= 1.15.0", "!= 1.15.1" gem "activesupport", ">= 6.1.7.5", "!= 7.1.0" # Add fastlane for CI/CD -gem "fastlane", "~> 2.228.0" +gem "fastlane", "~> 2.230.0" group :development do gem "dotenv" diff --git a/app/Gemfile.lock b/app/Gemfile.lock index ef2feddd2..1b67015b5 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -2,6 +2,7 @@ GEM remote: https://rubygems.org/ specs: CFPropertyList (3.0.8) + abbrev (0.1.2) activesupport (7.2.3) base64 benchmark (>= 0.3) @@ -22,8 +23,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1198.0) - aws-sdk-core (3.240.0) + aws-partitions (1.1201.0) + aws-sdk-core (3.241.3) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -31,17 +32,17 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.118.0) - aws-sdk-core (~> 3, >= 3.239.1) + aws-sdk-kms (1.120.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.209.0) - aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-s3 (1.211.0) + aws-sdk-core (~> 3, >= 3.241.3) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.3.0) + base64 (0.2.0) benchmark (0.5.0) bigdecimal (4.0.1) claide (1.1.0) @@ -88,6 +89,7 @@ GEM highline (~> 2.0.0) concurrent-ruby (1.3.6) connection_pool (3.0.2) + csv (3.3.5) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) @@ -128,15 +130,18 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.228.0) + fastlane (2.230.0) CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.0) babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2.0) bundler (>= 1.12.0, < 3.0.0) colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) @@ -154,9 +159,12 @@ GEM http-cookie (~> 1.0.5) json (< 3.0.0) jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) naturally (~> 2.2) + nkf (~> 0.2.0) optparse (>= 0.1.1, < 1.0.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) @@ -174,7 +182,7 @@ GEM fastlane-plugin-versioning_android (0.1.1) fastlane-sirp (1.0.0) sysrandom (~> 1.0) - ffi (1.17.2) + ffi (1.17.3) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) @@ -229,17 +237,18 @@ GEM mini_magick (4.13.2) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (6.0.0) + minitest (6.0.1) prism (~> 1.5) molinillo (0.8.0) - multi_json (1.18.0) + multi_json (1.19.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) nap (1.1.0) naturally (2.3.0) netrc (0.11.0) - nokogiri (1.18.10) + nkf (0.2.0) + nokogiri (1.19.0) mini_portile2 (~> 2.8.2) racc (~> 1.4) optparse (0.8.1) @@ -304,7 +313,7 @@ DEPENDENCIES activesupport (>= 6.1.7.5, != 7.1.0) cocoapods (>= 1.13, != 1.15.1, != 1.15.0) dotenv - fastlane (~> 2.228.0) + fastlane (~> 2.230.0) fastlane-plugin-increment_version_code fastlane-plugin-versioning_android nokogiri (~> 1.18) diff --git a/app/README.md b/app/README.md index e6e8c6cc8..ecd599a11 100644 --- a/app/README.md +++ b/app/README.md @@ -1,32 +1,80 @@ # Self.xyz Mobile App +## Quick Start + +Run the interactive setup script to check and install all dependencies: + +```bash +./scripts/setup-macos.sh +``` + +The script will prompt you to choose between: +1. **Check only** - Just show what's installed/missing +2. **Interactive setup** - Check and confirm before installing (recommended) +3. **Auto-install** - Install everything without prompts + +You can also pass flags directly: `--check-only` or `--yes` + ## Requirements -| Requirement | Version | Installation Guide | -| ----------- | -------- | ------------------------------------------------------------------------ | -| nodejs | >= 22 | [Install nodejs](https://nodejs.org/) | -| ruby | >= 3.1.0 | [Install ruby](https://www.ruby-lang.org/en/documentation/installation/) | -| circom | Latest | [Install circom](https://docs.circom.io/) | -| snarkjs | Latest | [Install snarkjs](https://github.com/iden3/snarkjs) | -| watchman | Latest | [Install watchman](https://facebook.github.io/watchman/) | +### macOS Setup -### Android +#### Core Dependencies -| Requirement | Version | Installation Guide | -| --------------------------- | ------------- | ------------------------------------------------------------------------------------ | -| Java | 17 | [Install Java](https://www.oracle.com/java/technologies/javase-jdk17-downloads.html) | -| Android Studio (Optional)\* | Latest | [Install Android Studio](https://developer.android.com/studio) | -| Android SDK | Latest | See instructions for Android below | -| Android NDK | 27.0.12077973 | See instructions for Android below | +```bash +# Node.js 22+ (via nvm) +nvm install 22 +nvm use 22 -\* To facilitate the installation of the SDK and the NDK, and to pair with development devices with a conventient QR code, you can use Android Studio. +# Watchman +brew install watchman -### iOS +# Ruby (via rbenv) - version specified in .ruby-version +brew install rbenv +echo 'eval "$(rbenv init -)"' >> ~/.zshrc +source ~/.zshrc +rbenv install # Reads version from .ruby-version +rbenv rehash -| Requirement | Version | Installation Guide | -| ----------- | ------- | --------------------------------------------------- | -| Xcode | Latest | [Install Xcode](https://developer.apple.com/xcode/) | -| cocoapods | Latest | [Install cocoapods](https://cocoapods.org/) | +# Ruby gems +gem install cocoapods bundler + +# circom and snarkjs (for ZK circuits) +# Follow: https://docs.circom.io/ and https://github.com/iden3/snarkjs +``` + +#### Android Dependencies + +```bash +# Java 17 +brew install openjdk@17 +``` + +Then install [Android Studio](https://developer.android.com/studio) and configure SDK/NDK (see [Android Setup](#android) below). + +#### iOS Dependencies + +Install [Xcode](https://developer.apple.com/xcode/) from the App Store (includes Command Line Tools). + +### Shell Configuration + +Add the following to your `~/.zshrc` (or `~/.bashrc`): + +```bash +# Java +export JAVA_HOME=$(/usr/libexec/java_home -v 17) + +# Android +export ANDROID_HOME=~/Library/Android/sdk +export ANDROID_SDK_ROOT=$ANDROID_HOME +export PATH=$PATH:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools +``` + +Then reload your shell: + +```bash +source ~/.zshrc +``` ## Installation @@ -54,47 +102,41 @@ and rerun the command. ### Android -#### Using Android Studio +#### Using Android Studio (Recommended) -In Android Studio, go to **Tools** > **SDK Manager** in the menu +1. Download and install [Android Studio](https://developer.android.com/studio) +2. Open Android Studio โ†’ **Settings** (or **Preferences** on macOS) โ†’ **SDK Manager** +3. Under **SDK Platforms**, install the platform with the highest API number +4. Under **SDK Tools**, check **Show Package Details**, expand **NDK (Side by side)**, select version **27.0.12077973** and install +5. Enable **USB debugging** on your Android device (Settings โ†’ Developer options โ†’ USB debugging) -Under **SDK Platforms**, install the platform with the highest API number +#### Using sdkmanager via CLI (Alternative) -Under **SDK Tools**, check the **Show Package Details** checkbox, expand **NDK (Side by side)**, select version **27.0.12077973** and install. +If you prefer not to use Android Studio, you can install the SDK via command line: -#### Using sdkmanager via CLI - -Create a directory for the Android SDK. For example `~/android_sdk`. Define the environment variable `ANDROID_HOME` to point that directory. - -Install sdkmanager under `ANDROID_HOME` according to the instructions on https://developer.android.com/tools/sdkmanager - -List available SDK platforms +1. Create a directory for the Android SDK (e.g., `~/android_sdk`) and set `ANDROID_HOME` to point to it +2. Install sdkmanager according to the [official instructions](https://developer.android.com/tools/sdkmanager) ```bash +# List available SDK platforms $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --list | grep platforms -``` -In the list of platforms, find the latest version and install it. (Replace _NN_ with the latest version number) - -```bash +# Install the latest platform (replace NN with version number) $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "platforms;android-NN" -``` -Install the NDK - -```bash +# Install the NDK $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.12077973" -``` -Define the environment variable `ANDROID_NDK_VERSION` to `27.0.12077973` and `ANDROID_NDK` to `$ANDROID_HOME/ndk/27.0.12077973` - -Install Platform Tools, needed for the `adb` tool - -```bash +# Install Platform Tools (for adb) $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install platform-tools ``` -Add `$ANDROID_HOME/platform-tools` to your `$PATH` variable +Set additional environment variables: + +```bash +export ANDROID_NDK_VERSION=27.0.12077973 +export ANDROID_NDK=$ANDROID_HOME/ndk/27.0.12077973 +``` ## Run the app @@ -149,13 +191,14 @@ To view the Android logs, use the Logcat feature in Android Studio, or use the ` > :warning: To run the app on iOS, you will need a paying Apple Developer account. Free accounts can't run apps that use NFC reading.
> Contact us if you need it to contribute. -Open the ios project on Xcode and add your provisioning profile in Targets > OpenPassport > Signing and Capabilities +Open the ios project in Xcode and add your provisioning profile in Targets > OpenPassport > Signing and Capabilities. -Then, install pods: +Then, install Ruby dependencies and CocoaPods: -``` +```bash cd ios -pod install +bundle install +bundle exec pod install ``` And run the app in Xcode. diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 8767efa4c..5ccd0a476 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -135,7 +135,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 121 - versionName "2.9.7" + versionName "2.9.11" manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp'] externalNativeBuild { cmake { diff --git a/app/fastlane/Fastfile b/app/fastlane/Fastfile index 3101b9ee4..a0d0913e0 100644 --- a/app/fastlane/Fastfile +++ b/app/fastlane/Fastfile @@ -172,6 +172,10 @@ platform :ios do Fastlane::Helpers.verify_env_vars(required_env_vars) + if local_development && version_bump != "skip" + Fastlane::Helpers.bump_local_build_number("ios") + end + # Read build number from version.json (already set by CI or local version-manager.cjs) build_number = Fastlane::Helpers.get_ios_build_number UI.message("๐Ÿ“ฆ Using iOS build number: #{build_number}") @@ -310,18 +314,14 @@ platform :android do # Uploads must be done by CI/CD machines with proper authentication if local_development && !skip_upload skip_upload = true - UI.important("๐Ÿ  LOCAL DEVELOPMENT: Automatically skipping Play Store upload") - UI.important(" Uploads require CI/CD machine permissions and will be handled automatically") + UI.important("๐Ÿ  LOCAL DEVELOPMENT: Play Store uploads are disabled") + UI.important(" Upload the AAB manually in the Play Console after the build finishes") end if local_development if ENV["ANDROID_KEYSTORE_PATH"].nil? ENV["ANDROID_KEYSTORE_PATH"] = Fastlane::Helpers.android_create_keystore(android_keystore_path) end - - if ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"].nil? - ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"] = Fastlane::Helpers.android_create_play_store_key(android_play_store_json_key_path) - end end required_env_vars = [ @@ -332,11 +332,13 @@ platform :android do "ANDROID_KEY_PASSWORD", "ANDROID_PACKAGE_NAME", ] - # Only require JSON key path when not running in CI (local development) - required_env_vars << "ANDROID_PLAY_STORE_JSON_KEY_PATH" if local_development Fastlane::Helpers.verify_env_vars(required_env_vars) + if local_development && version_bump != "skip" + Fastlane::Helpers.bump_local_build_number("android") + end + # Read version code from version.json (already set by CI or local version-manager.cjs) version_code = Fastlane::Helpers.get_android_build_number UI.message("๐Ÿ“ฆ Using Android build number: #{version_code}") @@ -353,13 +355,6 @@ platform :android do target_platform = options[:track] == "production" ? "Google Play" : "Internal Testing" should_upload = Fastlane::Helpers.should_upload_app(target_platform) - # Validate JSON key only in local development; CI uses Workload Identity Federation (ADC) - if local_development - validate_play_store_json_key( - json_key: ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"], - ) - end - Fastlane::Helpers.with_retry(max_retries: 3, delay: 10) do gradle( task: "clean bundleRelease --stacktrace --info", @@ -376,6 +371,9 @@ platform :android do if test_mode || skip_upload if skip_upload UI.important("๐Ÿ”จ BUILD ONLY: Skipping Play Store upload") + if local_development + UI.important("๐Ÿ“ฆ Manual upload required: #{android_aab_path}") + end else UI.important("๐Ÿงช TEST MODE: Skipping Play Store upload") end diff --git a/app/fastlane/helpers/version_manager.rb b/app/fastlane/helpers/version_manager.rb index 3bb97a6cb..352651611 100644 --- a/app/fastlane/helpers/version_manager.rb +++ b/app/fastlane/helpers/version_manager.rb @@ -44,6 +44,20 @@ module Fastlane data["android"]["build"] end + def bump_local_build_number(platform) + unless %w[ios android].include?(platform) + UI.user_error!("Invalid platform: #{platform}. Must be 'ios' or 'android'") + end + + data = read_version_file + data[platform]["build"] += 1 + + write_version_file(data) + UI.success("Bumped #{platform} build number to #{data[platform]["build"]}") + + data[platform]["build"] + end + def verify_ci_version_match # Verify that versions were pre-set by CI unless ENV["CI_VERSION"] && ENV["CI_IOS_BUILD"] && ENV["CI_ANDROID_BUILD"] diff --git a/app/ios/OpenPassport/Info.plist b/app/ios/OpenPassport/Info.plist index 0aa1dd8db..cb975feea 100644 --- a/app/ios/OpenPassport/Info.plist +++ b/app/ios/OpenPassport/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.9.7 + 2.9.11 CFBundleSignature ???? CFBundleURLTypes diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 5d967b466..54a7d7609 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -6,6 +6,8 @@ PODS: - AppAuth/ExternalUserAgent (2.0.0): - AppAuth/Core - boost (1.84.0) + - BVLinearGradient (2.8.3): + - React-Core - DoubleConversion (1.1.6) - fast_float (6.1.4) - FBLazyVector (0.76.9) @@ -1512,7 +1514,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-compat (2.23.0): + - react-native-compat (2.23.1): - DoubleConversion - glog - hermes-engine @@ -2174,7 +2176,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - segment-analytics-react-native (2.21.3): + - segment-analytics-react-native (2.21.4): - React-Core - sovran-react-native - Sentry/HybridSDK (8.53.2) @@ -2187,6 +2189,7 @@ PODS: DEPENDENCIES: - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) + - BVLinearGradient (from `../node_modules/react-native-linear-gradient`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) @@ -2323,6 +2326,8 @@ SPEC REPOS: EXTERNAL SOURCES: boost: :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" + BVLinearGradient: + :path: "../node_modules/react-native-linear-gradient" DoubleConversion: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" fast_float: @@ -2525,6 +2530,7 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 boost: 1dca942403ed9342f98334bf4c3621f011aa7946 + BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70 DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45 @@ -2587,7 +2593,7 @@ SPEC CHECKSUMS: react-native-biometrics: 43ed5b828646a7862dbc7945556446be00798e7d react-native-blur: 6334d934a9b5e67718b8f5725c44cc0a12946009 react-native-cloud-storage: 8d89f2bc574cf11068dfd90933905974087fb9e9 - react-native-compat: 44e82a19b6130e3965d6c8ff37dbc1546d477f0f + react-native-compat: b80530ebcd3d574be5dd99cb27b984a17c119abc react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 react-native-nfc-manager: 66a00e5ddab9704efebe19d605b1b8afb0bb1bd7 @@ -2636,7 +2642,7 @@ SPEC CHECKSUMS: RNScreens: 806e1449a8ec63c2a4e4cf8a63cc80203ccda9b8 RNSentry: 6ad982be2c8e32dab912afb4132b6a0d88484ea0 RNSVG: e1cf5a9a5aa12c69f2ec47031defbd87ae7fb697 - segment-analytics-react-native: a0c29c75ede1989118b50cac96b9495ea5c91a1d + segment-analytics-react-native: 0eae155b0e9fa560fa6b17d78941df64537c35b7 Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 sovran-react-native: a3ad3f8ff90c2002b2aa9790001a78b0b0a38594 diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj index a3d8833b1..1917c7aac 100644 --- a/app/ios/Self.xcodeproj/project.pbxproj +++ b/app/ios/Self.xcodeproj/project.pbxproj @@ -546,7 +546,7 @@ "$(PROJECT_DIR)", "$(PROJECT_DIR)/MoproKit/Libs", ); - MARKETING_VERSION = 2.9.7; + MARKETING_VERSION = 2.9.11; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -686,7 +686,7 @@ "$(PROJECT_DIR)", "$(PROJECT_DIR)/MoproKit/Libs", ); - MARKETING_VERSION = 2.9.7; + MARKETING_VERSION = 2.9.11; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/app/jest.setup.js b/app/jest.setup.js index a48de7dc0..1dc286223 100644 --- a/app/jest.setup.js +++ b/app/jest.setup.js @@ -100,6 +100,7 @@ jest.mock('react-native', () => { get NativeModules() { return global.NativeModules || {}; }, + useColorScheme: jest.fn(() => 'light'), NativeEventEmitter: jest.fn().mockImplementation(nativeModule => { return { addListener: jest.fn(), @@ -110,10 +111,15 @@ jest.mock('react-native', () => { }), PixelRatio: mockPixelRatio, Dimensions: { - get: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, - })), + get: jest.fn(dimension => { + const dimensions = { + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, + }; + return dimension ? dimensions[dimension] : dimensions; + }), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + removeEventListener: jest.fn(), }, Linking: { getInitialURL: jest.fn().mockResolvedValue(null), @@ -139,6 +145,7 @@ jest.mock('react-native', () => { ScrollView: 'ScrollView', TouchableOpacity: 'TouchableOpacity', TouchableHighlight: 'TouchableHighlight', + Pressable: 'Pressable', Image: 'Image', ActivityIndicator: 'ActivityIndicator', SafeAreaView: 'SafeAreaView', @@ -273,10 +280,15 @@ jest.mock( Version: 14, }, Dimensions: { - get: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, - })), + get: jest.fn(dimension => { + const dimensions = { + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, + }; + return dimension ? dimensions[dimension] : dimensions; + }), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + removeEventListener: jest.fn(), }, StyleSheet: { create: jest.fn(styles => styles), @@ -359,15 +371,18 @@ jest.mock( '../packages/mobile-sdk-alpha/node_modules/react-native/Libraries/Utilities/Dimensions', () => ({ getConstants: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, })), set: jest.fn(), - get: jest.fn(() => ({ - window: { width: 375, height: 667, scale: 2 }, - screen: { width: 375, height: 667, scale: 2 }, - })), - addEventListener: jest.fn(), + get: jest.fn(dimension => { + const dimensions = { + window: { width: 375, height: 667, scale: 2, fontScale: 1 }, + screen: { width: 375, height: 667, scale: 2, fontScale: 1 }, + }; + return dimension ? dimensions[dimension] : dimensions; + }), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), removeEventListener: jest.fn(), }), { virtual: true }, @@ -550,8 +565,14 @@ jest.mock( { virtual: true }, ); +// Mock the hooks subpath from mobile-sdk-alpha +jest.mock('@selfxyz/mobile-sdk-alpha/hooks', () => ({ + useSafeBottomPadding: jest.fn((basePadding = 20) => basePadding + 50), +})); + // Mock problematic mobile-sdk-alpha components that use React Native StyleSheet jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + // Override only the specific mocks we need NFCScannerScreen: jest.fn(() => null), SelfClientProvider: jest.fn(({ children }) => children), useSelfClient: jest.fn(() => { @@ -1031,6 +1052,9 @@ jest.mock('@react-native-clipboard/clipboard', () => ({ hasString: jest.fn().mockResolvedValue(false), })); +// Mock react-native-linear-gradient +jest.mock('react-native-linear-gradient', () => 'LinearGradient'); + // Mock react-native-localize jest.mock('react-native-localize', () => ({ getLocales: jest.fn().mockReturnValue([ diff --git a/app/metro.config.cjs b/app/metro.config.cjs index 9a94c7411..24797c027 100644 --- a/app/metro.config.cjs +++ b/app/metro.config.cjs @@ -409,6 +409,7 @@ const config = { 'react-native-reanimated', '@react-native-masked-view/masked-view', '@react-native-firebase/analytics', + 'react-native-b4a', ]; if (optionalPeerDependencies.includes(moduleName)) { diff --git a/app/package.json b/app/package.json index f4f11bc19..e1f3bdc3e 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@selfxyz/mobile-app", - "version": "2.9.7", + "version": "2.9.11", "private": true, "type": "module", "scripts": { @@ -148,12 +148,13 @@ "react-native-cloud-storage": "^2.2.2", "react-native-device-info": "^14.0.4", "react-native-dotenv": "^3.4.11", - "react-native-edge-to-edge": "^1.6.2", + "react-native-edge-to-edge": "^1.7.0", "react-native-gesture-handler": "2.19.0", "react-native-get-random-values": "^1.11.0", "react-native-haptic-feedback": "^2.3.3", "react-native-inappbrowser-reborn": "^3.7.0", "react-native-keychain": "^10.0.0", + "react-native-linear-gradient": "^2.8.3", "react-native-localize": "^3.5.2", "react-native-logs": "^5.3.0", "react-native-nfc-manager": "3.16.3", diff --git a/app/scripts/mobile-deploy-confirm.cjs b/app/scripts/mobile-deploy-confirm.cjs index fe9bccf51..90edc45e2 100755 --- a/app/scripts/mobile-deploy-confirm.cjs +++ b/app/scripts/mobile-deploy-confirm.cjs @@ -441,6 +441,14 @@ function displayWarningsAndGitStatus() { function displayFullConfirmation(platform, versions, deploymentMethod) { displayDeploymentHeader(platform); displayDeploymentMethod(deploymentMethod); + if ( + deploymentMethod === DEPLOYMENT_METHODS.LOCAL_FASTLANE && + (platform === PLATFORMS.ANDROID || platform === PLATFORMS.BOTH) + ) { + console.log( + `${CONSOLE_SYMBOLS.WARNING} Local Android uploads are disabled. You'll need to manually upload the AAB in Play Console.`, + ); + } displayPlatformVersions(platform, versions); displayWarningsAndGitStatus(); } diff --git a/app/scripts/setup-macos.sh b/app/scripts/setup-macos.sh new file mode 100755 index 000000000..c6b12fa75 --- /dev/null +++ b/app/scripts/setup-macos.sh @@ -0,0 +1,184 @@ +#!/bin/bash +# Self.xyz macOS Development Environment Setup +# Usage: ./scripts/setup-macos.sh [--check-only] [--yes] + +set -e + +# 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 + +# Args (can be overridden interactively) +CHECK_ONLY=false; AUTO_YES=false +for arg in "$@"; do + case $arg in + --check-only) CHECK_ONLY=true ;; + --yes|-y) AUTO_YES=true ;; + --interactive|-i) ;; # default behavior + esac +done + +# Colors +R='\033[31m' G='\033[32m' Y='\033[33m' B='\033[34m' C='\033[36m' NC='\033[0m' BOLD='\033[1m' + +ok() { echo -e "${G}โœ“${NC} $1"; } +err() { echo -e "${R}โœ—${NC} $1"; } +warn() { echo -e "${Y}โš ${NC} $1"; } +info() { echo -e "${C}โ„น${NC} $1"; } + +confirm() { + $AUTO_YES && return 0 + read -p "$1 [Y/n] " -n 1 -r; echo + [[ ! $REPLY =~ ^[Nn]$ ]] +} + +# 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_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_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/27.0.12077973" ]] && 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"; } + +# 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_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_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_shell() { + local rc=~/.zshrc + [[ "$SHELL" == *bash* ]] && rc=~/.bashrc + + # Check if already configured + if grep -q "# Self.xyz Dev Environment" "$rc" 2>/dev/null; then + ok "Shell already configured" + return 0 + fi + + info "Adding environment to $rc..." + cat >> "$rc" << 'EOF' + +# Self.xyz Dev Environment +export JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || echo "") +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" +EOF + ok "Shell configured. Run: source $rc" +} + +# Main +echo -e "\n${C}${BOLD}โ•โ•โ• Self.xyz macOS Setup โ•โ•โ•${NC}\n" + +# Interactive mode selection (if no flags provided) +if [[ "$CHECK_ONLY" == false && "$AUTO_YES" == false ]]; then + echo "How would you like to run the setup?" + echo "" + echo " 1) Check only - just show what's installed/missing" + echo " 2) Interactive setup - check and confirm before installing (recommended)" + echo " 3) Auto-install - install everything without prompts" + echo "" + read -p "Enter choice [1]: " -n 1 -r choice + echo "" + case $choice in + 2) ;; # interactive setup + 3) AUTO_YES=true ;; + *) CHECK_ONLY=true ;; # default: check only + esac + echo "" +fi + +# Define deps: name|check_fn|install_fn|manual_msg +DEPS=( + "Homebrew|chk_brew|inst_brew|" + "nvm|chk_nvm|inst_nvm|" + "Node.js $NODE_MAJOR|chk_node|inst_node|" + "Watchman|chk_watch|inst_watch|" + "rbenv|chk_rbenv|inst_rbenv|" + "Ruby $RUBY_VERSION|chk_ruby|inst_ruby|" + "CocoaPods|chk_pods|inst_pods|" + "Bundler|chk_bundler|inst_bundler|" + "Java 17|chk_java|inst_java|" + "Xcode|chk_xcode||Install from App Store: https://apps.apple.com/app/xcode/id497799835" + "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 27.0.12077973" + "Shell Config|chk_shell|inst_shell|" +) + +MISSING=() +MANUAL=() + +info "Checking dependencies...\n" +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 [[ -n "$manual" ]]; then + warn "$name - manual install required" + MANUAL+=("$name|$manual") + else + err "$name - not installed" + [[ -n "$inst" ]] && MISSING+=("$name|$inst") + fi +done + +$CHECK_ONLY && { + [[ ${#MANUAL[@]} -gt 0 ]] && { echo -e "\n${Y}Manual installs:${NC}"; for m in "${MANUAL[@]}"; do IFS='|' read -r n msg <<< "$m"; echo " $n: $msg"; done; } + exit 0 +} + +# Install missing +if [[ ${#MISSING[@]} -gt 0 ]]; then + echo -e "\n${B}Missing:${NC} $(printf '%s\n' "${MISSING[@]}" | cut -d'|' -f1 | tr '\n' ', ' | sed 's/, $//')" + if confirm "Install all?"; then + for m in "${MISSING[@]}"; do + IFS='|' read -r name fn <<< "$m" + info "Installing $name..." + $fn && ok "$name installed" || err "Failed: $name" + done + fi +fi + +# Manual instructions +[[ ${#MANUAL[@]} -gt 0 ]] && { echo -e "\n${Y}Manual installs needed:${NC}"; for m in "${MANUAL[@]}"; do IFS='|' read -r n msg <<< "$m"; echo " $n: $msg"; done; } + +# 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!" + 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 + +echo -e "\n${G}${BOLD}Setup complete!${NC} Open a new terminal, then: cd $APP_DIR && yarn ios\n" diff --git a/app/src/assets/icons/checkmark_white.svg b/app/src/assets/icons/checkmark_white.svg new file mode 100644 index 000000000..e903e1c42 --- /dev/null +++ b/app/src/assets/icons/checkmark_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/assets/images/bg_starfall_push.png b/app/src/assets/images/bg_starfall_push.png new file mode 100644 index 000000000..ad6f11e95 Binary files /dev/null and b/app/src/assets/images/bg_starfall_push.png differ diff --git a/app/src/assets/logos/opera_minipay.svg b/app/src/assets/logos/opera_minipay.svg new file mode 100644 index 000000000..8628c4f91 --- /dev/null +++ b/app/src/assets/logos/opera_minipay.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/src/assets/icons/logo_white.svg b/app/src/assets/logos/self.svg similarity index 100% rename from app/src/assets/icons/logo_white.svg rename to app/src/assets/logos/self.svg diff --git a/app/src/components/Disclosures.tsx b/app/src/components/Disclosures.tsx index 8770f020e..b95243a75 100644 --- a/app/src/components/Disclosures.tsx +++ b/app/src/components/Disclosures.tsx @@ -5,45 +5,24 @@ import React from 'react'; import { XStack, YStack } from 'tamagui'; -import type { Country3LetterCode } from '@selfxyz/common/constants'; -import { countryCodes } from '@selfxyz/common/constants'; import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils'; import { BodyText } from '@selfxyz/mobile-sdk-alpha/components'; import { slate200, slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import CheckMark from '@/assets/icons/checkmark.svg'; +import { + getDisclosureText, + ORDERED_DISCLOSURE_KEYS, +} from '@/utils/disclosureUtils'; interface DisclosureProps { disclosures: SelfAppDisclosureConfig; } -function listToString(list: string[]): string { - if (list.length === 1) { - return list[0]; - } else if (list.length === 2) { - return list.join(' nor '); - } - return `${list.slice(0, -1).join(', ')} nor ${list.at(-1)}`; -} - export default function Disclosures({ disclosures }: DisclosureProps) { - // Define the order in which disclosures should appear. - const ORDERED_KEYS: Array = [ - 'issuing_state', - 'name', - 'passport_number', - 'nationality', - 'date_of_birth', - 'gender', - 'expiry_date', - 'ofac', - 'excludedCountries', - 'minimumAge', - ] as const; - return ( - {ORDERED_KEYS.map(key => { + {ORDERED_DISCLOSURE_KEYS.map(key => { const isEnabled = disclosures[key]; if ( !isEnabled || @@ -52,53 +31,17 @@ export default function Disclosures({ disclosures }: DisclosureProps) { return null; } - let text = ''; - switch (key) { - case 'ofac': - text = 'I am not on the OFAC sanction list'; - break; - case 'excludedCountries': - text = `I am not a citizen of the following countries: ${countriesToSentence( - (disclosures.excludedCountries as Country3LetterCode[]) || [], - )}`; - break; - case 'minimumAge': - text = `Age is over ${disclosures.minimumAge}`; - break; - case 'name': - text = 'Name'; - break; - case 'passport_number': - text = 'Passport Number'; - break; - case 'date_of_birth': - text = 'Date of Birth'; - break; - case 'gender': - text = 'Gender'; - break; - case 'expiry_date': - text = 'Passport Expiry Date'; - break; - case 'issuing_state': - text = 'Issuing State'; - break; - case 'nationality': - text = 'Nationality'; - break; - default: - return null; + const text = getDisclosureText(key, disclosures); + if (!text) { + return null; } + return ; })} ); } -function countriesToSentence(countries: Array): string { - return listToString(countries.map(country => countryCodes[country])); -} - interface DisclosureItemProps { text: string; } diff --git a/app/src/components/documents/IDSelectorItem.tsx b/app/src/components/documents/IDSelectorItem.tsx new file mode 100644 index 000000000..297ac4186 --- /dev/null +++ b/app/src/components/documents/IDSelectorItem.tsx @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { Pressable } from 'react-native'; +import { Separator, Text, View, XStack, YStack } from 'tamagui'; +import { Check } from '@tamagui/lucide-icons'; + +import { + black, + green500, + green600, + iosSeparator, + slate200, + slate300, + slate400, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +export interface IDSelectorItemProps { + documentName: string; + state: IDSelectorState; + onPress?: () => void; + disabled?: boolean; + isLastItem?: boolean; + testID?: string; +} + +export type IDSelectorState = 'active' | 'verified' | 'expired' | 'mock'; + +function getSubtitleText(state: IDSelectorState): string { + switch (state) { + case 'active': + return 'Currently active'; + case 'verified': + return 'Verified ID'; + case 'expired': + return 'Expired'; + case 'mock': + return 'Testing document'; + } +} + +function getSubtitleColor(state: IDSelectorState): string { + switch (state) { + case 'active': + return green600; + case 'verified': + return slate400; + case 'expired': + return slate400; + case 'mock': + return slate400; + } +} + +export const IDSelectorItem: React.FC = ({ + documentName, + state, + onPress, + disabled, + isLastItem, + testID, +}) => { + const isDisabled = disabled || isDisabledState(state); + const isActive = state === 'active'; + const subtitleText = getSubtitleText(state); + const subtitleColor = getSubtitleColor(state); + const textColor = isDisabled ? slate400 : black; + + // Determine circle color based on state + const circleColor = isDisabled ? slate200 : slate300; + + return ( + <> + + + {/* Radio button indicator */} + + + {isActive && } + + + + {/* Document info */} + + + {documentName} + + + {subtitleText} + + + + + {!isLastItem && } + + ); +}; + +export function isDisabledState(state: IDSelectorState): boolean { + return state === 'expired'; +} diff --git a/app/src/components/documents/IDSelectorSheet.tsx b/app/src/components/documents/IDSelectorSheet.tsx new file mode 100644 index 000000000..9f50de8b1 --- /dev/null +++ b/app/src/components/documents/IDSelectorSheet.tsx @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { Button, ScrollView, Sheet, Text, View, XStack, YStack } from 'tamagui'; + +import { + black, + blue600, + slate200, + white, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; +import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks'; + +import type { IDSelectorState } from '@/components/documents/IDSelectorItem'; +import { + IDSelectorItem, + isDisabledState, +} from '@/components/documents/IDSelectorItem'; + +export interface IDSelectorDocument { + id: string; + name: string; + state: IDSelectorState; +} + +export interface IDSelectorSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + documents: IDSelectorDocument[]; + selectedId?: string; + onSelect: (documentId: string) => void; + onDismiss: () => void; + onApprove: () => void; + testID?: string; +} + +export const IDSelectorSheet: React.FC = ({ + open, + onOpenChange, + documents, + selectedId, + onSelect, + onDismiss, + onApprove, + testID = 'id-selector-sheet', +}) => { + const bottomPadding = useSafeBottomPadding(16); + + // Check if the selected document is valid (not expired or unregistered) + const selectedDoc = documents.find(d => d.id === selectedId); + const canApprove = selectedDoc && !isDisabledState(selectedDoc.state); + + return ( + + + + + {/* Header */} + + Select an ID + + + {/* Document List Container with border radius */} + + + {documents.map((doc, index) => { + const isSelected = doc.id === selectedId; + // Don't override to 'active' if the document is in a disabled state + const itemState: IDSelectorState = + isSelected && !isDisabledState(doc.state) + ? 'active' + : doc.state; + + return ( + onSelect(doc.id)} + isLastItem={index === documents.length - 1} + testID={`${testID}-item-${doc.id}`} + /> + ); + })} + + + + {/* Footer Buttons */} + + + + + + + + ); +}; diff --git a/app/src/components/documents/index.ts b/app/src/components/documents/index.ts new file mode 100644 index 000000000..e4bd90441 --- /dev/null +++ b/app/src/components/documents/index.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +export type { + IDSelectorDocument, + IDSelectorSheetProps, +} from '@/components/documents/IDSelectorSheet'; + +export type { + IDSelectorItemProps, + IDSelectorState, +} from '@/components/documents/IDSelectorItem'; +export { + IDSelectorItem, + isDisabledState, +} from '@/components/documents/IDSelectorItem'; +export { IDSelectorSheet } from '@/components/documents/IDSelectorSheet'; diff --git a/app/src/components/navbar/BaseNavBar.tsx b/app/src/components/navbar/BaseNavBar.tsx index 9611b84bc..e4f7186eb 100644 --- a/app/src/components/navbar/BaseNavBar.tsx +++ b/app/src/components/navbar/BaseNavBar.tsx @@ -33,6 +33,7 @@ interface RightActionProps extends ViewProps { interface NavBarTitleProps extends TextProps { children?: React.ReactNode; size?: 'large' | undefined; + color?: string; } export const LeftAction: React.FC = ({ @@ -84,13 +85,20 @@ export const LeftAction: React.FC = ({ return {children}; }; -const NavBarTitle: React.FC = ({ children, ...props }) => { +const NavBarTitle: React.FC = ({ + children, + color, + style, + ...props +}) => { if (!children) { return null; } return typeof children === 'string' ? ( - {children} + + {children} + ) : ( children ); diff --git a/app/src/components/navbar/DefaultNavBar.tsx b/app/src/components/navbar/DefaultNavBar.tsx index 196a8f7b5..f1a10885c 100644 --- a/app/src/components/navbar/DefaultNavBar.tsx +++ b/app/src/components/navbar/DefaultNavBar.tsx @@ -18,6 +18,8 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => { const { options } = props; const headerStyle = (options.headerStyle || {}) as ViewStyle; const insets = useSafeAreaInsets(); + const headerTitleStyle = (options.headerTitleStyle || {}) as TextStyle; + return ( { paddingBottom={20} backgroundColor={headerStyle.backgroundColor as string} barStyle={ - options.headerTintColor === white || - (options.headerTitleStyle as TextStyle)?.color === white + options.headerTintColor === white || headerTitleStyle?.color === white ? 'light' : 'dark' } @@ -40,9 +41,12 @@ export const DefaultNavBar = (props: NativeStackHeaderProps) => { buttonTap(); goBack(); }} - {...(options.headerTitleStyle as ViewStyle)} + color={options.headerTintColor as string} /> - + {props.options.title} diff --git a/app/src/components/navbar/HomeNavBar.tsx b/app/src/components/navbar/HomeNavBar.tsx index 5c4c02f23..fe998873e 100644 --- a/app/src/components/navbar/HomeNavBar.tsx +++ b/app/src/components/navbar/HomeNavBar.tsx @@ -56,7 +56,7 @@ export const HomeNavBar = (props: NativeStackHeaderProps) => { try { Clipboard.setString(''); } catch {} - props.navigation.navigate('Prove'); + props.navigation.navigate('ProvingScreenRouter'); } catch (error) { console.error('Error consuming token:', error); if ( diff --git a/app/src/components/navbar/Points.tsx b/app/src/components/navbar/Points.tsx index c4f78348a..5b71bbc2f 100644 --- a/app/src/components/navbar/Points.tsx +++ b/app/src/components/navbar/Points.tsx @@ -33,7 +33,7 @@ import { appsUrl } from '@/consts/links'; import { useIncomingPoints, usePoints } from '@/hooks/usePoints'; import { usePointsGuardrail } from '@/hooks/usePointsGuardrail'; import type { RootStackParamList } from '@/navigation'; -import analytics from '@/services/analytics'; +import { trackScreenView } from '@/services/analytics'; import { isTopicSubscribed, requestNotificationPermission, @@ -70,7 +70,6 @@ const Points: React.FC = () => { // Track NavBar view analytics useFocusEffect( React.useCallback(() => { - const { trackScreenView } = analytics(); trackScreenView('Points NavBar', { screenName: 'Points NavBar', }); diff --git a/app/src/components/proof-request/BottomActionBar.tsx b/app/src/components/proof-request/BottomActionBar.tsx new file mode 100644 index 000000000..3f0e22919 --- /dev/null +++ b/app/src/components/proof-request/BottomActionBar.tsx @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React, { useMemo } from 'react'; +import { + ActivityIndicator, + Dimensions, + Pressable, + StyleSheet, +} from 'react-native'; +import { Text, View, XStack } from 'tamagui'; + +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; +import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { ChevronUpDownIcon } from '@/components/proof-request/icons'; + +export interface BottomActionBarProps { + selectedDocumentName: string; + onDocumentSelectorPress: () => void; + onApprovePress: () => void; + approveDisabled?: boolean; + approving?: boolean; + testID?: string; +} + +/** + * Bottom action bar with document selector and approve button. + * Matches Figma design 15234:9322. + */ +export const BottomActionBar: React.FC = ({ + selectedDocumentName, + onDocumentSelectorPress, + onApprovePress, + approveDisabled = false, + approving = false, + testID = 'bottom-action-bar', +}) => { + // Reduce top padding to balance with safe area bottom padding + // The safe area hook adds significant padding on small screens for system UI + const topPadding = 8; + + // Calculate dynamic bottom padding based on screen height + // Scales proportionally to better center the select box beneath the disclosure list + const { height: screenHeight } = Dimensions.get('window'); + const basePadding = 12; + + // Get safe area padding (handles small screens < 900px with extra padding) + const safeAreaPadding = useSafeBottomPadding(basePadding); + + // Dynamic padding calculation: + // - Start with safe area padding (includes base + small screen adjustment) + // - Add additional padding that scales with screen height + // - Formula: safeAreaPadding + (screenHeight - 800) * 0.12 + // - This provides base padding, safe area handling, plus 0-50px extra on larger screens + // - The multiplier (0.12) ensures smooth scaling across different screen sizes + const dynamicPadding = useMemo(() => { + const heightMultiplier = Math.max(0, (screenHeight - 800) * 0.12); + return Math.round(safeAreaPadding + heightMultiplier); + }, [screenHeight, safeAreaPadding]); + + const bottomPadding = dynamicPadding; + + return ( + + + {/* Document Selector Button */} + [ + styles.documentButton, + pressed && styles.documentButtonPressed, + ]} + testID={`${testID}-document-selector`} + > + + + {selectedDocumentName} + + + + + + + + {/* Select Button */} + [ + styles.approveButton, + (approveDisabled || approving) && styles.approveButtonDisabled, + pressed && + !approveDisabled && + !approving && + styles.approveButtonPressed, + ]} + testID={`${testID}-approve`} + > + + {approving ? ( + + ) : ( + + Select + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + documentButton: { + backgroundColor: proofRequestColors.white, + borderWidth: 1, + borderColor: proofRequestColors.slate200, + borderRadius: 4, + }, + documentButtonPressed: { + backgroundColor: proofRequestColors.slate100, + }, + approveButton: { + flex: 1, + backgroundColor: proofRequestColors.blue600, + borderRadius: 4, + }, + approveButtonDisabled: { + opacity: 0.5, + }, + approveButtonPressed: { + backgroundColor: proofRequestColors.blue700, + }, +}); diff --git a/app/src/components/proof-request/BottomVerifyBar.tsx b/app/src/components/proof-request/BottomVerifyBar.tsx new file mode 100644 index 000000000..0106b7cce --- /dev/null +++ b/app/src/components/proof-request/BottomVerifyBar.tsx @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { View } from 'tamagui'; + +import { HeldPrimaryButtonProveScreen } from '@selfxyz/mobile-sdk-alpha/components'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; + +export interface BottomVerifyBarProps { + onVerify: () => void; + selectedAppSessionId: string | undefined | null; + hasScrolledToBottom: boolean; + isScrollable: boolean; + isReadyToProve: boolean; + isDocumentExpired: boolean; + testID?: string; +} + +export const BottomVerifyBar: React.FC = ({ + onVerify, + selectedAppSessionId, + hasScrolledToBottom, + isScrollable, + isReadyToProve, + isDocumentExpired, + testID = 'bottom-verify-bar', +}) => { + const insets = useSafeAreaInsets(); + + return ( + + + + ); +}; diff --git a/app/src/components/proof-request/ConnectedWalletBadge.tsx b/app/src/components/proof-request/ConnectedWalletBadge.tsx new file mode 100644 index 000000000..905f196c1 --- /dev/null +++ b/app/src/components/proof-request/ConnectedWalletBadge.tsx @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { Pressable } from 'react-native'; +import { Text, View, XStack } from 'tamagui'; + +import { plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { WalletIcon } from '@/components/proof-request/icons'; + +export interface ConnectedWalletBadgeProps { + address: string; + userIdType?: string; + onToggle?: () => void; + testID?: string; +} + +/** + * Blue badge showing connected wallet address. + * Matches Figma design 15234:9295 (icon). + */ +export const ConnectedWalletBadge: React.FC = ({ + address, + userIdType, + onToggle, + testID = 'connected-wallet-badge', +}) => { + const label = userIdType === 'hex' ? 'Connected Wallet' : 'Connected ID'; + + const content = ( + + {/* Label with icon */} + + + + {label} + + + + {/* Address */} + + + {truncateAddress(address)} + + + + ); + + if (onToggle) { + return ( + + {content} + + ); + } + + return content; +}; + +/** + * Truncates a wallet address for display. + * @example truncateAddress("0x1234567890abcdef1234567890abcdef12345678") // "0x12..5678" + */ +export function truncateAddress( + address: string, + startChars = 4, + endChars = 4, +): string { + if (address.length <= startChars + endChars + 2) { + return address; + } + return `${address.slice(0, startChars)}..${address.slice(-endChars)}`; +} diff --git a/app/src/components/proof-request/DisclosureItem.tsx b/app/src/components/proof-request/DisclosureItem.tsx new file mode 100644 index 000000000..22e506c06 --- /dev/null +++ b/app/src/components/proof-request/DisclosureItem.tsx @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { Pressable } from 'react-native'; +import { Text, View, XStack } from 'tamagui'; + +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { + FilledCircleIcon, + InfoCircleIcon, +} from '@/components/proof-request/icons'; + +export interface DisclosureItemProps { + text: string; + verified?: boolean; + onInfoPress?: () => void; + isLast?: boolean; + testID?: string; +} + +/** + * Individual disclosure row with green checkmark and optional info button. + * Matches Figma design 15234:9267. + */ +export const DisclosureItem: React.FC = ({ + text, + verified = true, + onInfoPress, + isLast = false, + testID = 'disclosure-item', +}) => { + return ( + + {/* Status Icon */} + + + + + {/* Disclosure Text */} + + + {text} + + + + {/* Info Button */} + {onInfoPress && ( + + + + + + )} + + ); +}; diff --git a/app/src/components/proof-request/ProofMetadataBar.tsx b/app/src/components/proof-request/ProofMetadataBar.tsx new file mode 100644 index 000000000..be7337a15 --- /dev/null +++ b/app/src/components/proof-request/ProofMetadataBar.tsx @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { Text, View, XStack } from 'tamagui'; + +import { plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { DocumentIcon } from '@/components/proof-request/icons'; + +export interface ProofMetadataBarProps { + timestamp: string; + testID?: string; +} + +/** + * Gray metadata bar showing "PROOFS REQUESTED" label and timestamp. + * Matches Figma design 15234:9281. + */ +export const ProofMetadataBar: React.FC = ({ + timestamp, + testID = 'proof-metadata-bar', +}) => { + return ( + + + {/* Icon + Label group */} + + + + Proofs Requested + + + + {/* Dot separator */} + + โ€ข + + + {/* Timestamp */} + + {timestamp} + + + + ); +}; + +/** + * Formats a Date object to match the Figma timestamp format. + * @example formatTimestamp(new Date()) // "4/7/2025 11:44 AM" + */ +export function formatTimestamp(date: Date): string { + const month = date.getMonth() + 1; + const day = date.getDate(); + const year = date.getFullYear(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const ampm = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + const displayMinutes = minutes.toString().padStart(2, '0'); + + return `${month}/${day}/${year} ${displayHours}:${displayMinutes} ${ampm}`; +} diff --git a/app/src/components/proof-request/ProofRequestCard.tsx b/app/src/components/proof-request/ProofRequestCard.tsx new file mode 100644 index 000000000..9d30dd389 --- /dev/null +++ b/app/src/components/proof-request/ProofRequestCard.tsx @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React, { useMemo } from 'react'; +import type { + ImageSourcePropType, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView as ScrollViewType, +} from 'react-native'; +import { ScrollView } from 'react-native'; +import { Text, View } from 'tamagui'; + +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { + proofRequestColors, + proofRequestSpacing, +} from '@/components/proof-request/designTokens'; +import { + formatTimestamp, + ProofMetadataBar, +} from '@/components/proof-request/ProofMetadataBar'; +import { ProofRequestHeader } from '@/components/proof-request/ProofRequestHeader'; + +export interface ProofRequestCardProps { + logoSource: ImageSourcePropType | null; + appName: string; + appUrl: string | null; + documentType?: string; + timestamp?: Date; + children?: React.ReactNode; + connectedWalletBadge?: React.ReactNode; + testID?: string; + onScroll?: (event: NativeSyntheticEvent) => void; + scrollViewRef?: React.RefObject; + onContentSizeChange?: (width: number, height: number) => void; + onLayout?: (event: LayoutChangeEvent) => void; + initialScrollOffset?: number; +} + +/** + * Main card container for proof request screens. + * Combines header, metadata bar, and content section. + * Matches Figma design 15234:9267. + */ +export const ProofRequestCard: React.FC = ({ + logoSource, + appName, + appUrl, + documentType = '', + timestamp, + children, + connectedWalletBadge, + testID = 'proof-request-card', + onScroll, + scrollViewRef, + onContentSizeChange, + onLayout, + initialScrollOffset, +}) => { + // Create default timestamp once and reuse it to avoid unnecessary re-renders + const defaultTimestamp = useMemo(() => new Date(), []); + const effectiveTimestamp = timestamp ?? defaultTimestamp; + + // Build request message with highlighted app name and document type + const requestMessage = ( + <> + + {appName} + + + { + ' is requesting access to the following information from your verified ' + } + + + {documentType} + + + . + + + ); + + return ( + + + {/* Black Header */} + + + {/* Metadata Bar */} + + + {/* White Content Area */} + + {/* Connected Wallet Badge - Fixed position under metadata bar */} + {connectedWalletBadge && ( + + {connectedWalletBadge} + + )} + + {/* Scrollable Content */} + + + {children} + + + + + + ); +}; diff --git a/app/src/components/proof-request/ProofRequestHeader.tsx b/app/src/components/proof-request/ProofRequestHeader.tsx new file mode 100644 index 000000000..4751c03f0 --- /dev/null +++ b/app/src/components/proof-request/ProofRequestHeader.tsx @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import type { ImageSourcePropType } from 'react-native'; +import { Image, Text, View, YStack } from 'tamagui'; + +import { + advercase, + dinot, + plexMono, +} from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; + +export interface ProofRequestHeaderProps { + logoSource: ImageSourcePropType | null; + appName: string; + appUrl: string | null; + requestMessage: React.ReactNode; + testID?: string; +} + +/** + * Black header section for proof request screens. + * Displays app logo, name, URL, and request description. + * Matches Figma design 15234:9267. + */ +export const ProofRequestHeader: React.FC = ({ + logoSource, + appName, + appUrl, + requestMessage, + testID = 'proof-request-header', +}) => { + const hasLogo = logoSource !== null; + + return ( + + {/* Logo and App Info Row */} + + {logoSource && ( + + + + )} + + + {appName} + + {appUrl && ( + + + {appUrl} + + + )} + + + + {/* Request Description */} + + {requestMessage} + + + ); +}; diff --git a/app/src/components/proof-request/WalletAddressModal.tsx b/app/src/components/proof-request/WalletAddressModal.tsx new file mode 100644 index 000000000..e73ecd511 --- /dev/null +++ b/app/src/components/proof-request/WalletAddressModal.tsx @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Modal, Pressable, StyleSheet } from 'react-native'; +import { Text, View, XStack, YStack } from 'tamagui'; +import Clipboard from '@react-native-clipboard/clipboard'; + +import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request/designTokens'; +import { CopyIcon, WalletIcon } from '@/components/proof-request/icons'; + +export interface WalletAddressModalProps { + visible: boolean; + onClose: () => void; + address: string; + userIdType?: string; + testID?: string; +} + +/** + * Modal that displays the full wallet address with copy functionality. + * Appears when user taps on the truncated wallet badge. + */ +export const WalletAddressModal: React.FC = ({ + visible, + onClose, + address, + userIdType, + testID = 'wallet-address-modal', +}) => { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + const label = userIdType === 'hex' ? 'Connected Wallet' : 'Connected ID'; + + // Reset copied state when modal closes + useEffect(() => { + if (!visible) { + setCopied(false); + } + }, [visible]); + + // Clear timeout on unmount or when modal closes/address changes + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [visible, address, onClose]); + + const handleCopy = useCallback(() => { + // Clear any existing timeout before setting a new one + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + Clipboard.setString(address); + setCopied(true); + + // Reset copied state and close after a brief delay + timeoutRef.current = setTimeout(() => { + setCopied(false); + onClose(); + timeoutRef.current = null; + }, 800); + }, [address, onClose]); + + return ( + + + + e.stopPropagation()}> + + {/* Header */} + + + + + {label} + + + + + {/* Full Address */} + + + {address} + + + + {/* Action Buttons */} + + [ + copied ? styles.copiedButton : styles.copyButton, + pressed && !copied && styles.copyButtonPressed, + ]} + testID={`${testID}-copy`} + > + + {copied ? ( + + โœ“ + + ) : ( + + )} + + {copied ? 'Copied!' : 'Copy'} + + + + + {!copied && ( + [ + styles.closeButton, + pressed && styles.closeButtonPressed, + ]} + testID={`${testID}-close`} + > + + + Close + + + + )} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + copyButton: { + flex: 1, + backgroundColor: proofRequestColors.blue600, + borderRadius: 8, + }, + copyButtonPressed: { + backgroundColor: proofRequestColors.blue700, + }, + copiedButton: { + flex: 1, + backgroundColor: proofRequestColors.emerald500, + borderRadius: 8, + }, + closeButton: { + flex: 1, + backgroundColor: proofRequestColors.slate100, + borderRadius: 8, + borderWidth: 1, + borderColor: proofRequestColors.slate200, + }, + closeButtonPressed: { + backgroundColor: proofRequestColors.slate200, + }, +}); diff --git a/app/src/components/proof-request/designTokens.ts b/app/src/components/proof-request/designTokens.ts new file mode 100644 index 000000000..5a5aa8079 --- /dev/null +++ b/app/src/components/proof-request/designTokens.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +/** + * Design tokens for proof request components. + * Extracted from Figma design 15234:9267 and 15234:9322. + */ + +export const proofRequestColors = { + // Base colors + black: '#000000', + white: '#FFFFFF', + + // Slate palette + slate100: '#F8FAFC', + slate200: '#E2E8F0', + slate400: '#94A3B8', + slate500: '#71717A', + slate900: '#0F172A', + + // Blue palette + blue500: '#3B82F6', + blue600: '#2563EB', + blue700: '#1D4ED8', + + // Status colors + emerald500: '#10B981', + + // Zinc palette + zinc500: '#71717A', +} as const; + +export const proofRequestSpacing = { + cardPadding: 20, + headerPadding: 30, + itemPadding: 16, + borderRadius: 10, + borderRadiusSmall: 4, +} as const; diff --git a/app/src/components/proof-request/icons.tsx b/app/src/components/proof-request/icons.tsx new file mode 100644 index 000000000..9f31470b7 --- /dev/null +++ b/app/src/components/proof-request/icons.tsx @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import Svg, { Circle, Path, Rect } from 'react-native-svg'; + +export interface IconProps { + size?: number; + color?: string; +} + +/** + * Chevron up/down icon (dropdown) + */ +export const ChevronUpDownIcon: React.FC = ({ + size = 20, + color = '#94A3B8', +}) => ( + + + +); + +/** + * Copy icon + */ +export const CopyIcon: React.FC = ({ + size = 16, + color = '#FFFFFF', +}) => ( + + + + +); + +/** + * Document icon (lighter stroke to match SF Symbol design) + */ +export const DocumentIcon: React.FC = ({ + size = 18, + color = '#94A3B8', +}) => ( + + + + + +); + +/** + * Filled circle icon (checkmark/bullet point) + */ +export const FilledCircleIcon: React.FC = ({ + size = 18, + color = '#10B981', +}) => ( + + + +); + +/** + * Info circle icon + */ +export const InfoCircleIcon: React.FC = ({ + size = 20, + color = '#3B82F6', +}) => ( + + + + +); + +/** + * Wallet icon (credit card style to match SF Symbol creditcard ๔€Ÿฟ) + */ +export const WalletIcon: React.FC = ({ + size = 16, + color = '#FFFFFF', +}) => ( + + + + +); diff --git a/app/src/components/proof-request/index.ts b/app/src/components/proof-request/index.ts new file mode 100644 index 000000000..ac1503031 --- /dev/null +++ b/app/src/components/proof-request/index.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +export type { BottomActionBarProps } from '@/components/proof-request/BottomActionBar'; +export type { BottomVerifyBarProps } from '@/components/proof-request/BottomVerifyBar'; + +// Metadata bar +export type { ConnectedWalletBadgeProps } from '@/components/proof-request/ConnectedWalletBadge'; + +export type { DisclosureItemProps } from '@/components/proof-request/DisclosureItem'; + +export type { IconProps } from '@/components/proof-request/icons'; + +// Header section +export type { ProofMetadataBarProps } from '@/components/proof-request/ProofMetadataBar'; + +/** + * Proof Request Component Library + * + * Shared components for proof request preview and proving screens. + * These components implement the Figma designs 15234:9267 and 15234:9322. + */ +// Main card component +export type { ProofRequestCardProps } from '@/components/proof-request/ProofRequestCard'; +export type { ProofRequestHeaderProps } from '@/components/proof-request/ProofRequestHeader'; + +export type { WalletAddressModalProps } from '@/components/proof-request/WalletAddressModal'; + +// Icons +export { BottomActionBar } from '@/components/proof-request/BottomActionBar'; +export { BottomVerifyBar } from '@/components/proof-request/BottomVerifyBar'; + +// Bottom action bar +export { + ChevronUpDownIcon, + CopyIcon, + DocumentIcon, + FilledCircleIcon, + InfoCircleIcon, + WalletIcon, +} from '@/components/proof-request/icons'; + +export { + ConnectedWalletBadge, + truncateAddress, +} from '@/components/proof-request/ConnectedWalletBadge'; + +// Connected wallet badge +export { DisclosureItem } from '@/components/proof-request/DisclosureItem'; + +// Disclosure item +export { + ProofMetadataBar, + formatTimestamp, +} from '@/components/proof-request/ProofMetadataBar'; + +export { ProofRequestCard } from '@/components/proof-request/ProofRequestCard'; + +export { ProofRequestHeader } from '@/components/proof-request/ProofRequestHeader'; + +export { WalletAddressModal } from '@/components/proof-request/WalletAddressModal'; + +// Design tokens +export { + proofRequestColors, + proofRequestSpacing, +} from '@/components/proof-request/designTokens'; diff --git a/app/src/components/starfall/StarfallLogoHeader.tsx b/app/src/components/starfall/StarfallLogoHeader.tsx new file mode 100644 index 000000000..4a8f30352 --- /dev/null +++ b/app/src/components/starfall/StarfallLogoHeader.tsx @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { View, XStack } from 'tamagui'; + +import { black, zinc800 } from '@selfxyz/mobile-sdk-alpha/constants/colors'; + +import CheckmarkIcon from '@/assets/icons/checkmark_white.svg'; +import OperaLogo from '@/assets/logos/opera_minipay.svg'; +import SelfLogo from '@/assets/logos/self.svg'; + +export const StarfallLogoHeader: React.FC = () => ( + + {/* Opera MiniPay logo */} + + + + + {/* Checkmark icon */} + + + + + {/* Self logo */} + + + + +); diff --git a/app/src/components/starfall/StarfallPIN.tsx b/app/src/components/starfall/StarfallPIN.tsx new file mode 100644 index 000000000..fb0cac18b --- /dev/null +++ b/app/src/components/starfall/StarfallPIN.tsx @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { Text, XStack, YStack } from 'tamagui'; + +import { white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +export interface StarfallPINProps { + code: string; +} + +export const StarfallPIN: React.FC = ({ code }) => { + // Split the code into individual digits (expects 4 digits) + const digits = code.split('').slice(0, 4); + + // Pad with empty strings if less than 4 digits + while (digits.length < 4) { + digits.push(''); + } + + return ( + + {digits.map((digit, index) => ( + + + {digit} + + + ))} + + ); +}; diff --git a/app/src/hooks/useConnectionModal.ts b/app/src/hooks/useConnectionModal.ts index 31189c547..75c808a8f 100644 --- a/app/src/hooks/useConnectionModal.ts +++ b/app/src/hooks/useConnectionModal.ts @@ -11,11 +11,9 @@ import { apiPingUrl } from '@/consts/links'; import { useModal } from '@/hooks/useModal'; import { useNetInfo } from '@/hooks/useNetInfo'; import { navigationRef } from '@/navigation'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; import { useSettingStore } from '@/stores/settingStore'; -const { trackEvent } = analytics(); - const connectionModalParams = { titleText: 'Internet connection error', bodyText: 'In order to use SELF, you must have access to the internet.', diff --git a/app/src/hooks/useEarnPointsFlow.ts b/app/src/hooks/useEarnPointsFlow.ts index 87dc80ba6..53749da9a 100644 --- a/app/src/hooks/useEarnPointsFlow.ts +++ b/app/src/hooks/useEarnPointsFlow.ts @@ -40,7 +40,7 @@ export const useEarnPointsFlow = ({ // Use setTimeout to ensure modal dismisses before navigating setTimeout(() => { - navigation.navigate('Prove'); + navigation.navigate('ProvingScreenRouter'); }, 100); }, [selfClient, navigation]); diff --git a/app/src/hooks/useFeedbackAutoHide.ts b/app/src/hooks/useFeedbackAutoHide.ts index 908e8a1c5..a86bf118a 100644 --- a/app/src/hooks/useFeedbackAutoHide.ts +++ b/app/src/hooks/useFeedbackAutoHide.ts @@ -17,7 +17,13 @@ export const useFeedbackAutoHide = () => { // When screen goes out of focus, hide the feedback button return () => { - hideFeedbackButton(); + try { + hideFeedbackButton(); + } catch (error) { + if (__DEV__) { + console.debug('Failed to hide feedback button:', error); + } + } }; }, []), ); diff --git a/app/src/hooks/useFeedbackModal.ts b/app/src/hooks/useFeedbackModal.ts index 02b10dbc6..a2df0da32 100644 --- a/app/src/hooks/useFeedbackModal.ts +++ b/app/src/hooks/useFeedbackModal.ts @@ -27,25 +27,38 @@ export const useFeedbackModal = () => { timeoutRef.current = null; } - switch (type) { - case 'button': - showFeedbackButton(); - break; - case 'widget': - showFeedbackWidget(); - break; - case 'custom': - setIsVisible(true); - break; - default: - showFeedbackButton(); + try { + switch (type) { + case 'button': + showFeedbackButton(); + break; + case 'widget': + showFeedbackWidget(); + break; + case 'custom': + setIsVisible(true); + break; + default: + showFeedbackButton(); + } + } catch (error) { + if (__DEV__) { + console.debug('Failed to show feedback button/widget:', error); + } + setIsVisible(true); } // we can close the feedback modals(sentry and custom modals), but can't do so for the Feedback button. // This hides the button after 10 seconds. if (type === 'button') { timeoutRef.current = setTimeout(() => { - hideFeedbackButton(); + try { + hideFeedbackButton(); + } catch (error) { + if (__DEV__) { + console.debug('Failed to hide feedback button:', error); + } + } timeoutRef.current = null; }, 10000); } @@ -57,7 +70,13 @@ export const useFeedbackModal = () => { timeoutRef.current = null; } - hideFeedbackButton(); + try { + hideFeedbackButton(); + } catch (error) { + if (__DEV__) { + console.debug('Failed to hide feedback button:', error); + } + } setIsVisible(false); }, []); diff --git a/app/src/hooks/useProofDisclosureStalenessCheck.ts b/app/src/hooks/useProofDisclosureStalenessCheck.ts new file mode 100644 index 000000000..560d53b99 --- /dev/null +++ b/app/src/hooks/useProofDisclosureStalenessCheck.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useCallback } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import type { SelfApp } from '@selfxyz/common'; + +import type { RootStackParamList } from '@/navigation'; + +/** + * Hook that checks if SelfApp data is stale (missing or empty disclosures) + * and navigates to Home screen if stale data is detected. + * + * Uses a small delay to allow store updates to propagate after navigation + * (e.g., after QR code scan sets selfApp data). + */ +export function useProofDisclosureStalenessCheck( + selfApp: SelfApp | null, + disclosureItems: Array<{ key: string; text: string }>, + navigation: NativeStackNavigationProp, +) { + useFocusEffect( + useCallback(() => { + // Add a small delay to allow Zustand store updates to propagate + // after navigation (e.g., when selfApp is set from QR scan) + const timeoutId = setTimeout(() => { + if (!selfApp || disclosureItems.length === 0) { + navigation.navigate({ name: 'Home', params: {} }); + } + }, 300); + + return () => { + clearTimeout(timeoutId); + }; + }, [selfApp, disclosureItems.length, navigation]), + ); +} diff --git a/app/src/hooks/useSelfAppData.ts b/app/src/hooks/useSelfAppData.ts new file mode 100644 index 000000000..913662759 --- /dev/null +++ b/app/src/hooks/useSelfAppData.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useMemo } from 'react'; + +import type { SelfApp } from '@selfxyz/common'; +import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType'; +import { formatEndpoint } from '@selfxyz/common/utils/scope'; + +import { getDisclosureItems } from '@/utils/disclosureUtils'; +import { formatUserId } from '@/utils/formatUserId'; + +/** + * Hook that extracts and transforms SelfApp data for use in UI components. + * Returns memoized values for logo source, URL, formatted user ID, and disclosure items. + */ +export function useSelfAppData(selfApp: SelfApp | null) { + const logoSource = useMemo(() => { + if (!selfApp?.logoBase64) { + return null; + } + + // Check if the logo is already a URL + if ( + selfApp.logoBase64.startsWith('http://') || + selfApp.logoBase64.startsWith('https://') + ) { + return { uri: selfApp.logoBase64 }; + } + + // Otherwise handle as base64 + const base64String = selfApp.logoBase64.startsWith('data:image') + ? selfApp.logoBase64 + : `data:image/png;base64,${selfApp.logoBase64}`; + return { uri: base64String }; + }, [selfApp?.logoBase64]); + + const url = useMemo(() => { + if (!selfApp?.endpoint) { + return null; + } + return formatEndpoint(selfApp.endpoint); + }, [selfApp?.endpoint]); + + const formattedUserId = useMemo( + () => formatUserId(selfApp?.userId, selfApp?.userIdType), + [selfApp?.userId, selfApp?.userIdType], + ); + + const disclosureItems = useMemo(() => { + const disclosures = (selfApp?.disclosures as SelfAppDisclosureConfig) || {}; + return getDisclosureItems(disclosures); + }, [selfApp?.disclosures]); + + return { + logoSource, + url, + formattedUserId, + disclosureItems, + }; +} diff --git a/app/src/integrations/nfc/nfcScanner.ts b/app/src/integrations/nfc/nfcScanner.ts index 2cfdb245a..e356d5392 100644 --- a/app/src/integrations/nfc/nfcScanner.ts +++ b/app/src/integrations/nfc/nfcScanner.ts @@ -27,6 +27,7 @@ interface Inputs { usePacePolling?: boolean; sessionId: string; userId?: string; + skipReselect?: boolean; } interface DataGroupHash { @@ -91,6 +92,7 @@ const scanAndroid = async ( canNumber: inputs.canNumber ?? '', useCan: inputs.useCan ?? false, sessionId: inputs.sessionId, + skipReselect: inputs.skipReselect ?? false, }); }; diff --git a/app/src/integrations/nfc/passportReader.ts b/app/src/integrations/nfc/passportReader.ts index 8449a6e94..d78733414 100644 --- a/app/src/integrations/nfc/passportReader.ts +++ b/app/src/integrations/nfc/passportReader.ts @@ -16,6 +16,7 @@ type ScanOptions = { usePacePolling?: boolean; sessionId?: string; quality?: number; + skipReselect?: boolean; }; export interface AndroidScanResponse { @@ -91,6 +92,8 @@ if (Platform.OS === 'android') { canNumber = '', useCan = false, quality = 1, + skipReselect = false, + sessionId, } = options; return androidScan({ @@ -100,6 +103,8 @@ if (Platform.OS === 'android') { canNumber, useCan, quality, + skipReselect, + sessionId, }); }; } diff --git a/app/src/navigation/account.ts b/app/src/navigation/account.ts index 7b1656dd9..93f4f8f60 100644 --- a/app/src/navigation/account.ts +++ b/app/src/navigation/account.ts @@ -16,6 +16,7 @@ import AccountRecoveryScreen from '@/screens/account/recovery/AccountRecoveryScr import DocumentDataNotFoundScreen from '@/screens/account/recovery/DocumentDataNotFoundScreen'; import RecoverWithPhraseScreen from '@/screens/account/recovery/RecoverWithPhraseScreen'; import CloudBackupScreen from '@/screens/account/settings/CloudBackupScreen'; +import { ProofSettingsScreen } from '@/screens/account/settings/ProofSettingsScreen'; import SettingsScreen from '@/screens/account/settings/SettingsScreen'; import ShowRecoveryPhraseScreen from '@/screens/account/settings/ShowRecoveryPhraseScreen'; import { IS_EUCLID_ENABLED } from '@/utils/devUtils'; @@ -65,6 +66,18 @@ const accountScreens = { }, } as NativeStackNavigationOptions, }, + ProofSettings: { + screen: ProofSettingsScreen, + options: { + title: 'Proof Settings', + headerStyle: { + backgroundColor: white, + }, + headerTitleStyle: { + color: black, + }, + } as NativeStackNavigationOptions, + }, Settings: { screen: SettingsScreen, options: { diff --git a/app/src/navigation/deeplinks.ts b/app/src/navigation/deeplinks.ts index dc1cac680..b2d803644 100644 --- a/app/src/navigation/deeplinks.ts +++ b/app/src/navigation/deeplinks.ts @@ -9,6 +9,7 @@ import { countries } from '@selfxyz/common/constants/countries'; import type { IdDocInput } from '@selfxyz/common/utils'; import type { SelfClient } from '@selfxyz/mobile-sdk-alpha'; +import type { RootStackParamList } from '@/navigation'; import { navigationRef } from '@/navigation'; import useUserStore from '@/stores/userStore'; import { IS_DEV_MODE } from '@/utils/devUtils'; @@ -108,6 +109,28 @@ export const getAndClearQueuedUrl = (): string | null => { return url; }; +const safeNavigate = ( + navigationState: ReturnType, +): void => { + const targetScreen = navigationState.routes[1]?.name as + | keyof RootStackParamList + | undefined; + + const currentRoute = navigationRef.getCurrentRoute(); + const isColdLaunch = currentRoute?.name === 'Splash'; + + if (!isColdLaunch && targetScreen) { + // Use object syntax to satisfy TypeScript's strict typing for navigate + // The params will be undefined for screens that don't require them + navigationRef.navigate({ + name: targetScreen, + params: undefined, + } as Parameters[0]); + } else { + navigationRef.reset(navigationState); + } +}; + export const handleUrl = (selfClient: SelfClient, uri: string) => { const validatedParams = parseAndValidateUrlParams(uri); const { @@ -125,8 +148,11 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { selfClient.getSelfAppState().setSelfApp(selfAppJson); selfClient.getSelfAppState().startAppListener(selfAppJson.sessionId); - navigationRef.reset( - createDeeplinkNavigationState('Prove', correctParentScreen), + safeNavigate( + createDeeplinkNavigationState( + 'ProvingScreenRouter', + correctParentScreen, + ), ); return; @@ -134,7 +160,7 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { if (IS_DEV_MODE) { console.error('Error parsing selfApp:', error); } - navigationRef.reset( + safeNavigate( createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen), ); } @@ -142,8 +168,8 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { selfClient.getSelfAppState().cleanSelfApp(); selfClient.getSelfAppState().startAppListener(sessionId); - navigationRef.reset( - createDeeplinkNavigationState('Prove', correctParentScreen), + safeNavigate( + createDeeplinkNavigationState('ProvingScreenRouter', correctParentScreen), ); } else if (mock_passport) { try { @@ -172,25 +198,26 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { }); // Reset navigation stack with correct parent -> MockDataDeepLink - navigationRef.reset( + safeNavigate( createDeeplinkNavigationState('MockDataDeepLink', correctParentScreen), ); } catch (error) { if (IS_DEV_MODE) { console.error('Error parsing mock_passport data or navigating:', error); } - navigationRef.reset( + safeNavigate( createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen), ); } } else if (referrer && typeof referrer === 'string') { useUserStore.getState().setDeepLinkReferrer(referrer); - // Navigate to HomeScreen - it will show confirmation modal and then navigate to GratificationScreen - navigationRef.reset({ - index: 0, - routes: [{ name: 'Home' }], - }); + const currentRoute = navigationRef.getCurrentRoute(); + if (currentRoute?.name === 'Home') { + // Already on Home, no navigation needed - the modal will show automatically + } else { + safeNavigate(createDeeplinkNavigationState('Home', 'Home')); + } } else if (Platform.OS === 'web') { // TODO: web handle links if we need to idk if we do // For web, we can handle the URL some other way if we dont do this loading app in web always navigates to QRCodeTrouble @@ -208,7 +235,7 @@ export const handleUrl = (selfClient: SelfClient, uri: string) => { 'No sessionId, selfApp or valid OAuth parameters found in the data', ); } - navigationRef.reset( + safeNavigate( createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen), ); } diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index 452917e4f..67273f933 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -27,10 +27,11 @@ import documentsScreens from '@/navigation/documents'; import homeScreens from '@/navigation/home'; import onboardingScreens from '@/navigation/onboarding'; import sharedScreens from '@/navigation/shared'; +import starfallScreens from '@/navigation/starfall'; import verificationScreens from '@/navigation/verification'; import type { ModalNavigationParams } from '@/screens/app/ModalScreen'; import type { WebViewScreenParams } from '@/screens/shared/WebViewScreen'; -import analytics from '@/services/analytics'; +import { trackScreenView } from '@/services/analytics'; import type { ProofHistory } from '@/stores/proofTypes'; export const navigationScreens = { @@ -41,6 +42,7 @@ export const navigationScreens = { ...verificationScreens, ...accountScreens, ...sharedScreens, + ...starfallScreens, ...devScreens, // allow in production for testing }; @@ -71,6 +73,8 @@ export type RootStackParamList = Omit< | 'Disclaimer' | 'DocumentNFCScan' | 'DocumentOnboarding' + | 'DocumentSelectorForProving' + | 'ProvingScreenRouter' | 'Gratification' | 'Home' | 'IDPicker' @@ -140,13 +144,24 @@ export type RootStackParamList = Omit< returnToScreen?: 'Points'; } | undefined; + ProofSettings: undefined; AccountVerifiedSuccess: undefined; // Proof/Verification screens ProofHistoryDetail: { data: ProofHistory; }; - Prove: undefined; + Prove: + | { + scrollOffset?: number; + } + | undefined; + ProvingScreenRouter: undefined; + DocumentSelectorForProving: + | { + documentType?: string; + } + | undefined; // App screens Loading: { @@ -158,6 +173,7 @@ export type RootStackParamList = Omit< Gratification: { points?: number; }; + StarfallPushCode: undefined; // Home screens Home: { @@ -195,7 +211,6 @@ declare global { } } -const { trackScreenView } = analytics(); const Navigation = createStaticNavigation(AppNavigation); const NavigationWithTracking = () => { diff --git a/app/src/navigation/starfall.ts b/app/src/navigation/starfall.ts new file mode 100644 index 000000000..6de84c5cf --- /dev/null +++ b/app/src/navigation/starfall.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import type { NativeStackNavigationOptions } from '@react-navigation/native-stack'; + +import StarfallPushCodeScreen from '@/screens/starfall/StarfallPushCodeScreen'; + +const starfallScreens = { + StarfallPushCode: { + screen: StarfallPushCodeScreen, + options: { + headerShown: false, + } as NativeStackNavigationOptions, + }, +}; + +export default starfallScreens; diff --git a/app/src/navigation/verification.ts b/app/src/navigation/verification.ts index 24492bdfc..549986362 100644 --- a/app/src/navigation/verification.ts +++ b/app/src/navigation/verification.ts @@ -6,11 +6,30 @@ import type { NativeStackNavigationOptions } from '@react-navigation/native-stac import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { DocumentSelectorForProvingScreen } from '@/screens/verification/DocumentSelectorForProvingScreen'; import ProofRequestStatusScreen from '@/screens/verification/ProofRequestStatusScreen'; import ProveScreen from '@/screens/verification/ProveScreen'; +import { ProvingScreenRouter } from '@/screens/verification/ProvingScreenRouter'; import QRCodeTroubleScreen from '@/screens/verification/QRCodeTroubleScreen'; import QRCodeViewFinderScreen from '@/screens/verification/QRCodeViewFinderScreen'; +/** + * Shared header configuration for proof request screens + */ +const proofRequestHeaderOptions: NativeStackNavigationOptions = { + title: 'Proof Requested', + headerStyle: { + backgroundColor: black, + }, + headerTitleStyle: { + color: white, + fontWeight: '600', + }, + headerTintColor: white, + gestureEnabled: false, + animation: 'none', +}; + const verificationScreens = { ProofRequestStatus: { screen: ProofRequestStatusScreen, @@ -20,18 +39,17 @@ const verificationScreens = { gestureEnabled: false, } as NativeStackNavigationOptions, }, + ProvingScreenRouter: { + screen: ProvingScreenRouter, + options: proofRequestHeaderOptions, + }, + DocumentSelectorForProving: { + screen: DocumentSelectorForProvingScreen, + options: proofRequestHeaderOptions, + }, Prove: { screen: ProveScreen, - options: { - title: 'Request Proof', - headerStyle: { - backgroundColor: black, - }, - headerTitleStyle: { - color: white, - }, - gestureEnabled: false, - } as NativeStackNavigationOptions, + options: proofRequestHeaderOptions, }, QRCodeTrouble: { screen: QRCodeTroubleScreen, diff --git a/app/src/providers/authProvider.tsx b/app/src/providers/authProvider.tsx index 72a5db305..bfcdf8023 100644 --- a/app/src/providers/authProvider.tsx +++ b/app/src/providers/authProvider.tsx @@ -22,11 +22,14 @@ import { createKeychainOptions, detectSecurityCapabilities, } from '@/integrations/keychain'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; import { useSettingStore } from '@/stores/settingStore'; import type { Mnemonic } from '@/types/mnemonic'; - -const { trackEvent } = analytics(); +import { + getKeychainErrorIdentity, + isKeychainCryptoError, + isUserCancellation, +} from '@/utils/keychainErrors'; const SERVICE_NAME = 'secret'; @@ -149,31 +152,73 @@ async function restoreFromMnemonic( } } +let keychainCryptoFailureCallback: + | ((errorType: 'user_cancelled' | 'crypto_failed') => void) + | null = null; + async function loadOrCreateMnemonic( keychainOptions: KeychainOptions, ): Promise { // Get adaptive security configuration const { setOptions, getOptions } = keychainOptions; - const storedMnemonic = await Keychain.getGenericPassword({ - ...getOptions, - service: SERVICE_NAME, - }); - if (storedMnemonic) { - try { - JSON.parse(storedMnemonic.password); - trackEvent(AuthEvents.MNEMONIC_LOADED); - return storedMnemonic.password; - } catch (e: unknown) { - console.error( - 'Error parsing stored mnemonic, old secret format was used', - e, - ); - trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, { - reason: 'unknown_error', - error: e instanceof Error ? e.message : String(e), - }); + try { + const storedMnemonic = await Keychain.getGenericPassword({ + ...getOptions, + service: SERVICE_NAME, + }); + if (storedMnemonic) { + try { + JSON.parse(storedMnemonic.password); + trackEvent(AuthEvents.MNEMONIC_LOADED); + return storedMnemonic.password; + } catch (e: unknown) { + console.error( + 'Error parsing stored mnemonic, old secret format was used', + e, + ); + trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, { + reason: 'unknown_error', + error: e instanceof Error ? e.message : String(e), + }); + } } + } catch (error: unknown) { + if (isUserCancellation(error)) { + console.log('User cancelled authentication'); + trackEvent(AuthEvents.BIOMETRIC_LOGIN_CANCELLED); + + if (keychainCryptoFailureCallback) { + keychainCryptoFailureCallback('user_cancelled'); + } + + throw error; + } + + if (isKeychainCryptoError(error)) { + const err = getKeychainErrorIdentity(error); + console.error('Keychain crypto error:', { + code: err?.code, + name: err?.name, + }); + trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, { + reason: 'keychain_crypto_failed', + errorCode: err?.code, + }); + + if (keychainCryptoFailureCallback) { + keychainCryptoFailureCallback('crypto_failed'); + } + + throw error; + } + + console.error('Error loading mnemonic:', error); + trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, { + reason: 'unknown_error', + error: error instanceof Error ? error.message : String(error), + }); + throw error; } try { const { mnemonic } = ethers.HDNodeWallet.fromMnemonic( @@ -426,6 +471,13 @@ export async function migrateToSecureKeychain(): Promise { } } +// Global callback for keychain crypto failures +export function setKeychainCryptoFailureCallback( + callback: ((errorType: 'user_cancelled' | 'crypto_failed') => void) | null, +) { + keychainCryptoFailureCallback = callback; +} + export async function unsafe_clearSecrets() { if (__DEV__) { await Keychain.resetGenericPassword({ service: SERVICE_NAME }); @@ -458,10 +510,6 @@ export async function unsafe_getPointsPrivateKey( return wallet.privateKey; } -/** - * The only reason this is exported without being locked behind user biometrics is to allow `loadPassportDataAndSecret` - * to access both the privatekey and the passport data with the user only authenticating once - */ export async function unsafe_getPrivateKey(keychainOptions?: KeychainOptions) { const options = keychainOptions || diff --git a/app/src/providers/authProvider.web.tsx b/app/src/providers/authProvider.web.tsx index 6c19f8650..34004a220 100644 --- a/app/src/providers/authProvider.web.tsx +++ b/app/src/providers/authProvider.web.tsx @@ -18,11 +18,9 @@ import React, { import { AuthEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; import type { Mnemonic } from '@/types/mnemonic'; -const { trackEvent } = analytics(); - type SignedPayload = { signature: string; data: T }; // Check if Android bridge is available diff --git a/app/src/providers/notificationTrackingProvider.tsx b/app/src/providers/notificationTrackingProvider.tsx index 8746fb55f..a4346c8e1 100644 --- a/app/src/providers/notificationTrackingProvider.tsx +++ b/app/src/providers/notificationTrackingProvider.tsx @@ -8,9 +8,7 @@ import messaging from '@react-native-firebase/messaging'; import { NotificationEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; -import analytics from '@/services/analytics'; - -const { trackEvent } = analytics(); +import { trackEvent } from '@/services/analytics'; export const NotificationTrackingProvider: React.FC = ({ children, diff --git a/app/src/providers/passportDataProvider.tsx b/app/src/providers/passportDataProvider.tsx index 8ce305cff..dffcab240 100644 --- a/app/src/providers/passportDataProvider.tsx +++ b/app/src/providers/passportDataProvider.tsx @@ -67,6 +67,59 @@ import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { createKeychainOptions } from '@/integrations/keychain'; import { unsafe_getPrivateKey, useAuth } from '@/providers/authProvider'; +import type { KeychainErrorType } from '@/utils/keychainErrors'; +import { + getKeychainErrorIdentity, + isKeychainCryptoError, + isUserCancellation, +} from '@/utils/keychainErrors'; + +let keychainCryptoFailureCallback: + | ((errorType: 'user_cancelled' | 'crypto_failed') => void) + | null = null; + +export function setPassportKeychainErrorCallback( + callback: ((errorType: 'user_cancelled' | 'crypto_failed') => void) | null, +) { + keychainCryptoFailureCallback = callback; +} + +function notifyKeychainFailure(type: KeychainErrorType) { + if (keychainCryptoFailureCallback) { + keychainCryptoFailureCallback(type); + } +} + +function handleKeychainReadError({ + contextLabel, + error, + throwOnUserCancel = false, +}: { + contextLabel: string; + error: unknown; + throwOnUserCancel?: boolean; +}) { + if (isUserCancellation(error)) { + console.log(`User cancelled authentication for ${contextLabel}`); + notifyKeychainFailure('user_cancelled'); + + if (throwOnUserCancel) { + throw error; + } + } + + if (isKeychainCryptoError(error)) { + const err = getKeychainErrorIdentity(error); + console.error(`Keychain crypto error loading ${contextLabel}:`, { + code: err?.code, + name: err?.name, + }); + + notifyKeychainFailure('crypto_failed'); + } + + console.log(`Error loading ${contextLabel}:`, error); +} // Create safe wrapper functions to prevent undefined errors during early initialization // These need to be declared early to avoid dependency issues @@ -447,7 +500,10 @@ export async function loadDocumentByIdDirectlyFromKeychain( return JSON.parse(documentCreds.password); } } catch (error) { - console.log(`Error loading document ${documentId}:`, error); + handleKeychainReadError({ + contextLabel: `document ${documentId}`, + error, + }); } return null; } @@ -491,7 +547,11 @@ export async function loadDocumentCatalogDirectlyFromKeychain(): Promise { } } }, + enableKeychainErrorModal, + disableKeychainErrorModal, }, crypto: { async hash( @@ -130,7 +142,7 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { }, analytics: { trackEvent: (event: string, data?: TrackEventParams) => { - analytics().trackEvent(event, data); + trackEvent(event, data); }, trackNfcEvent: (name: string, data?: Record) => { trackNfcEvent(name, data); @@ -211,21 +223,21 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { if (fcmToken) { try { - analytics().trackEvent('DEVICE_TOKEN_REG_STARTED'); + trackEvent('DEVICE_TOKEN_REG_STARTED'); logProofEvent('info', 'Device token registration started', context); const { registerDeviceToken: registerFirebaseDeviceToken } = await import('@/services/notifications/notificationService'); await registerFirebaseDeviceToken(uuid, fcmToken, isMock); - analytics().trackEvent('DEVICE_TOKEN_REG_SUCCESS'); + trackEvent('DEVICE_TOKEN_REG_SUCCESS'); logProofEvent('info', 'Device token registration success', context); } catch (error) { logProofEvent('warn', 'Device token registration failed', context, { error: error instanceof Error ? error.message : String(error), }); console.error('Error registering device token:', error); - analytics().trackEvent('DEVICE_TOKEN_REG_FAILED', { + trackEvent('DEVICE_TOKEN_REG_FAILED', { message: error instanceof Error ? error.message : String(error), }); } @@ -322,4 +334,56 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { ); }; +export function disableKeychainErrorModal() { + setKeychainCryptoFailureCallback(null); + setPassportKeychainErrorCallback(null); +} + +// Functions to enable/disable keychain error modals +// These should be called by the provingMachine when entering/exiting proving flows +export function enableKeychainErrorModal() { + setKeychainCryptoFailureCallback(showKeychainErrorModal); + setPassportKeychainErrorCallback(showKeychainErrorModal); +} + +export function showKeychainErrorModal( + errorType: 'user_cancelled' | 'crypto_failed', +) { + if (Platform.OS !== 'android') return; + if (!navigationRef.isReady()) return; + + const errorContent = { + user_cancelled: { + titleText: 'Authentication Required', + bodyText: + 'You need to authenticate with your fingerprint, PIN or faceID to continue the verification process. Please try again.', + buttonText: 'Try Again', + }, + crypto_failed: { + titleText: 'Keychain Error', + bodyText: + 'Unable to access your keychain. This may happen if your device security settings have changed or if the encrypted data was corrupted. Please contact support if the issue persists.', + buttonText: 'Go to Home', + }, + }; + + const content = errorContent[errorType]; + + const callbackId = registerModalCallbacks({ + onButtonPress: () => { + unregisterModalCallbacks(callbackId); + navigationRef.navigate({ name: 'Home', params: {} }); + }, + onModalDismiss: () => { + unregisterModalCallbacks(callbackId); + navigationRef.navigate({ name: 'Home', params: {} }); + }, + }); + + navigationRef.navigate('Modal', { + ...content, + callbackId, + }); +} + export default SelfClientProvider; diff --git a/app/src/proving/validateDocument.ts b/app/src/proving/validateDocument.ts index 180c73d08..8549f8d97 100644 --- a/app/src/proving/validateDocument.ts +++ b/app/src/proving/validateDocument.ts @@ -27,9 +27,7 @@ import { storePassportData, updateDocumentRegistrationState, } from '@/providers/passportDataProvider'; -import analytics from '@/services/analytics'; - -const { trackEvent } = analytics(); +import { trackEvent } from '@/services/analytics'; /** * This function checks and updates registration states for all documents and updates the `isRegistered`. diff --git a/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx b/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx index 017d926e3..45a4a6a20 100644 --- a/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx +++ b/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx @@ -21,9 +21,7 @@ import { import useHapticNavigation from '@/hooks/useHapticNavigation'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; -import analytics from '@/services/analytics'; - -const { flush: flushAnalytics } = analytics(); +import { flush as flushAnalytics } from '@/services/analytics'; const DocumentDataNotFoundScreen: React.FC = () => { const selfClient = useSelfClient(); diff --git a/app/src/screens/account/settings/ProofSettingsScreen.tsx b/app/src/screens/account/settings/ProofSettingsScreen.tsx new file mode 100644 index 000000000..2227a5be9 --- /dev/null +++ b/app/src/screens/account/settings/ProofSettingsScreen.tsx @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { StyleSheet, Switch, Text, View } from 'react-native'; +import { ScrollView, YStack } from 'tamagui'; + +import { + black, + blue600, + slate200, + slate500, + white, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { useSettingStore } from '@/stores/settingStore'; + +const ProofSettingsScreen: React.FC = () => { + const { + skipDocumentSelector, + setSkipDocumentSelector, + skipDocumentSelectorIfSingle, + setSkipDocumentSelectorIfSingle, + } = useSettingStore(); + + return ( + + + + Document Selection + + + + + Always skip document selection + + + Go directly to proof generation using your previously selected + or first available document + + + + + + + + + + + Skip when only one document + + + Automatically select your document when you only have one valid + ID available + + + + + + {skipDocumentSelector && ( + + Document selection is always skipped. The "Skip when only one + document" setting has no effect. + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + sectionTitle: { + fontSize: 14, + fontFamily: dinot, + fontWeight: '600', + color: slate500, + textTransform: 'uppercase', + letterSpacing: 1, + }, + settingRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + }, + settingTextContainer: { + flex: 1, + gap: 4, + }, + settingLabel: { + fontSize: 16, + fontFamily: dinot, + fontWeight: '500', + color: black, + }, + settingDescription: { + fontSize: 14, + fontFamily: dinot, + color: slate500, + }, + divider: { + height: 1, + backgroundColor: slate200, + }, + infoText: { + fontSize: 13, + fontFamily: dinot, + fontStyle: 'italic', + color: slate500, + paddingHorizontal: 4, + }, +}); + +export { ProofSettingsScreen }; diff --git a/app/src/screens/account/settings/SettingsScreen.tsx b/app/src/screens/account/settings/SettingsScreen.tsx index b8e76452d..cb53ccce3 100644 --- a/app/src/screens/account/settings/SettingsScreen.tsx +++ b/app/src/screens/account/settings/SettingsScreen.tsx @@ -12,7 +12,7 @@ import type { SvgProps } from 'react-native-svg'; import { Button, ScrollView, View, XStack, YStack } from 'tamagui'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { Bug, FileText } from '@tamagui/lucide-icons'; +import { Bug, FileText, Settings2 } from '@tamagui/lucide-icons'; import { BodyText, pressedStyle } from '@selfxyz/mobile-sdk-alpha/components'; import { @@ -78,6 +78,7 @@ const routes = [Data, 'View document info', 'DocumentDataInfo'], [Lock, 'Reveal recovery phrase', 'ShowRecoveryPhrase'], [Cloud, 'Cloud backup', 'CloudBackupSettings'], + [Settings2 as React.FC, 'Proof settings', 'ProofSettings'], [Feedback, 'Send feedback', 'email_feedback'], [ShareIcon, 'Share Self app', 'share'], [ @@ -88,6 +89,7 @@ const routes = ] satisfies [React.FC, string, RouteOption][]) : ([ [Data, 'View document info', 'DocumentDataInfo'], + [Settings2 as React.FC, 'Proof settings', 'ProofSettings'], [Feedback, 'Send feeback', 'email_feedback'], [ FileText as React.FC, @@ -105,10 +107,10 @@ const DEBUG_MENU: [React.FC, string, RouteOption][] = [ ]; const DOCUMENT_DEPENDENT_ROUTES: RouteOption[] = [ - 'CloudBackupSettings', 'DocumentDataInfo', 'ShowRecoveryPhrase', ]; +const CLOUD_BACKUP_ROUTE: RouteOption = 'CloudBackupSettings'; const social = [ [X, xUrl], @@ -185,16 +187,20 @@ const SettingsScreen: React.FC = () => { const screenRoutes = useMemo(() => { const baseRoutes = isDevMode ? [...routes, ...DEBUG_MENU] : routes; + const shouldHideCloudBackup = Platform.OS === 'android'; + const hasConfirmedRealDocument = hasRealDocument === true; - // Show all routes while loading or if user has a real document - if (hasRealDocument === null || hasRealDocument === true) { - return baseRoutes; - } + return baseRoutes.filter(([, , route]) => { + if (DOCUMENT_DEPENDENT_ROUTES.includes(route)) { + return hasConfirmedRealDocument; + } - // Only filter out document-related routes if we've confirmed user has no real documents - return baseRoutes.filter( - ([, , route]) => !DOCUMENT_DEPENDENT_ROUTES.includes(route), - ); + if (shouldHideCloudBackup && route === CLOUD_BACKUP_ROUTE) { + return hasConfirmedRealDocument; + } + + return true; + }); }, [hasRealDocument, isDevMode]); const devModeTap = Gesture.Tap() diff --git a/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx b/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx index 54b6adef8..33a0ae2bf 100644 --- a/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx +++ b/app/src/screens/account/settings/ShowRecoveryPhraseScreen.tsx @@ -15,6 +15,7 @@ import Mnemonic from '@/components/Mnemonic'; import useMnemonic from '@/hooks/useMnemonic'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import { useSettingStore } from '@/stores/settingStore'; +import { getRecoveryPhraseWarningMessage } from '@/utils/crypto/mnemonic'; import { IS_EUCLID_ENABLED } from '@/utils/devUtils'; function useCopyRecoveryPhrase(mnemonic: string[] | undefined) { @@ -89,10 +90,7 @@ const ShowRecoveryPhraseScreen: React.FC & { gap={20} > - - This phrase is the only way to recover your account. Keep it secret, - keep it safe. - + {getRecoveryPhraseWarningMessage()} ); diff --git a/app/src/screens/app/GratificationScreen.tsx b/app/src/screens/app/GratificationScreen.tsx index cecd21144..e9c378260 100644 --- a/app/src/screens/app/GratificationScreen.tsx +++ b/app/src/screens/app/GratificationScreen.tsx @@ -26,8 +26,8 @@ import { } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import { dinot, dinotBold } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; -import LogoWhite from '@/assets/icons/logo_white.svg'; import GratificationBg from '@/assets/images/gratification_bg.svg'; +import SelfLogo from '@/assets/logos/self.svg'; import type { RootStackParamList } from '@/navigation'; const GratificationScreen: React.FC = () => { @@ -160,7 +160,7 @@ const GratificationScreen: React.FC = () => { > {/* Logo icon */} - + {/* Points display */} diff --git a/app/src/screens/dev/DevSettingsScreen.tsx b/app/src/screens/dev/DevSettingsScreen.tsx index c47655a62..8eba89e13 100644 --- a/app/src/screens/dev/DevSettingsScreen.tsx +++ b/app/src/screens/dev/DevSettingsScreen.tsx @@ -11,8 +11,8 @@ import React, { useState, } from 'react'; import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; -import { Alert, ScrollView } from 'react-native'; -import { Adapt, Button, Select, Sheet, Text, XStack, YStack } from 'tamagui'; +import { Alert, ScrollView, TouchableOpacity } from 'react-native'; +import { Button, Sheet, Text, XStack, YStack } from 'tamagui'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { Check, ChevronDown, ChevronRight } from '@tamagui/lucide-icons'; @@ -33,7 +33,6 @@ import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks'; import BugIcon from '@/assets/icons/bug_icon.svg'; -import IdIcon from '@/assets/icons/id_icon.svg'; import WarningIcon from '@/assets/icons/warning.svg'; import type { RootStackParamList } from '@/navigation'; import { navigationScreens } from '@/navigation'; @@ -187,82 +186,207 @@ function ParameterSection({ const ScreenSelector = ({}) => { const navigation = useNavigation(); const [open, setOpen] = useState(false); + + const screenList = useMemo( + () => + ( + Object.keys(navigationScreens) as (keyof typeof navigationScreens)[] + ).sort(), + [], + ); + return ( - +const LogLevelSelector = ({ + currentLevel, + onSelect, +}: { + currentLevel: string; + onSelect: (level: 'debug' | 'info' | 'warn' | 'error') => void; +}) => { + const [open, setOpen] = useState(false); + + const logLevels = ['debug', 'info', 'warn', 'error'] as const; + + return ( + <> + + + + + + + + + Select log level + + + + + {logLevels.map(level => ( + { + setOpen(false); + onSelect(level); + }} + > + + + {level.toUpperCase()} + + {currentLevel === level && ( + + )} + + + ))} + + + + + ); }; @@ -526,57 +650,6 @@ const DevSettingsScreen: React.FC = ({}) => { paddingTop="$4" paddingBottom={paddingBottom} > - } - title="Manage ID Documents" - description="Register new IDs and generate test IDs" - > - {[ - { - label: 'Manage available IDs', - onPress: () => { - navigation.navigate('ManageDocuments'); - }, - }, - { - label: 'Generate Test ID', - onPress: () => { - navigation.navigate('CreateMock'); - }, - }, - { - label: 'Scan new ID Document', - onPress: () => { - navigation.navigate('DocumentOnboarding'); - }, - }, - ].map(({ label, onPress }) => ( - - - - ))} - - } title="Debug Shortcuts" @@ -642,11 +715,11 @@ const DevSettingsScreen: React.FC = ({}) => { > handleTopicToggle(['nova'], 'Nova')} + onToggle={() => handleTopicToggle(['nova'], 'Starfall')} /> = ({}) => { onToggle={() => handleTopicToggle(['general'], 'General')} /> = ({}) => { title="Log Level" description="Configure logging verbosity" > - - {(['debug', 'info', 'warn', 'error'] as const).map(level => ( - - ))} - + { + const navigation = + useNavigation>(); const selfClient = useSelfClient(); const { loadDocumentCatalog, @@ -73,7 +75,20 @@ const PassportDataSelector = () => { loadPassportDataInfo(); }, [loadPassportDataInfo]); - const handleDocumentSelection = async (documentId: string) => { + const handleDocumentSelection = async ( + documentId: string, + isRegistered: boolean | undefined, + ) => { + if (!isRegistered) { + Alert.alert( + 'Document not registered', + 'This document cannot be selected as active, because it is not registered. Click the add button next to it to register it first.', + [{ text: 'OK', style: 'cancel' }], + ); + + return; + } + await setSelectedDocument(documentId); // Reload to update UI without loading state for quick operations const catalog = await loadDocumentCatalog(); @@ -90,24 +105,40 @@ const PassportDataSelector = () => { await loadPassportDataInfo(); }; - const handleDeleteButtonPress = (documentId: string) => { - Alert.alert( - 'โš ๏ธ Delete Document โš ๏ธ', - 'Are you sure you want to delete this document?\n\nThis document is already linked to your identity in Self Protocol and cannot be linked by another person.', - [ - { - text: 'Cancel', - style: 'cancel', + const handleRegisterDocument = async (documentId: string) => { + try { + await setSelectedDocument(documentId); + navigation.navigate('ConfirmBelonging', {}); + } catch { + Alert.alert( + 'Registration Error', + 'Failed to prepare document for registration. Please try again.', + [{ text: 'OK', style: 'cancel' }], + ); + } + }; + + const handleDeleteButtonPress = ( + documentId: string, + isRegistered: boolean | undefined, + ) => { + const message = isRegistered + ? 'Are you sure you want to delete this document?\n\nThis document is already linked to your identity in Self Protocol and cannot be linked by another person.' + : 'Are you sure you want to delete this document?'; + + Alert.alert('โš ๏ธ Delete Document โš ๏ธ', message, [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + await handleDeleteSpecific(documentId); }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - await handleDeleteSpecific(documentId); - }, - }, - ], - ); + }, + ]); }; const getDisplayName = (documentType: string): string => { @@ -156,6 +187,16 @@ const PassportDataSelector = () => { } }; + const getDocumentBackgroundColor = ( + isSelected: boolean, + isRegistered: boolean | undefined, + ): string => { + if (!isRegistered) { + return '#ffebee'; // Light red for unregistered documents + } + return isSelected ? '$gray2' : 'white'; + }; + if (loading) { return ( @@ -196,6 +237,10 @@ const PassportDataSelector = () => { ); } + const hasUnregisteredDocuments = documentCatalog.documents.some( + doc => !doc.isRegistered, + ); + return ( { > Available Documents + {hasUnregisteredDocuments && ( + + + โš ๏ธ We've detected some documents that are not registered. In order + to use them, you'll have to register them first by clicking the plus + icon next to them. + + + )} {documentCatalog.documents.map((metadata: DocumentMetadata) => ( { : borderColor } borderRadius="$3" - backgroundColor={ - documentCatalog.selectedDocumentId === metadata.id - ? '$gray2' - : 'white' + backgroundColor={getDocumentBackgroundColor( + documentCatalog.selectedDocumentId === metadata.id, + metadata.isRegistered, + )} + onPress={() => + handleDocumentSelection(metadata.id, metadata.isRegistered) } - onPress={() => handleDocumentSelection(metadata.id)} pressStyle={{ opacity: 0.8 }} > { } borderColor={textBlack} borderWidth={1} - onPress={() => handleDocumentSelection(metadata.id)} + onPress={() => + handleDocumentSelection(metadata.id, metadata.isRegistered) + } > {documentCatalog.selectedDocumentId === metadata.id && ( @@ -256,19 +319,36 @@ const PassportDataSelector = () => { - + + {metadata.isRegistered !== true && ( + + )} + + ))} diff --git a/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx b/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx index 5534819b8..e9d0a2c10 100644 --- a/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx +++ b/app/src/screens/documents/scanning/DocumentCameraTroubleScreen.tsx @@ -16,9 +16,7 @@ import type { TipProps } from '@/components/Tips'; import Tips from '@/components/Tips'; import useHapticNavigation from '@/hooks/useHapticNavigation'; import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout'; -import analytics from '@/services/analytics'; - -const { flush: flushAnalytics } = analytics(); +import { flush as flushAnalytics } from '@/services/analytics'; const tips: TipProps[] = [ { diff --git a/app/src/screens/documents/scanning/DocumentNFCMethodSelectionScreen.tsx b/app/src/screens/documents/scanning/DocumentNFCMethodSelectionScreen.tsx index fa73a9cbe..317992b4f 100644 --- a/app/src/screens/documents/scanning/DocumentNFCMethodSelectionScreen.tsx +++ b/app/src/screens/documents/scanning/DocumentNFCMethodSelectionScreen.tsx @@ -39,6 +39,13 @@ const NFC_METHODS = [ platform: ['ios'], params: {}, }, + { + key: 'skipReselect', + label: 'Skip Re-select', + description: 'Skip the re-select step after the NFC scan.', + platform: ['android'], + params: { skipReselect: true }, + }, { key: 'usePacePolling', label: 'Use PACE Polling', diff --git a/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx b/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx index c092573ff..7b5b3cb47 100644 --- a/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx +++ b/app/src/screens/documents/scanning/DocumentNFCScanScreen.tsx @@ -81,6 +81,7 @@ const emitter = : null; type DocumentNFCScanRouteParams = { + skipReselect?: boolean; usePacePolling?: boolean; canNumber?: string; useCan?: boolean; @@ -326,8 +327,14 @@ const DocumentNFCScanScreen: React.FC = () => { }, 30000); try { - const { canNumber, useCan, skipPACE, skipCA, extendedMode } = - route.params ?? {}; + const { + canNumber, + useCan, + skipPACE, + skipCA, + extendedMode, + skipReselect, + } = route.params ?? {}; await configureNfcAnalytics(); const scanResponse = await scan({ @@ -341,6 +348,7 @@ const DocumentNFCScanScreen: React.FC = () => { extendedMode, usePacePolling: isPacePolling, sessionId: sessionIdRef.current, + skipReselect, }); // Check if scan was cancelled by timeout diff --git a/app/src/screens/home/HomeScreen.tsx b/app/src/screens/home/HomeScreen.tsx index 151da5f55..a347595a0 100644 --- a/app/src/screens/home/HomeScreen.tsx +++ b/app/src/screens/home/HomeScreen.tsx @@ -10,7 +10,15 @@ import React, { useState, } from 'react'; import { Dimensions, Image, Pressable } from 'react-native'; -import { Button, ScrollView, Text, View, XStack, YStack } from 'tamagui'; +import { + Button, + ScrollView, + Spinner, + Text, + View, + XStack, + YStack, +} from 'tamagui'; import { useFocusEffect, useIsFocused, @@ -201,7 +209,7 @@ const HomeScreen: React.FC = () => { justifyContent="center" alignItems="center" > - Loading documents... + ); } diff --git a/app/src/screens/onboarding/SaveRecoveryPhraseScreen.tsx b/app/src/screens/onboarding/SaveRecoveryPhraseScreen.tsx index de62cca5e..6a732bb45 100644 --- a/app/src/screens/onboarding/SaveRecoveryPhraseScreen.tsx +++ b/app/src/screens/onboarding/SaveRecoveryPhraseScreen.tsx @@ -23,6 +23,7 @@ import useMnemonic from '@/hooks/useMnemonic'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import { STORAGE_NAME } from '@/services/cloud-backup'; import { useSettingStore } from '@/stores/settingStore'; +import { getRecoveryPhraseWarningMessage } from '@/utils/crypto/mnemonic'; const SaveRecoveryPhraseScreen: React.FC = () => { const [userHasSeenMnemonic, setUserHasSeenMnemonic] = useState(false); @@ -55,8 +56,7 @@ const SaveRecoveryPhraseScreen: React.FC = () => { Save your recovery phrase - This phrase is the only way to recover your account. Keep it secret, - keep it safe. + {getRecoveryPhraseWarningMessage()} { + const navigation = useNavigation(); + const [code, setCode] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isCopied, setIsCopied] = useState(false); + const copyTimeoutRef = useRef(null); + + const handleFetchCode = async () => { + try { + setIsLoading(true); + setError(null); + confirmTap(); + + const walletAddress = await getOrGeneratePointsAddress(); + const fetchedCode = await fetchPushCode(walletAddress); + + setCode(fetchedCode); + } catch (err) { + console.error('Failed to fetch push code:', err); + setError('Failed to generate code. Please try again.'); + setCode(null); // Clear stale code on error + } finally { + setIsLoading(false); + } + }; + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + }; + }, []); + + const handleRetry = () => { + handleFetchCode(); + }; + + const handleCopyCode = async () => { + if (!code || code === DASH_CODE) { + return; + } + + try { + confirmTap(); + await Clipboard.setString(code); + setIsCopied(true); + + // Clear any existing timeout before creating a new one + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + + // Reset after 1.65 seconds + copyTimeoutRef.current = setTimeout(() => { + setIsCopied(false); + copyTimeoutRef.current = null; + }, 1650); + } catch (copyError) { + console.error('Failed to copy to clipboard:', copyError); + } + }; + + const handleDismiss = () => { + confirmTap(); + navigation.goBack(); + }; + + return ( + + + {/* Colorful background image */} + + {/* Fade to black overlay - stronger at bottom */} + + + + {/* Content container */} + + {/* App logos section */} + + + {/* Title and content */} + + + Your Starfall code awaits + + + + + + Open Starfall in Opera MiniPay and enter this four digit code + to continue your journey. + + + + + + + + {/* Error message */} + {error && ( + + {error} + + )} + + + + + + + {/* Bottom buttons */} + + {/* Debug: Fetch code button or Retry button on error */} + {error ? ( + + Retry + + ) : ( + + {isLoading ? 'Fetching...' : 'Fetch code'} + + )} + + + {isCopied ? 'Code copied!' : 'Copy code'} + + + Dismiss + + + + + ); +}; + +export default StarfallPushCodeScreen; diff --git a/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx new file mode 100644 index 000000000..140bb3d79 --- /dev/null +++ b/app/src/screens/verification/DocumentSelectorForProvingScreen.tsx @@ -0,0 +1,491 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; +import { ActivityIndicator, StyleSheet } from 'react-native'; +import { Text, View, YStack } from 'tamagui'; +import type { RouteProp } from '@react-navigation/native'; +import { + useFocusEffect, + useNavigation, + useRoute, +} from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; +import { + getDocumentAttributes, + isDocumentValidForProving, + useSelfClient, +} from '@selfxyz/mobile-sdk-alpha'; +import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import type { IDSelectorState } from '@/components/documents'; +import { IDSelectorSheet, isDisabledState } from '@/components/documents'; +import { + BottomActionBar, + ConnectedWalletBadge, + DisclosureItem, + ProofRequestCard, + proofRequestColors, + truncateAddress, + WalletAddressModal, +} from '@/components/proof-request'; +import { useSelfAppData } from '@/hooks/useSelfAppData'; +import type { RootStackParamList } from '@/navigation'; +import { usePassport } from '@/providers/passportDataProvider'; +import { getDocumentTypeName } from '@/utils/documentUtils'; + +function getDocumentDisplayName( + metadata: DocumentMetadata, + documentData?: IDDocument, +): string { + const category = metadata.documentCategory || ''; + const isMock = metadata.mock; + + // Extract country information from document data + let countryCode: string | null = null; + if (documentData) { + try { + const attributes = getDocumentAttributes(documentData); + countryCode = attributes.nationalitySlice || null; + } catch { + // If we can't extract attributes, continue without country + } + } + + const mockPrefix = isMock ? 'Dev ' : ''; + + if (category === 'passport') { + const base = 'Passport'; + return countryCode + ? `${mockPrefix}${countryCode} ${base}` + : `${mockPrefix}${base}`; + } else if (category === 'id_card') { + const base = 'ID Card'; + return countryCode + ? `${mockPrefix}${countryCode} ${base}` + : `${mockPrefix}${base}`; + } else if (category === 'aadhaar') { + return isMock ? 'Dev Aadhaar ID' : 'Aadhaar ID'; + } + + return isMock ? `Dev ${metadata.documentType}` : metadata.documentType; +} + +function determineDocumentState( + metadata: DocumentMetadata, + documentData: IDDocument | undefined, +): IDSelectorState { + // Use SDK to check if document is valid (not expired) + if (!isDocumentValidForProving(metadata, documentData)) { + return 'expired'; + } + + // UI-specific state mapping: Mock documents are selectable but marked as developer/mock + if (metadata.mock) { + return 'mock'; + } + + // Both registered and non-registered real documents are valid for selection + // They will be registered during the proving flow if needed + return 'verified'; +} + +const DocumentSelectorForProvingScreen: React.FC = () => { + const navigation = + useNavigation>(); + const route = + useRoute>(); + const selfClient = useSelfClient(); + const { useSelfAppStore } = selfClient; + const selfApp = useSelfAppStore(state => state.selfApp); + const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } = + usePassport(); + // Extract SelfApp data using hook + const { logoSource, url, formattedUserId, disclosureItems } = + useSelfAppData(selfApp); + + const [documentCatalog, setDocumentCatalog] = useState({ + documents: [], + }); + const [allDocuments, setAllDocuments] = useState< + Record + >({}); + const [selectedDocumentId, setSelectedDocumentId] = useState< + string | undefined + >(); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [walletModalOpen, setWalletModalOpen] = useState(false); + const abortControllerRef = useRef(null); + const scrollOffsetRef = useRef(0); + + const pickInitialDocument = useCallback( + ( + catalog: DocumentCatalog, + docs: Record, + ) => { + if (catalog.selectedDocumentId) { + const selectedMeta = catalog.documents.find( + doc => doc.id === catalog.selectedDocumentId, + ); + const selectedData = selectedMeta + ? docs[catalog.selectedDocumentId] + : undefined; + + if (selectedMeta && selectedData) { + const state = determineDocumentState(selectedMeta, selectedData.data); + if (!isDisabledState(state)) { + return catalog.selectedDocumentId; + } + } else if (selectedMeta) { + return catalog.selectedDocumentId; + } + } + + const firstValid = catalog.documents.find(doc => { + const docData = docs[doc.id]; + const state = determineDocumentState(doc, docData?.data); + return !isDisabledState(state); + }); + + return firstValid?.id; + }, + [], + ); + + const loadDocuments = useCallback(async () => { + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + setLoading(true); + setError(null); + try { + const catalog = await loadDocumentCatalog(); + const docs = await getAllDocuments(); + + // Don't update state if this request was aborted + if (controller.signal.aborted) { + return; + } + + setDocumentCatalog(catalog); + setAllDocuments(docs); + setSelectedDocumentId(pickInitialDocument(catalog, docs)); + } catch (loadError) { + // Don't show error if this request was aborted + if (controller.signal.aborted) { + return; + } + console.warn('Failed to load documents:', loadError); + setError('Unable to load documents.'); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, [getAllDocuments, loadDocumentCatalog, pickInitialDocument]); + + useFocusEffect( + useCallback(() => { + loadDocuments(); + }, [loadDocuments]), + ); + + // Cleanup abort controller on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const documents = useMemo(() => { + return documentCatalog.documents + .map(metadata => { + const docData = allDocuments[metadata.id]; + const baseState = determineDocumentState(metadata, docData?.data); + const isSelected = metadata.id === selectedDocumentId; + const itemState = + isSelected && !isDisabledState(baseState) ? 'active' : baseState; + + return { + id: metadata.id, + name: getDocumentDisplayName(metadata, docData?.data), + state: itemState, + }; + }) + .sort((a, b) => { + // Get metadata for both documents + const metaA = documentCatalog.documents.find(d => d.id === a.id); + const metaB = documentCatalog.documents.find(d => d.id === b.id); + + // Sort real documents before mock documents + if (metaA && metaB) { + if (metaA.mock !== metaB.mock) { + return metaA.mock ? 1 : -1; // Real first + } + } + + // Within same type (real/mock), sort alphabetically by name + return a.name.localeCompare(b.name); + }); + }, [allDocuments, documentCatalog.documents, selectedDocumentId]); + + const selectedDocument = documents.find(doc => doc.id === selectedDocumentId); + const canContinue = + !!selectedDocument && !isDisabledState(selectedDocument.state); + + // Get document type for the proof request message + const selectedDocumentType = useMemo(() => { + // If we have a preloaded document type from route params, use it while loading + const preloadedType = route.params?.documentType; + if (loading && preloadedType) { + return preloadedType; + } + + if (!selectedDocumentId) return preloadedType || ''; + const metadata = documentCatalog.documents.find( + d => d.id === selectedDocumentId, + ); + return getDocumentTypeName(metadata?.documentCategory); + }, [ + selectedDocumentId, + documentCatalog.documents, + loading, + route.params?.documentType, + ]); + + const handleSelect = useCallback((documentId: string) => { + setSelectedDocumentId(documentId); + }, []); + + const handleSheetSelect = useCallback(async () => { + if (!selectedDocumentId || !canContinue || submitting) { + return; + } + + setSubmitting(true); + setError(null); + try { + await setSelectedDocument(selectedDocumentId); + setSheetOpen(false); // Close the sheet first + navigation.navigate('Prove', { scrollOffset: scrollOffsetRef.current }); + } catch (selectionError) { + console.error('Failed to set selected document:', selectionError); + setError('Failed to select document. Please try again.'); + } finally { + setSubmitting(false); + } + }, [ + selectedDocumentId, + canContinue, + submitting, + setSelectedDocument, + navigation, + ]); + + const handleApprove = async () => { + if (!selectedDocumentId || !canContinue || submitting) { + return; + } + + setSubmitting(true); + setError(null); + try { + await setSelectedDocument(selectedDocumentId); + navigation.navigate('Prove', { scrollOffset: scrollOffsetRef.current }); + } catch (selectionError) { + console.error('Failed to set selected document:', selectionError); + setError('Failed to select document. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, + [], + ); + + // Loading state + if (loading) { + return ( + + + + ); + } + + // Error state + if (error) { + return ( + + + {error} + + + + Retry + + + + ); + } + + // Empty state + if (documents.length === 0) { + return ( + + + No documents found. Please scan a document first. + + + ); + } + + return ( + + {/* Main Content - Proof Request Card */} + setWalletModalOpen(true)} + testID="document-selector-wallet-badge" + /> + ) : undefined + } + onScroll={handleScroll} + testID="document-selector-card" + > + {/* Disclosure Items */} + + {disclosureItems.map((item, index) => ( + + ))} + + + + {/* Bottom Action Bar */} + setSheetOpen(true)} + onApprovePress={handleApprove} + approveDisabled={!canContinue} + approving={submitting} + testID="document-selector-action-bar" + /> + + {/* ID Selector Sheet */} + setSheetOpen(false)} + onApprove={handleSheetSelect} + testID="document-selector-sheet" + /> + + {/* Wallet Address Modal */} + {formattedUserId && selfApp?.userId && ( + setWalletModalOpen(false)} + address={selfApp.userId} + userIdType={selfApp?.userIdType} + testID="document-selector-wallet-modal" + /> + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: white, + }, +}); + +export { DocumentSelectorForProvingScreen }; diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index 57a0f3dbe..641d311d2 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -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, @@ -14,33 +13,33 @@ import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, + ScrollView as ScrollViewType, } from 'react-native'; -import { ScrollView, StyleSheet, TouchableOpacity } from 'react-native'; -import { Image, Text, View, XStack, YStack } from 'tamagui'; -import { useIsFocused, useNavigation } from '@react-navigation/native'; +import { StyleSheet, useWindowDimensions } from 'react-native'; +import { View, YStack } from 'tamagui'; +import type { RouteProp } from '@react-navigation/native'; +import { + useIsFocused, + useNavigation, + useRoute, +} from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { Eye, EyeOff } from '@tamagui/lucide-icons'; import { isMRZDocument } from '@selfxyz/common'; -import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType'; -import { formatEndpoint } from '@selfxyz/common/utils/scope'; import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha'; -import miscAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json'; -import { - BodyText, - Caption, - HeldPrimaryButtonProveScreen, -} from '@selfxyz/mobile-sdk-alpha/components'; import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; -import { - black, - slate300, - white, -} from '@selfxyz/mobile-sdk-alpha/constants/colors'; -import Disclosures from '@/components/Disclosures'; +import { + BottomVerifyBar, + ConnectedWalletBadge, + DisclosureItem, + ProofRequestCard, + proofRequestColors, + truncateAddress, + WalletAddressModal, +} from '@/components/proof-request'; +import { useSelfAppData } from '@/hooks/useSelfAppData'; import { buttonTap } from '@/integrations/haptics'; -import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import type { RootStackParamList } from '@/navigation'; import { setDefaultDocumentTypeIfNeeded, @@ -56,35 +55,63 @@ import { checkDocumentExpiration, getDocumentAttributes, } from '@/utils/documentAttributes'; -import { formatUserId } from '@/utils/formatUserId'; +import { getDocumentTypeName } from '@/utils/documentUtils'; const ProveScreen: React.FC = () => { const selfClient = useSelfClient(); const { trackEvent } = selfClient; - const { navigate } = + const navigation = useNavigation>(); + const { navigate } = navigation; + const route = useRoute>(); const isFocused = useIsFocused(); const { useProvingStore, useSelfAppStore } = selfClient; const selectedApp = useSelfAppStore(state => state.selfApp); + + // Extract SelfApp data using hook + const { logoSource, url, formattedUserId, disclosureItems } = + useSelfAppData(selectedApp); + const selectedAppRef = useRef(null); const processedSessionsRef = useRef>(new Set()); const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [scrollViewContentHeight, setScrollViewContentHeight] = useState(0); const [scrollViewHeight, setScrollViewHeight] = useState(0); - const [showFullAddress, setShowFullAddress] = useState(false); const [isDocumentExpired, setIsDocumentExpired] = useState(false); + const [documentType, setDocumentType] = useState(''); + const [walletModalOpen, setWalletModalOpen] = useState(false); const isDocumentExpiredRef = useRef(false); - const scrollViewRef = useRef(null); + const scrollViewRef = useRef(null); + const hasInitializedScrollStateRef = useRef(false); const isContentShorterThanScrollView = useMemo( - () => scrollViewContentHeight <= scrollViewHeight, + () => scrollViewContentHeight <= scrollViewHeight + 50, [scrollViewContentHeight, scrollViewHeight], ); + + const isScrollable = useMemo( + () => !isContentShorterThanScrollView, + [isContentShorterThanScrollView], + ); const provingStore = useProvingStore(); const currentState = useProvingStore(state => state.currentState); const isReadyToProve = currentState === 'ready_to_prove'; + // Use window dimensions for dynamic scroll offset padding + // This scales with viewport height rather than using hardcoded platform values + const { height: windowHeight } = useWindowDimensions(); + + const initialScrollOffset = useMemo(() => { + if (route.params?.scrollOffset === undefined) { + return undefined; + } + // Use ~1.5% of window height as padding to account for minor layout differences + // This scales appropriately across different device sizes + const padding = windowHeight * 0.01; + return route.params.scrollOffset + padding; + }, [route.params?.scrollOffset, windowHeight]); + const { addProofHistory } = useProofHistoryStore(); const { loadDocumentCatalog } = usePassport(); @@ -92,6 +119,7 @@ const ProveScreen: React.FC = () => { const addHistory = async () => { if (provingStore.uuid && selectedApp) { const catalog = await loadDocumentCatalog(); + const selectedDocumentId = catalog.selectedDocumentId; addProofHistory({ @@ -109,21 +137,43 @@ const ProveScreen: React.FC = () => { } }; addHistory(); - }, [addProofHistory, provingStore.uuid, selectedApp, loadDocumentCatalog]); + }, [addProofHistory, loadDocumentCatalog, provingStore.uuid, selectedApp]); useEffect(() => { + // Wait for actual measurements before determining initial scroll state + // Both start at 0, causing false-positive on first render + const hasMeasurements = scrollViewContentHeight > 0 && scrollViewHeight > 0; + + if (!hasMeasurements || hasInitializedScrollStateRef.current) { + return; + } + + // Only auto-enable if content is short enough that no scrolling is needed if (isContentShorterThanScrollView) { setHasScrolledToBottom(true); - } else { - setHasScrolledToBottom(false); } - }, [isContentShorterThanScrollView]); + // If content is long, leave hasScrolledToBottom as false (require scroll) + // Don't explicitly set to false to avoid resetting user's scroll progress + + // Mark as initialized so we don't override user's scroll state later + hasInitializedScrollStateRef.current = true; + }, [ + isContentShorterThanScrollView, + scrollViewContentHeight, + scrollViewHeight, + ]); useEffect(() => { if (!isFocused || !selectedApp) { return; } + // Reset scroll state tracking for new session + if (selectedAppRef.current?.sessionId !== selectedApp.sessionId) { + hasInitializedScrollStateRef.current = false; + setHasScrolledToBottom(false); + } + setDefaultDocumentTypeIfNeeded(); const checkExpirationAndInit = async () => { @@ -142,6 +192,9 @@ const ProveScreen: React.FC = () => { setIsDocumentExpired(isExpired); isDocumentExpiredRef.current = isExpired; } + setDocumentType( + getDocumentTypeName(selectedDocument?.data?.documentCategory), + ); } catch (error) { console.error('Error checking document expiration:', error); setIsDocumentExpired(false); @@ -212,43 +265,6 @@ const ProveScreen: React.FC = () => { enhanceApp(); }, [selectedApp, selfClient]); - const disclosureOptions = useMemo(() => { - return (selectedApp?.disclosures as SelfAppDisclosureConfig) || []; - }, [selectedApp?.disclosures]); - - // Format the logo source based on whether it's a URL or base64 string - const logoSource = useMemo(() => { - if (!selectedApp?.logoBase64) { - return null; - } - - // Check if the logo is already a URL - if ( - selectedApp.logoBase64.startsWith('http://') || - selectedApp.logoBase64.startsWith('https://') - ) { - return { uri: selectedApp.logoBase64 }; - } - - // Otherwise handle as base64 as before - const base64String = selectedApp.logoBase64.startsWith('data:image') - ? selectedApp.logoBase64 - : `data:image/png;base64,${selectedApp.logoBase64}`; - return { uri: base64String }; - }, [selectedApp?.logoBase64]); - - const url = useMemo(() => { - if (!selectedApp?.endpoint) { - return null; - } - return formatEndpoint(selectedApp.endpoint); - }, [selectedApp?.endpoint]); - - const formattedUserId = useMemo( - () => formatUserId(selectedApp?.userId, selectedApp?.userIdType), - [selectedApp?.userId, selectedApp?.userIdType], - ); - function onVerify() { provingStore.setUserConfirmed(selfClient); buttonTap(); @@ -270,7 +286,7 @@ const ProveScreen: React.FC = () => { } const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; - const paddingToBottom = 10; + const paddingToBottom = 50; const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom; @@ -304,213 +320,80 @@ const ProveScreen: React.FC = () => { ); const handleScrollViewLayout = useCallback((event: LayoutChangeEvent) => { - setScrollViewHeight(event.nativeEvent.layout.height); + const layoutHeight = event.nativeEvent.layout.height; + setScrollViewHeight(layoutHeight); }, []); - const handleAddressToggle = useCallback(() => { - if (selectedApp?.userIdType === 'hex') { - setShowFullAddress(!showFullAddress); - buttonTap(); - } - }, [selectedApp?.userIdType, showFullAddress]); - return ( - - - - {!selectedApp?.sessionId ? ( - + setWalletModalOpen(true)} + testID="prove-screen-wallet-badge" /> - ) : ( - - {logoSource && ( - - )} - - {url} - - - {selectedApp.appName} is requesting - you to prove the following information: - - - )} - - - - - + {/* Disclosure Items */} + + {disclosureItems.map((item, index) => ( + + ))} + + - {/* Display connected wallet or UUID */} - {formattedUserId && ( - - - {selectedApp?.userIdType === 'hex' - ? 'Connected Wallet' - : 'Connected ID'} - : - - - - - - - {selectedApp?.userIdType === 'hex' && showFullAddress - ? selectedApp.userId - : formattedUserId} - - - {selectedApp?.userIdType === 'hex' && ( - - {showFullAddress ? ( - - ) : ( - - )} - - )} - - {selectedApp?.userIdType === 'hex' && ( - - {showFullAddress - ? 'Tap to hide address' - : 'Tap to show full address'} - - )} - - - - )} + - {/* Display userDefinedData if it exists */} - {selectedApp?.userDefinedData && ( - - - Additional Information: - - - - {selectedApp.userDefinedData} - - - - )} - - - - Self will confirm that these details are accurate and none of your - confidential info will be revealed to {selectedApp?.appName} - - - - setWalletModalOpen(false)} + address={selectedApp.userId} + userIdType={selectedApp?.userIdType} + testID="prove-screen-wallet-modal" /> - - + )} + ); }; export default ProveScreen; const styles = StyleSheet.create({ - animation: { - top: 0, - width: 200, - height: 200, - transform: [{ scale: 2 }, { translateY: -20 }], + container: { + flex: 1, + backgroundColor: proofRequestColors.white, }, }); diff --git a/app/src/screens/verification/ProvingScreenRouter.tsx b/app/src/screens/verification/ProvingScreenRouter.tsx new file mode 100644 index 000000000..058297d20 --- /dev/null +++ b/app/src/screens/verification/ProvingScreenRouter.tsx @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ActivityIndicator } from 'react-native'; +import { Text, View } from 'tamagui'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import { + isDocumentValidForProving, + pickBestDocumentToSelect, +} from '@selfxyz/mobile-sdk-alpha'; +import { black } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import { proofRequestColors } from '@/components/proof-request'; +import type { RootStackParamList } from '@/navigation'; +import { usePassport } from '@/providers/passportDataProvider'; +import { useSettingStore } from '@/stores/settingStore'; +import { getDocumentTypeName } from '@/utils/documentUtils'; + +/** + * Router screen for the proving flow that decides whether to skip the document selector. + * + * This screen: + * 1. Loads document catalog and counts valid documents + * 2. Checks skip settings (skipDocumentSelector, skipDocumentSelectorIfSingle) + * 3. Routes to appropriate screen: + * - No valid documents -> DocumentDataNotFound + * - Skip enabled -> auto-select and go to Prove + * - Otherwise -> DocumentSelectorForProving + */ +const ProvingScreenRouter: React.FC = () => { + const navigation = + useNavigation>(); + const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } = + usePassport(); + const { skipDocumentSelector, skipDocumentSelectorIfSingle } = + useSettingStore(); + const [error, setError] = useState(null); + const abortControllerRef = useRef(null); + const hasRoutedRef = useRef(false); + + const loadAndRoute = useCallback(async () => { + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + // Prevent double routing + if (hasRoutedRef.current) { + return; + } + + setError(null); + try { + const catalog = await loadDocumentCatalog(); + const docs = await getAllDocuments(); + + // Don't continue if this request was aborted + if (controller.signal.aborted) { + return; + } + + // Count valid documents + const validDocuments = catalog.documents.filter(doc => { + const docData = docs[doc.id]; + return isDocumentValidForProving(doc, docData?.data); + }); + + const validCount = validDocuments.length; + + // Mark as routed to prevent re-routing + hasRoutedRef.current = true; + + // Route based on document availability and skip settings + if (validCount === 0) { + // No valid documents - redirect to onboarding + navigation.replace('DocumentDataNotFound'); + return; + } + + // Determine document type from first valid document for display + const firstValidDoc = validDocuments[0]; + const documentType = getDocumentTypeName(firstValidDoc?.documentCategory); + + // Determine if we should skip the selector + const shouldSkip = + skipDocumentSelector || + (skipDocumentSelectorIfSingle && validCount === 1); + + if (shouldSkip) { + // Auto-select and navigate to Prove + const docToSelect = pickBestDocumentToSelect(catalog, docs); + if (docToSelect) { + try { + await setSelectedDocument(docToSelect); + navigation.replace('Prove'); + } catch (selectError) { + console.error('Failed to auto-select document:', selectError); + // On error, fall back to showing the selector + hasRoutedRef.current = false; + navigation.replace('DocumentSelectorForProving', { + documentType, + }); + } + } else { + // No valid document to select, show selector + navigation.replace('DocumentSelectorForProving', { + documentType, + }); + } + } else { + // Show the document selector + navigation.replace('DocumentSelectorForProving', { + documentType, + }); + } + } catch (loadError) { + // Don't show error if this request was aborted + if (controller.signal.aborted) { + return; + } + console.warn('Failed to load documents for routing:', loadError); + setError('Unable to load documents.'); + // Reset routed flag to allow retry + hasRoutedRef.current = false; + } + }, [ + getAllDocuments, + loadDocumentCatalog, + navigation, + setSelectedDocument, + skipDocumentSelector, + skipDocumentSelectorIfSingle, + ]); + + useFocusEffect( + useCallback(() => { + // Reset routing flag when screen gains focus + hasRoutedRef.current = false; + loadAndRoute(); + }, [loadAndRoute]), + ); + + // Cleanup abort controller on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + return ( + + {error ? ( + + + {error} + + { + hasRoutedRef.current = false; + loadAndRoute(); + }} + pressStyle={{ opacity: 0.7 }} + testID="proving-router-retry" + > + + Retry + + + + ) : ( + <> + + + )} + + ); +}; + +export { ProvingScreenRouter }; diff --git a/app/src/screens/verification/QRCodeViewFinderScreen.tsx b/app/src/screens/verification/QRCodeViewFinderScreen.tsx index eea105562..f01f1942f 100644 --- a/app/src/screens/verification/QRCodeViewFinderScreen.tsx +++ b/app/src/screens/verification/QRCodeViewFinderScreen.tsx @@ -48,7 +48,7 @@ const QRCodeViewFinderScreen: React.FC = () => { const isFocused = useIsFocused(); const [doneScanningQR, setDoneScanningQR] = useState(false); const { top: safeAreaTop } = useSafeAreaInsets(); - const navigateToProve = useHapticNavigation('Prove'); + const navigateToDocumentSelector = useHapticNavigation('ProvingScreenRouter'); // This resets to the default state when we navigate back to this screen useFocusEffect( @@ -91,7 +91,7 @@ const QRCodeViewFinderScreen: React.FC = () => { .startAppListener(selfAppJson.sessionId); setTimeout(() => { - navigateToProve(); + navigateToDocumentSelector(); }, 100); } catch (parseError) { trackEvent(ProofEvents.QR_SCAN_FAILED, { @@ -115,7 +115,7 @@ const QRCodeViewFinderScreen: React.FC = () => { selfClient.getSelfAppState().startAppListener(sessionId); setTimeout(() => { - navigateToProve(); + navigateToDocumentSelector(); }, 100); } else { trackEvent(ProofEvents.QR_SCAN_FAILED, { @@ -129,7 +129,13 @@ const QRCodeViewFinderScreen: React.FC = () => { } } }, - [doneScanningQR, navigation, navigateToProve, trackEvent, selfClient], + [ + doneScanningQR, + navigation, + navigateToDocumentSelector, + trackEvent, + selfClient, + ], ); const shouldRenderCamera = !connectionModalVisible && !doneScanningQR; diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index 3ad9c7415..86fda5704 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -13,9 +13,19 @@ import type { TrackEventParams } from '@selfxyz/mobile-sdk-alpha'; import { createSegmentClient } from '@/config/segment'; import { PassportReader } from '@/integrations/nfc/passportReader'; +// ============================================================================ +// Constants +// ============================================================================ + +const MIXPANEL_AUTO_FLUSH_THRESHOLD = 5; +const MAX_EVENT_QUEUE_SIZE = 100; + +// ============================================================================ +// State Management +// ============================================================================ + const segmentClient = createSegmentClient(); -// --- Analytics flush strategy --- let mixpanelConfigured = false; let eventCount = 0; let isConnected = true; @@ -25,6 +35,10 @@ const eventQueue: Array<{ properties?: Record; }> = []; +// ============================================================================ +// Internal Helpers - JSON Coercion +// ============================================================================ + function coerceToJsonValue( value: unknown, seen = new WeakSet(), @@ -93,64 +107,53 @@ function validateParams( return cleanParams(validatedProps); } -/* - Records analytics events and screen views - In development mode, events are logged to console instead of being sent to Segment +// ============================================================================ +// Internal Helpers - Event Tracking +// ============================================================================ + +/** + * Internal tracking function used by trackEvent and trackScreenView + * Records analytics events and screen views + * In development mode, events are logged to console instead of being sent to Segment + * + * NOTE: Screen views are tracked as 'Screen Viewed' events for Mixpanel compatibility */ -const analytics = () => { - function _track( - type: 'event' | 'screen', - eventName: string, - properties?: Record, - ) { - // Validate and clean properties - const validatedProps = validateParams(properties); +function _track( + type: 'event' | 'screen', + eventName: string, + properties?: Record, +) { + // Transform screen events for Mixpanel compatibility + const finalEventName = type === 'screen' ? `Viewed ${eventName}` : eventName; - if (__DEV__) { - console.log(`[DEV: Analytics ${type.toUpperCase()}]`, { - name: eventName, - properties: validatedProps, - }); - return; - } + // Validate and clean properties + const validatedProps = validateParams(properties); - if (!segmentClient) { - return; - } - const trackMethod = (e: string, p?: JsonMap) => - type === 'screen' - ? segmentClient.screen(e, p) - : segmentClient.track(e, p); - - if (!validatedProps) { - // you may need to remove the catch when debugging - return trackMethod(eventName).catch(console.info); - } - - // you may need to remove the catch when debugging - trackMethod(eventName, validatedProps).catch(console.info); + if (__DEV__) { + console.log(`[DEV: Analytics ${type.toUpperCase()}]`, { + name: finalEventName, + properties: validatedProps, + }); + return; } - return { - // Using LiteralCheck will allow constants but not plain string literals - trackEvent: (eventName: string, properties?: TrackEventParams) => { - _track('event', eventName, properties); - }, - trackScreenView: ( - screenName: string, - properties?: Record, - ) => { - _track('screen', screenName, properties); - }, - flush: () => { - if (!__DEV__ && segmentClient) { - segmentClient.flush(); - } - }, - }; -}; + if (!segmentClient) { + return; + } -export default analytics; + // Always use track() for both events and screen views (Mixpanel compatibility) + if (!validatedProps) { + // you may need to remove the catch when debugging + return segmentClient.track(finalEventName).catch(console.info); + } + + // you may need to remove the catch when debugging + segmentClient.track(finalEventName, validatedProps).catch(console.info); +} + +// ============================================================================ +// Public API - Segment Analytics +// ============================================================================ /** * Cleanup function to clear event queues @@ -160,6 +163,117 @@ export const cleanupAnalytics = () => { eventCount = 0; }; +// ============================================================================ +// Public API - Mixpanel NFC Analytics +// ============================================================================ +export const configureNfcAnalytics = async () => { + if (!MIXPANEL_NFC_PROJECT_TOKEN || mixpanelConfigured) return; + const enableDebugLogs = ENABLE_DEBUG_LOGS; + + // Check if PassportReader and configure method exist (Android doesn't have configure) + if (PassportReader && typeof PassportReader.configure === 'function') { + try { + // iOS configure method only accepts token and enableDebugLogs + // Android doesn't have this method at all + await Promise.resolve( + PassportReader.configure(MIXPANEL_NFC_PROJECT_TOKEN, enableDebugLogs), + ); + } catch (error) { + console.warn('Failed to configure NFC analytics:', error); + } + } + + setupFlushPolicies(); + mixpanelConfigured = true; +}; + +/** + * Flush any pending analytics events immediately + */ +export const flush = () => { + if (!__DEV__ && segmentClient) { + segmentClient.flush(); + } +}; + +/** + * Consolidated analytics flush function that flushes both Segment and Mixpanel events + * This should be called when you want to ensure all analytics events are sent immediately + */ +export const flushAllAnalytics = () => { + // Flush Segment analytics + flush(); + + // Never flush Mixpanel during active NFC scanning to prevent interference + if (!isNfcScanningActive) { + flushMixpanelEvents().catch(console.warn); + } +}; + +/** + * Set NFC scanning state to prevent analytics flush interference + */ +export const setNfcScanningActive = (active: boolean) => { + isNfcScanningActive = active; + if (__DEV__) + console.log( + `[NFC Analytics] Scanning state: ${active ? 'active' : 'inactive'}`, + ); + + // Flush queued events when scanning completes + if (!active && eventQueue.length > 0) { + flushMixpanelEvents().catch(console.warn); + } +}; + +/** + * Track an analytics event + * @param eventName - Name of the event to track + * @param properties - Optional properties to attach to the event + */ +export const trackEvent = ( + eventName: string, + properties?: TrackEventParams, +) => { + _track('event', eventName, properties); +}; + +export const trackNfcEvent = async ( + name: string, + properties?: Record, +) => { + if (!MIXPANEL_NFC_PROJECT_TOKEN) return; + if (!mixpanelConfigured) await configureNfcAnalytics(); + + if (!isConnected || isNfcScanningActive) { + if (eventQueue.length >= MAX_EVENT_QUEUE_SIZE) { + if (__DEV__) + console.warn('[Mixpanel] Event queue full, dropping oldest event'); + eventQueue.shift(); + } + eventQueue.push({ name, properties }); + return; + } + + try { + if (PassportReader && PassportReader.trackEvent) { + await Promise.resolve(PassportReader.trackEvent(name, properties)); + } + eventCount++; + // Prevent automatic flush during NFC scanning + if (eventCount >= MIXPANEL_AUTO_FLUSH_THRESHOLD && !isNfcScanningActive) { + flushMixpanelEvents().catch(console.warn); + } + } catch { + if (eventQueue.length >= MAX_EVENT_QUEUE_SIZE) { + if (__DEV__) + console.warn('[Mixpanel] Event queue full, dropping oldest event'); + eventQueue.shift(); + } + eventQueue.push({ name, properties }); + } +}; + const setupFlushPolicies = () => { AppState.addEventListener('change', (state: AppStateStatus) => { // Never flush during active NFC scanning to prevent interference @@ -221,84 +335,14 @@ const flushMixpanelEvents = async () => { } }; -// --- Mixpanel NFC Analytics --- -export const configureNfcAnalytics = async () => { - if (!MIXPANEL_NFC_PROJECT_TOKEN || mixpanelConfigured) return; - const enableDebugLogs = - String(ENABLE_DEBUG_LOGS ?? '') - .trim() - .toLowerCase() === 'true'; - - // Check if PassportReader and configure method exist (Android doesn't have configure) - if (PassportReader && typeof PassportReader.configure === 'function') { - try { - // iOS configure method only accepts token and enableDebugLogs - // Android doesn't have this method at all - await Promise.resolve( - PassportReader.configure(MIXPANEL_NFC_PROJECT_TOKEN, enableDebugLogs), - ); - } catch (error) { - console.warn('Failed to configure NFC analytics:', error); - } - } - - setupFlushPolicies(); - mixpanelConfigured = true; -}; - /** - * Consolidated analytics flush function that flushes both Segment and Mixpanel events - * This should be called when you want to ensure all analytics events are sent immediately + * Track a screen view + * @param screenName - Name of the screen to track + * @param properties - Optional properties to attach to the screen view */ -export const flushAllAnalytics = () => { - // Flush Segment analytics - const { flush: flushAnalytics } = analytics(); - flushAnalytics(); - - // Never flush Mixpanel during active NFC scanning to prevent interference - if (!isNfcScanningActive) { - flushMixpanelEvents().catch(console.warn); - } -}; - -/** - * Set NFC scanning state to prevent analytics flush interference - */ -export const setNfcScanningActive = (active: boolean) => { - isNfcScanningActive = active; - if (__DEV__) - console.log( - `[NFC Analytics] Scanning state: ${active ? 'active' : 'inactive'}`, - ); - - // Flush queued events when scanning completes - if (!active && eventQueue.length > 0) { - flushMixpanelEvents().catch(console.warn); - } -}; - -export const trackNfcEvent = async ( - name: string, +export const trackScreenView = ( + screenName: string, properties?: Record, ) => { - if (!MIXPANEL_NFC_PROJECT_TOKEN) return; - if (!mixpanelConfigured) await configureNfcAnalytics(); - - if (!isConnected || isNfcScanningActive) { - eventQueue.push({ name, properties }); - return; - } - - try { - if (PassportReader && PassportReader.trackEvent) { - await Promise.resolve(PassportReader.trackEvent(name, properties)); - } - eventCount++; - // Prevent automatic flush during NFC scanning - if (eventCount >= 5 && !isNfcScanningActive) { - flushMixpanelEvents().catch(console.warn); - } - } catch { - eventQueue.push({ name, properties }); - } + _track('screen', screenName, properties); }; diff --git a/app/src/services/logging/index.ts b/app/src/services/logging/index.ts index 5f58a78ee..be2bb07ef 100644 --- a/app/src/services/logging/index.ts +++ b/app/src/services/logging/index.ts @@ -56,33 +56,11 @@ const DocumentLogger = Logger.extend('DOCUMENT'); //Native Modules const NfcLogger = Logger.extend('NFC'); -// Collect all extended loggers for severity updates -const extendedLoggers = [ - AppLogger, - NotificationLogger, - AuthLogger, - PassportLogger, - ProofLogger, - SettingsLogger, - BackupLogger, - MockDataLogger, - DocumentLogger, - NfcLogger, -]; - // Subscribe to settings store changes to update logger severity dynamically -// Extended loggers are independent instances, so we need to update each one -// Note: Dynamically created loggers (e.g., in nativeLoggerBridge for unknown categories) -// will inherit the severity at creation time but won't receive runtime updates let previousSeverity = initialSeverity; useSettingStore.subscribe(state => { if (state.loggingSeverity !== previousSeverity) { Logger.setSeverity(state.loggingSeverity); - // Update all extended loggers since they don't inherit runtime changes - // Extended loggers have setSeverity at runtime, even if not in type definition - extendedLoggers.forEach(extLogger => { - (extLogger as typeof Logger).setSeverity(state.loggingSeverity); - }); previousSeverity = state.loggingSeverity; } }); diff --git a/app/src/services/starfall/pushCodeService.ts b/app/src/services/starfall/pushCodeService.ts new file mode 100644 index 000000000..c55a65f69 --- /dev/null +++ b/app/src/services/starfall/pushCodeService.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { POINTS_API_BASE_URL } from '@/services/points/constants'; + +const REQUEST_TIMEOUT_MS = 30000; // 30 seconds + +/** + * Fetches a one-time push code for the specified wallet address. + * The code has a TTL of 30 minutes and refreshes with each call. + * + * @param walletAddress - The wallet address to generate a push code for + * @returns The 4-digit push code as a string + * @throws Error if the API request fails or times out + */ +export async function fetchPushCode(walletAddress: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, REQUEST_TIMEOUT_MS); + + try { + const response = await fetch( + `${POINTS_API_BASE_URL}/push/wallet/${walletAddress}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + signal: controller.signal, + }, + ); + + // Clear timeout on successful response + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error( + `Failed to fetch push code: ${response.status} ${response.statusText}`, + ); + } + + const code = await response.json(); + + // The API returns a JSON string like "5932" + if (typeof code !== 'string' || code.length !== 4) { + throw new Error('Invalid push code format received from API'); + } + + return code; + } catch (error) { + // Clear timeout on error + clearTimeout(timeoutId); + + // Handle abort/timeout specifically + if (error instanceof Error && error.name === 'AbortError') { + console.error('Push code request timed out'); + throw new Error( + 'Request timed out. Please check your connection and try again.', + ); + } + + console.error('Error fetching push code:', error); + throw error; + } +} diff --git a/app/src/stores/database.ts b/app/src/stores/database.ts index 717420019..76561008d 100644 --- a/app/src/stores/database.ts +++ b/app/src/stores/database.ts @@ -14,6 +14,9 @@ const STALE_PROOF_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes SQLite.enablePromise(true); +const toInsertId = (result: SQLite.ResultSet) => + result.insertId ? result.insertId.toString() : '0'; + async function openDatabase() { return SQLite.openDatabase({ name: DB_NAME, @@ -127,8 +130,9 @@ export const database: ProofDB = { proof.documentId, ], ); + // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId.toString(), + id: toInsertId(insertResult), timestamp, rowsAffected: insertResult.rowsAffected, }; @@ -154,8 +158,9 @@ export const database: ProofDB = { proof.documentId, ], ); + // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId.toString(), + id: toInsertId(insertResult), timestamp, rowsAffected: insertResult.rowsAffected, }; @@ -182,8 +187,9 @@ export const database: ProofDB = { proof.documentId, ], ); + // Handle case where INSERT OR IGNORE skips insertion due to duplicate sessionId return { - id: insertResult.insertId.toString(), + id: toInsertId(insertResult), timestamp, rowsAffected: insertResult.rowsAffected, }; diff --git a/app/src/stores/settingStore.ts b/app/src/stores/settingStore.ts index 550bb792d..499234400 100644 --- a/app/src/stores/settingStore.ts +++ b/app/src/stores/settingStore.ts @@ -34,8 +34,12 @@ interface PersistedSettingsState { setKeychainMigrationCompleted: () => void; setLoggingSeverity: (severity: LoggingSeverity) => void; setPointsAddress: (address: string | null) => void; + setSkipDocumentSelector: (value: boolean) => void; + setSkipDocumentSelectorIfSingle: (value: boolean) => void; setSubscribedTopics: (topics: string[]) => void; setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void; + skipDocumentSelector: boolean; + skipDocumentSelectorIfSingle: boolean; subscribedTopics: string[]; toggleCloudBackupEnabled: () => void; turnkeyBackupEnabled: boolean; @@ -135,6 +139,14 @@ export const useSettingStore = create()( setPointsAddress: (address: string | null) => set({ pointsAddress: address }), + // Document selector skip settings + skipDocumentSelector: false, + setSkipDocumentSelector: (value: boolean) => + set({ skipDocumentSelector: value }), + skipDocumentSelectorIfSingle: true, + setSkipDocumentSelectorIfSingle: (value: boolean) => + set({ skipDocumentSelectorIfSingle: value }), + // Non-persisted state (will not be saved to storage) hideNetworkModal: false, setHideNetworkModal: (hideNetworkModal: boolean) => { diff --git a/app/src/types/reactNativePassportReader.d.ts b/app/src/types/reactNativePassportReader.d.ts index df5d16167..7077eb5ef 100644 --- a/app/src/types/reactNativePassportReader.d.ts +++ b/app/src/types/reactNativePassportReader.d.ts @@ -11,6 +11,7 @@ declare module 'react-native-passport-reader' { useCan: boolean; quality?: number; sessionId?: string; + skipReselect?: boolean; } interface PassportReader { diff --git a/app/src/utils/crypto/mnemonic.ts b/app/src/utils/crypto/mnemonic.ts index 0cf24f5c5..5eaf5b30d 100644 --- a/app/src/utils/crypto/mnemonic.ts +++ b/app/src/utils/crypto/mnemonic.ts @@ -4,8 +4,19 @@ import { ethers } from 'ethers'; +import { STORAGE_NAME } from '@/services/cloud-backup'; import type { Mnemonic } from '@/types/mnemonic'; +/** + * Gets the recovery phrase warning message based on the current platform. + * The message mentions cloud backup options available for the OS. + * @returns The recovery phrase warning message + */ +export function getRecoveryPhraseWarningMessage(): string { + const cloudBackupName = STORAGE_NAME; + return `Using this phrase or ${cloudBackupName} backup are the only ways to recover your account. Keep it secret, keep it safe.`; +} + /** * Type guard to check if an object is a valid Mnemonic * @param obj The object to check diff --git a/app/src/utils/devUtils.ts b/app/src/utils/devUtils.ts index 1bd470b67..b26d77048 100644 --- a/app/src/utils/devUtils.ts +++ b/app/src/utils/devUtils.ts @@ -8,4 +8,4 @@ * Use this constant instead of checking __DEV__ directly throughout the codebase. */ export const IS_DEV_MODE = typeof __DEV__ !== 'undefined' && __DEV__; -export const IS_EUCLID_ENABLED = false; //IS_DEV_MODE; // just in case we forgot to turn it off before pushing to prod. +export const IS_EUCLID_ENABLED = false; // Enabled for proof request UI redesign diff --git a/app/src/utils/disclosureUtils.ts b/app/src/utils/disclosureUtils.ts new file mode 100644 index 000000000..5bcde4df0 --- /dev/null +++ b/app/src/utils/disclosureUtils.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import type { Country3LetterCode } from '@selfxyz/common/constants'; +import { countryCodes } from '@selfxyz/common/constants'; +import type { SelfAppDisclosureConfig } from '@selfxyz/common/utils/appType'; + +function listToString(list: string[]): string { + if (list.length === 1) { + return list[0]; + } else if (list.length === 2) { + return list.join(' nor '); + } + return `${list.slice(0, -1).join(', ')} nor ${list.at(-1)}`; +} + +function countriesToSentence(countries: Country3LetterCode[]): string { + return listToString(countries.map(country => countryCodes[country])); +} + +export const ORDERED_DISCLOSURE_KEYS: Array = [ + 'issuing_state', + 'name', + 'passport_number', + 'nationality', + 'date_of_birth', + 'gender', + 'expiry_date', + 'ofac', + 'excludedCountries', + 'minimumAge', +] as const; + +export function getDisclosureItems( + disclosures: SelfAppDisclosureConfig, +): Array<{ key: string; text: string }> { + const items: Array<{ key: string; text: string }> = []; + + for (const key of ORDERED_DISCLOSURE_KEYS) { + const isEnabled = disclosures[key]; + if (!isEnabled || (Array.isArray(isEnabled) && isEnabled.length === 0)) { + continue; + } + + const text = getDisclosureText(key, disclosures); + if (text) { + items.push({ key, text }); + } + } + + return items; +} + +/** + * Generates the display text for a disclosure key. + * This is the single source of truth for disclosure text across the app. + */ +export function getDisclosureText( + key: keyof SelfAppDisclosureConfig, + disclosures: SelfAppDisclosureConfig, +): string { + switch (key) { + case 'ofac': + return 'I am not on the OFAC sanction list'; + case 'excludedCountries': + return `I am not a citizen of the following countries: ${countriesToSentence( + (disclosures.excludedCountries as Country3LetterCode[]) || [], + )}`; + case 'minimumAge': + return `Age is over ${disclosures.minimumAge}`; + case 'name': + return 'Name'; + case 'passport_number': + return 'Passport Number'; + case 'date_of_birth': + return 'Date of Birth'; + case 'gender': + return 'Gender'; + case 'expiry_date': + return 'Passport Expiry Date'; + case 'issuing_state': + return 'Issuing State'; + case 'nationality': + return 'Nationality'; + default: + return ''; + } +} diff --git a/app/src/utils/documentUtils.ts b/app/src/utils/documentUtils.ts new file mode 100644 index 000000000..d60e27133 --- /dev/null +++ b/app/src/utils/documentUtils.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +/** + * Gets the document type display name for the proof request message. + */ +export function getDocumentTypeName(category: string | undefined): string { + switch (category) { + case 'passport': + return 'Passport'; + case 'id_card': + return 'ID Card'; + case 'aadhaar': + return 'Aadhaar'; + default: + return 'Document'; + } +} diff --git a/app/src/utils/index.ts b/app/src/utils/index.ts index f54dee0ae..62a866586 100644 --- a/app/src/utils/index.ts +++ b/app/src/utils/index.ts @@ -39,6 +39,9 @@ export { extraYPadding, normalizeBorderWidth } from '@/utils/styleUtils'; // JSON utilities export { formatUserId } from '@/utils/formatUserId'; +// Document utilities +export { getDocumentTypeName } from '@/utils/documentUtils'; + export { getModalCallbacks, registerModalCallbacks, diff --git a/app/src/utils/keychainErrors.ts b/app/src/utils/keychainErrors.ts new file mode 100644 index 000000000..b3be2a113 --- /dev/null +++ b/app/src/utils/keychainErrors.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +export type KeychainErrorIdentity = { + code?: string; + name?: string; +}; + +type KeychainError = { + code?: string; + message?: string; + name?: string; +}; + +export type KeychainErrorType = 'user_cancelled' | 'crypto_failed'; + +export function getKeychainErrorIdentity( + error: unknown, +): KeychainErrorIdentity { + const err = error as KeychainError; + return { code: err?.code, name: err?.name }; +} + +export function isKeychainCryptoError(error: unknown): boolean { + const err = error as KeychainError; + return Boolean( + (err?.code === 'E_CRYPTO_FAILED' || + err?.name === 'com.oblador.keychain.exceptions.CryptoFailedException' || + err?.message?.includes('CryptoFailedException') || + err?.message?.includes('Decryption failed') || + err?.message?.includes('Authentication tag verification failed')) && + !isUserCancellation(error), + ); +} + +export function isUserCancellation(error: unknown): boolean { + const err = error as KeychainError; + return Boolean( + err?.code === 'E_AUTHENTICATION_FAILED' || + err?.code === 'USER_CANCELED' || + err?.message?.includes('User canceled') || + err?.message?.includes('Authentication canceled') || + err?.message?.includes('cancelled by user'), + ); +} diff --git a/app/tests/__setup__/mocks/navigation.js b/app/tests/__setup__/mocks/navigation.js index 4dfcf7d38..83159a837 100644 --- a/app/tests/__setup__/mocks/navigation.js +++ b/app/tests/__setup__/mocks/navigation.js @@ -9,10 +9,23 @@ jest.mock('@react-navigation/native', () => { const MockNavigator = (props, _ref) => props.children; MockNavigator.displayName = 'MockNavigator'; + // `useFocusEffect` should behave like an effect: it should not synchronously run + // on every re-render, otherwise any state updates inside the callback can cause + // an infinite render loop in tests. + const focusEffectCallbacks = new WeakSet(); + return { useFocusEffect: jest.fn(callback => { - // Immediately invoke the effect for testing without requiring a container - return callback(); + // Invoke only once per callback instance (per component mount), similar to + // how a real focus effect would run on focus rather than every render. + if ( + typeof callback === 'function' && + !focusEffectCallbacks.has(callback) + ) { + focusEffectCallbacks.add(callback); + return callback(); + } + return undefined; }), useNavigation: jest.fn(() => ({ navigate: jest.fn(), diff --git a/app/tests/__setup__/mocks/ui.js b/app/tests/__setup__/mocks/ui.js index 18d3b0d19..68c8b0e95 100644 --- a/app/tests/__setup__/mocks/ui.js +++ b/app/tests/__setup__/mocks/ui.js @@ -54,10 +54,18 @@ jest.mock('@selfxyz/mobile-sdk-alpha/components', () => { const Text = jest.fn(({ children, ...props }) => children || null); Text.displayName = 'MockText'; + const Title = jest.fn(({ children, ...props }) => children || null); + Title.displayName = 'MockTitle'; + + const View = jest.fn(({ children, ...props }) => children || null); + View.displayName = 'MockView'; + return { __esModule: true, Button, XStack, + Title, + View, // Provide minimal Text to satisfy potential usages Text, }; @@ -175,6 +183,10 @@ jest.mock('@tamagui/lucide-icons', () => { ExternalLink: makeIcon('external-link'), X: makeIcon('x'), Clipboard: makeIcon('clipboard'), + Check: makeIcon('check'), + Circle: makeIcon('circle'), + ChevronDown: makeIcon('chevron-down'), + ChevronLeft: makeIcon('chevron-left'), }; }); diff --git a/app/tests/src/components/documents/IDSelectorSheet.test.tsx b/app/tests/src/components/documents/IDSelectorSheet.test.tsx new file mode 100644 index 000000000..255d6f35e --- /dev/null +++ b/app/tests/src/components/documents/IDSelectorSheet.test.tsx @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { fireEvent, render } from '@testing-library/react-native'; + +import type { IDSelectorDocument } from '@/components/documents'; +import { IDSelectorItem, IDSelectorSheet } from '@/components/documents'; + +describe('IDSelectorItem', () => { + const mockOnPress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with testID', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('test-item')).toBeTruthy(); + }); + + it('calls onPress when pressed on active state', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('test-item')); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('calls onPress when pressed on verified state', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('test-item')); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('renders different states correctly', () => { + // Render active state + const { rerender, getByTestId } = render( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + + // Rerender with verified state + rerender( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + + // Rerender with expired state + rerender( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + + // Rerender with mock state + rerender( + , + ); + expect(getByTestId('test-item')).toBeTruthy(); + }); +}); + +describe('IDSelectorSheet', () => { + const mockDocuments: IDSelectorDocument[] = [ + { id: 'doc1', name: 'EU ID', state: 'verified' }, + { id: 'doc2', name: 'FRA Passport', state: 'verified' }, + { id: 'doc3', name: 'Dev USA Passport', state: 'mock' }, + { id: 'doc4', name: 'Aadhaar ID', state: 'expired' }, + ]; + + const mockOnOpenChange = jest.fn(); + const mockOnSelect = jest.fn(); + const mockOnDismiss = jest.fn(); + const mockOnApprove = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders document items with testIDs', () => { + const { getByTestId } = render( + , + ); + + // Document items use Pressable which properly passes testID + expect(getByTestId('sheet-item-doc1')).toBeTruthy(); + expect(getByTestId('sheet-item-doc2')).toBeTruthy(); + expect(getByTestId('sheet-item-doc3')).toBeTruthy(); + expect(getByTestId('sheet-item-doc4')).toBeTruthy(); + }); + + it('calls onSelect when a document item is pressed', () => { + const { getByTestId } = render( + , + ); + + // Press doc2 item + fireEvent.press(getByTestId('sheet-item-doc2')); + expect(mockOnSelect).toHaveBeenCalledWith('doc2'); + }); + + it('renders empty list without document items', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('sheet-item-doc1')).toBeNull(); + expect(queryByTestId('sheet-item-doc2')).toBeNull(); + }); + + it('shows selected document as active', () => { + const { getByTestId } = render( + , + ); + + // The selected item should have the check icon (indicating active state) + expect(getByTestId('icon-check')).toBeTruthy(); + }); + + it('calls onSelect with different document IDs', () => { + const { getByTestId } = render( + , + ); + + // Press each item and verify the correct ID is passed + fireEvent.press(getByTestId('sheet-item-doc1')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc1'); + + fireEvent.press(getByTestId('sheet-item-doc2')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc2'); + + fireEvent.press(getByTestId('sheet-item-doc3')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc3'); + + fireEvent.press(getByTestId('sheet-item-doc4')); + expect(mockOnSelect).toHaveBeenLastCalledWith('doc4'); + }); +}); diff --git a/app/tests/src/hooks/useEarnPointsFlow.test.ts b/app/tests/src/hooks/useEarnPointsFlow.test.ts index 40ff79ddd..5b003e3b7 100644 --- a/app/tests/src/hooks/useEarnPointsFlow.test.ts +++ b/app/tests/src/hooks/useEarnPointsFlow.test.ts @@ -268,7 +268,7 @@ describe('useEarnPointsFlow', () => { jest.advanceTimersByTime(100); }); - expect(mockNavigate).toHaveBeenCalledWith('Prove'); + expect(mockNavigate).toHaveBeenCalledWith('ProvingScreenRouter'); }); it('should clear referrer when points disclosure modal is dismissed with referrer', async () => { diff --git a/app/tests/src/hooks/useProofDisclosureStalenessCheck.test.ts b/app/tests/src/hooks/useProofDisclosureStalenessCheck.test.ts new file mode 100644 index 000000000..f9a828d3d --- /dev/null +++ b/app/tests/src/hooks/useProofDisclosureStalenessCheck.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { act, renderHook } from '@testing-library/react-native'; + +import type { SelfApp } from '@selfxyz/common'; + +import { useProofDisclosureStalenessCheck } from '@/hooks/useProofDisclosureStalenessCheck'; + +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: (callback: () => void | (() => void)) => { + callback(); + }, +})); + +describe('useProofDisclosureStalenessCheck', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('navigates home when selfApp is missing', () => { + const navigation = { navigate: jest.fn() }; + + renderHook(() => + useProofDisclosureStalenessCheck( + null, + [{ key: 'a', text: 'Disclosure' }], + navigation as any, + ), + ); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(navigation.navigate).toHaveBeenCalledWith({ + name: 'Home', + params: {}, + }); + }); + + it('navigates home when disclosure items are empty', () => { + const navigation = { navigate: jest.fn() }; + const selfApp = { appName: 'Test App' } as unknown as SelfApp; + + renderHook(() => + useProofDisclosureStalenessCheck(selfApp, [], navigation as any), + ); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(navigation.navigate).toHaveBeenCalledWith({ + name: 'Home', + params: {}, + }); + }); + + it('does not navigate when data is present', () => { + const navigation = { navigate: jest.fn() }; + const selfApp = { appName: 'Test App' } as unknown as SelfApp; + + renderHook(() => + useProofDisclosureStalenessCheck( + selfApp, + [{ key: 'a', text: 'Disclosure' }], + navigation as any, + ), + ); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(navigation.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx index 3ed68c962..9a02ad058 100644 --- a/app/tests/src/navigation.test.tsx +++ b/app/tests/src/navigation.test.tsx @@ -19,6 +19,9 @@ jest.mock('@/services/analytics', () => ({ trackScreenView: jest.fn(), flush: jest.fn(), })), + trackEvent: jest.fn(), + trackScreenView: jest.fn(), + flush: jest.fn(), })); describe('navigation', () => { @@ -59,6 +62,7 @@ describe('navigation', () => { 'DocumentNFCScan', 'DocumentNFCTrouble', 'DocumentOnboarding', + 'DocumentSelectorForProving', 'Gratification', 'Home', 'IDPicker', @@ -72,7 +76,9 @@ describe('navigation', () => { 'ProofHistory', 'ProofHistoryDetail', 'ProofRequestStatus', + 'ProofSettings', 'Prove', + 'ProvingScreenRouter', 'QRCodeTrouble', 'QRCodeViewFinder', 'RecoverWithPhrase', @@ -81,6 +87,7 @@ describe('navigation', () => { 'Settings', 'ShowRecoveryPhrase', 'Splash', + 'StarfallPushCode', 'WebView', ]); }); diff --git a/app/tests/src/navigation/deeplinks.test.ts b/app/tests/src/navigation/deeplinks.test.ts index 1c97a10b3..b73f87b6f 100644 --- a/app/tests/src/navigation/deeplinks.test.ts +++ b/app/tests/src/navigation/deeplinks.test.ts @@ -36,6 +36,7 @@ jest.mock('@/navigation', () => ({ navigate: jest.fn(), isReady: jest.fn(() => true), reset: jest.fn(), + getCurrentRoute: jest.fn(), }, })); @@ -66,6 +67,10 @@ describe('deeplinks', () => { setDeepLinkUserDetails, }); mockPlatform.OS = 'ios'; + + // Setup default getCurrentRoute mock to return Splash (cold launch scenario) + const { navigationRef } = require('@/navigation'); + navigationRef.getCurrentRoute.mockReturnValue({ name: 'Splash' }); }); describe('handleUrl', () => { @@ -92,7 +97,7 @@ describe('deeplinks', () => { const { navigationRef } = require('@/navigation'); expect(navigationRef.reset).toHaveBeenCalledWith({ index: 1, - routes: [{ name: 'Home' }, { name: 'Prove' }], + routes: [{ name: 'Home' }, { name: 'ProvingScreenRouter' }], }); }); @@ -118,7 +123,7 @@ describe('deeplinks', () => { const { navigationRef } = require('@/navigation'); expect(navigationRef.reset).toHaveBeenCalledWith({ index: 1, - routes: [{ name: 'Home' }, { name: 'Prove' }], + routes: [{ name: 'Home' }, { name: 'ProvingScreenRouter' }], }); }); @@ -156,9 +161,10 @@ describe('deeplinks', () => { const { navigationRef } = require('@/navigation'); // Should navigate to HomeScreen, which will show confirmation modal + // During cold launch (Splash screen), reset is called with full navigation state expect(navigationRef.reset).toHaveBeenCalledWith({ - index: 0, - routes: [{ name: 'Home' }], + index: 1, + routes: [{ name: 'Home' }, { name: 'Home' }], }); }); @@ -598,7 +604,7 @@ describe('deeplinks', () => { mockLinking.getInitialURL.mockResolvedValue(undefined as any); mockLinking.addEventListener.mockReturnValue({ remove }); - const cleanup = setupUniversalLinkListenerInNavigation(); + const cleanup = setupUniversalLinkListenerInNavigation({} as SelfClient); expect(mockLinking.addEventListener).toHaveBeenCalled(); cleanup(); expect(remove).toHaveBeenCalled(); diff --git a/app/tests/src/navigation/index.test.ts b/app/tests/src/navigation/index.test.ts deleted file mode 100644 index 4897dd12c..000000000 --- a/app/tests/src/navigation/index.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. -// SPDX-License-Identifier: BUSL-1.1 -// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. - -// Mock the navigation module to avoid deep import chains that overwhelm the parser -jest.mock('@/navigation', () => { - const mockScreens = { - // App screens - Home: {}, - Launch: {}, - Loading: {}, - Modal: {}, - Gratification: {}, - WebView: {}, - Points: {}, - // Onboarding screens - Disclaimer: {}, - Splash: {}, - // Documents screens - IDPicker: {}, - IdDetails: {}, - CountryPicker: { - statusBar: { hidden: true, style: 'dark' }, - }, - DocumentCamera: {}, - DocumentCameraTrouble: {}, - DocumentDataInfo: {}, - DocumentDataNotFound: {}, - DocumentNFCMethodSelection: {}, - DocumentNFCScan: {}, - DocumentNFCTrouble: {}, - DocumentOnboarding: {}, - ManageDocuments: {}, - // Verification screens - ConfirmBelonging: {}, - Prove: {}, - ProofHistory: {}, - ProofHistoryDetail: {}, - ProofRequestStatus: {}, - QRCodeViewFinder: {}, - QRCodeTrouble: {}, - // Account screens - AccountRecovery: {}, - AccountRecoveryChoice: {}, - AccountVerifiedSuccess: {}, - CloudBackupSettings: {}, - SaveRecoveryPhrase: {}, - ShowRecoveryPhrase: {}, - RecoverWithPhrase: {}, - Settings: {}, - Referral: {}, - DeferredLinkingInfo: {}, - // Shared screens - ComingSoon: {}, - // Dev screens - DevSettings: {}, - DevFeatureFlags: {}, - DevHapticFeedback: {}, - DevLoadingScreen: {}, - DevPrivateKey: {}, - CreateMock: {}, - MockDataDeepLink: {}, - // Aadhaar screens - AadhaarUpload: {}, - AadhaarUploadSuccess: {}, - AadhaarUploadError: {}, - }; - - return { - navigationScreens: mockScreens, - navigationRef: { current: null }, - }; -}); - -describe('navigation', () => { - it('should have the correct navigation screens', () => { - const navigationScreens = require('@/navigation').navigationScreens; - const listOfScreens = Object.keys(navigationScreens).sort(); - expect(listOfScreens).toEqual([ - 'AadhaarUpload', - 'AadhaarUploadError', - 'AadhaarUploadSuccess', - 'AccountRecovery', - 'AccountRecoveryChoice', - 'AccountVerifiedSuccess', - 'CloudBackupSettings', - 'ComingSoon', - 'ConfirmBelonging', - 'CountryPicker', - 'CreateMock', - 'DeferredLinkingInfo', - 'DevFeatureFlags', - 'DevHapticFeedback', - 'DevLoadingScreen', - 'DevPrivateKey', - 'DevSettings', - 'Disclaimer', - 'DocumentCamera', - 'DocumentCameraTrouble', - 'DocumentDataInfo', - 'DocumentDataNotFound', - 'DocumentNFCMethodSelection', - 'DocumentNFCScan', - 'DocumentNFCTrouble', - 'DocumentOnboarding', - 'Gratification', - 'Home', - 'IDPicker', - 'IdDetails', - 'Launch', - 'Loading', - 'ManageDocuments', - 'MockDataDeepLink', - 'Modal', - 'Points', - 'ProofHistory', - 'ProofHistoryDetail', - 'ProofRequestStatus', - 'Prove', - 'QRCodeTrouble', - 'QRCodeViewFinder', - 'RecoverWithPhrase', - 'Referral', - 'SaveRecoveryPhrase', - 'Settings', - 'ShowRecoveryPhrase', - 'Splash', - 'WebView', - ]); - }); -}); diff --git a/app/tests/src/proving/validateDocument.test.ts b/app/tests/src/proving/validateDocument.test.ts index c24c25516..f3fca732d 100644 --- a/app/tests/src/proving/validateDocument.test.ts +++ b/app/tests/src/proving/validateDocument.test.ts @@ -11,16 +11,20 @@ import { checkAndUpdateRegistrationStates, getAlternativeCSCA, } from '@/proving/validateDocument'; -import analytics from '@/services/analytics'; +import { trackEvent } from '@/services/analytics'; // Mock the analytics module to avoid side effects in tests -jest.mock('@/services/analytics', () => { - // Create mock inside factory to avoid temporal dead zone - const mockTrackEvent = jest.fn(); - return jest.fn(() => ({ - trackEvent: mockTrackEvent, - })); -}); +jest.mock('@/services/analytics', () => ({ + __esModule: true, + default: jest.fn(() => ({ + trackEvent: jest.fn(), + trackScreenView: jest.fn(), + flush: jest.fn(), + })), + trackEvent: jest.fn(), + trackScreenView: jest.fn(), + flush: jest.fn(), +})); // Mock the passport data provider to avoid database operations const mockGetAllDocumentsDirectlyFromKeychain = jest.fn(); @@ -153,8 +157,7 @@ describe('getAlternativeCSCA', () => { beforeEach(() => { jest.clearAllMocks(); // Get the mocked trackEvent from the analytics module - const mockAnalytics = jest.mocked(analytics); - mockTrackEvent = mockAnalytics().trackEvent as jest.Mock; + mockTrackEvent = jest.mocked(trackEvent) as jest.Mock; }); it('should return public keys in Record format for Aadhaar with valid public keys', () => { @@ -245,8 +248,7 @@ describe('checkAndUpdateRegistrationStates', () => { beforeEach(() => { jest.clearAllMocks(); // Get the mocked trackEvent from the analytics module - const mockAnalytics = jest.mocked(analytics); - mockTrackEvent = mockAnalytics().trackEvent as jest.Mock; + mockTrackEvent = jest.mocked(trackEvent) as jest.Mock; mockGetState.mockReturnValue( buildState({ diff --git a/app/tests/src/screens/GratificationScreen.test.tsx b/app/tests/src/screens/GratificationScreen.test.tsx index 608f4d270..5c4128398 100644 --- a/app/tests/src/screens/GratificationScreen.test.tsx +++ b/app/tests/src/screens/GratificationScreen.test.tsx @@ -97,7 +97,7 @@ jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({ })); jest.mock('@/assets/icons/arrow_left.svg', () => 'ArrowLeft'); -jest.mock('@/assets/icons/logo_white.svg', () => 'LogoWhite'); +jest.mock('@/assets/logos/self.svg', () => 'SelfLogo'); const mockUseNavigation = useNavigation as jest.MockedFunction< typeof useNavigation diff --git a/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx new file mode 100644 index 000000000..3ef5587da --- /dev/null +++ b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx @@ -0,0 +1,468 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useNavigation } from '@react-navigation/native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; +import { + getDocumentAttributes, + isDocumentValidForProving, + useSelfClient, +} from '@selfxyz/mobile-sdk-alpha'; + +import { usePassport } from '@/providers/passportDataProvider'; +import { DocumentSelectorForProvingScreen } from '@/screens/verification/DocumentSelectorForProvingScreen'; + +// Mock useFocusEffect to behave like useEffect in tests +// Note: We use a closure-based approach to avoid requiring React (prevents OOM per test-memory-optimization rules) +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + + // Track execution per component instance using a Map + const executionMap = new Map(); + + return { + ...actual, + useFocusEffect: (callback: () => void | (() => void)) => { + // Use a stable object as key - in real usage, callback is stable due to useCallback + if (!executionMap.has(callback)) { + executionMap.set(callback, true); + // Schedule callback to run after current render (simulates focus effect) + Promise.resolve().then(() => { + const cleanup = callback(); + if (typeof cleanup === 'function') { + cleanup(); + } + }); + } + }, + }; +}); + +// Mock the WalletAddressModal to avoid Modal rendering issues in tests +// Note: We return a simple string component directly to avoid requiring React (prevents OOM in CI) +jest.mock('@/components/proof-request/WalletAddressModal', () => ({ + WalletAddressModal: jest.fn(() => null), +})); + +jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + useSelfClient: jest.fn(), + getDocumentAttributes: jest.fn(), + isDocumentValidForProving: jest.fn(), +})); + +jest.mock('@/providers/passportDataProvider', () => ({ + usePassport: jest.fn(), +})); + +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockUseSelfClient = useSelfClient as jest.MockedFunction< + typeof useSelfClient +>; +const mockGetDocumentAttributes = getDocumentAttributes as jest.MockedFunction< + typeof getDocumentAttributes +>; +const mockIsDocumentValidForProving = + isDocumentValidForProving as jest.MockedFunction< + typeof isDocumentValidForProving + >; +const mockUsePassport = usePassport as jest.MockedFunction; + +type MockDocumentEntry = { + metadata: DocumentMetadata; + data: IDDocument; +}; + +const createMetadata = ( + overrides: Partial & { id: string }, +): DocumentMetadata => ({ + id: overrides.id, + documentType: overrides.documentType ?? 'us', + documentCategory: overrides.documentCategory ?? 'passport', + data: overrides.data ?? 'mock-data', + mock: overrides.mock ?? false, + isRegistered: overrides.isRegistered, + registeredAt: overrides.registeredAt, +}); + +const createDocumentEntry = ( + metadata: DocumentMetadata, + expiryDateSlice?: string, + nationalitySlice?: string, +): MockDocumentEntry => ({ + metadata, + data: { + documentType: metadata.documentType as any, + documentCategory: metadata.documentCategory as any, + mock: metadata.mock, + expiryDateSlice, + nationalitySlice, + } as unknown as IDDocument, +}); + +const createAllDocuments = (entries: MockDocumentEntry[]) => + entries.reduce< + Record + >((acc, entry) => { + acc[entry.metadata.id] = { + data: entry.data, + metadata: entry.metadata, + }; + return acc; + }, {}); + +const mockSelfApp = { + appName: 'Example App', + endpoint: 'https://example.com', + logoBase64: 'https://example.com/logo.png', + sessionId: 'session-id', + disclosures: { + name: true, + passport_number: true, + }, + userId: '0x1234567890abcdef1234567890abcdef12345678', + userIdType: 'hex', +}; + +const mockNavigate = jest.fn(); +const mockLoadDocumentCatalog = jest.fn(); +const mockGetAllDocuments = jest.fn(); +const mockSetSelectedDocument = jest.fn(); + +// Stable passport context to prevent infinite re-renders +const stablePassportContext = { + loadDocumentCatalog: mockLoadDocumentCatalog, + getAllDocuments: mockGetAllDocuments, + setSelectedDocument: mockSetSelectedDocument, +}; + +// Stable navigation object +const stableNavigation = { + navigate: mockNavigate, +}; + +// Stable self client selector function +const stableSelfAppSelector = ( + selector: (state: { selfApp: typeof mockSelfApp }) => unknown, +) => selector({ selfApp: mockSelfApp }); + +// Stable self client object +const stableSelfClient = { + useSelfAppStore: stableSelfAppSelector, +}; + +describe('DocumentSelectorForProvingScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseNavigation.mockReturnValue(stableNavigation as any); + + mockUseSelfClient.mockReturnValue(stableSelfClient as any); + + mockUsePassport.mockReturnValue(stablePassportContext as any); + + mockIsDocumentValidForProving.mockImplementation( + (_metadata, documentData) => + (documentData as { expiryDateSlice?: string } | undefined) + ?.expiryDateSlice !== 'expired', + ); + mockGetDocumentAttributes.mockImplementation((documentData: unknown) => ({ + nameSlice: '', + dobSlice: '', + yobSlice: '', + issuingStateSlice: '', + nationalitySlice: + (documentData as { nationalitySlice?: string })?.nationalitySlice || '', + passNoSlice: '', + sexSlice: '', + expiryDateSlice: + (documentData as { expiryDateSlice?: string })?.expiryDateSlice || '', + isPassportType: true, + })); + }); + + describe('Loading and Initial State', () => { + it('loads documents on mount and renders action bar', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + const { getByTestId } = render(); + + // Wait for documents to load and verify action bar buttons are rendered + // Note: Tamagui View doesn't forward testID, but Pressable children do + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve'), + ).toBeTruthy(); + expect( + getByTestId('document-selector-action-bar-document-selector'), + ).toBeTruthy(); + }); + + // Verify mocks were called + expect(mockLoadDocumentCatalog).toHaveBeenCalledTimes(1); + expect(mockGetAllDocuments).toHaveBeenCalledTimes(1); + }); + + it('renders wallet badge when userId is present', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve'), + ).toBeTruthy(); + }); + + // Wallet badge is a Pressable so testID works + expect( + getByTestId('document-selector-wallet-badge-pressable'), + ).toBeTruthy(); + }); + }); + + describe('Document Selection', () => { + it('enables approve button when valid documents exist', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + }); + + it('auto-selects first valid document when current selection is expired', async () => { + const expiredPassport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const validCard = createMetadata({ + id: 'doc-2', + documentType: 'ca', + documentCategory: 'id_card', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [expiredPassport, validCard], + selectedDocumentId: 'doc-1', // Currently selected is expired + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([ + createDocumentEntry(expiredPassport, 'expired'), + createDocumentEntry(validCard), + ]), + ); + + const { getByTestId } = render(); + + // Should auto-select the valid document (doc-2) + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + + // Approve should select the auto-selected valid document + fireEvent.press(getByTestId('document-selector-action-bar-approve')); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-2'); + }); + }); + + it('disables approve button when only expired documents exist', async () => { + const expiredPassport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const expiredCard = createMetadata({ + id: 'doc-2', + documentType: 'ca', + documentCategory: 'id_card', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [expiredPassport, expiredCard], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([ + createDocumentEntry(expiredPassport, 'expired'), + createDocumentEntry(expiredCard, 'expired'), + ]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(true); + }); + }); + }); + + describe('Navigation and Approval', () => { + it('navigates to Prove screen after successful approval', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + + // Press approve directly from action bar + fireEvent.press(getByTestId('document-selector-action-bar-approve')); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockNavigate).toHaveBeenCalledWith('Prove', expect.any(Object)); + }); + }); + }); + + describe('Error Handling', () => { + it('shows error state when document loading fails', async () => { + mockLoadDocumentCatalog.mockRejectedValue(new Error('failure')); + mockGetAllDocuments.mockResolvedValue({}); + + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + render(); + + // Wait for the load to fail and verify the error was logged + await waitFor(() => { + expect(mockLoadDocumentCatalog).toHaveBeenCalledTimes(1); + }); + + // Verify error was logged (component shows error state) + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to load documents:', + expect.any(Error), + ); + + consoleWarnSpy.mockRestore(); + }); + + it('shows error when document selection fails during approval', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + mockSetSelectedDocument.mockRejectedValue(new Error('Selection failed')); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('document-selector-action-bar-approve').props.disabled, + ).toBe(false); + }); + + // Press approve directly from action bar + fireEvent.press(getByTestId('document-selector-action-bar-approve')); + + // Verify error was logged and navigation did not occur + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to set selected document:', + expect.any(Error), + ); + }); + + expect(mockNavigate).not.toHaveBeenCalledWith( + 'Prove', + expect.any(Object), + ); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx b/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx new file mode 100644 index 000000000..027f2735a --- /dev/null +++ b/app/tests/src/screens/verification/ProvingScreenRouter.test.tsx @@ -0,0 +1,321 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useNavigation } from '@react-navigation/native'; +import { render, waitFor } from '@testing-library/react-native'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; +import { + isDocumentValidForProving, + pickBestDocumentToSelect, +} from '@selfxyz/mobile-sdk-alpha'; + +import { usePassport } from '@/providers/passportDataProvider'; +import { ProvingScreenRouter } from '@/screens/verification/ProvingScreenRouter'; +import { useSettingStore } from '@/stores/settingStore'; + +// Mock useFocusEffect to behave like useEffect in tests +// Note: We use jest.requireActual for React to avoid nested require() which causes OOM in CI +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + const ReactActual = jest.requireActual('react'); + return { + ...actual, + useFocusEffect: (callback: () => void) => { + ReactActual.useEffect(() => { + callback(); + }, [callback]); + }, + }; +}); + +jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + isDocumentValidForProving: jest.fn(), + pickBestDocumentToSelect: jest.fn(), +})); + +jest.mock('@/providers/passportDataProvider', () => ({ + usePassport: jest.fn(), +})); + +jest.mock('@/stores/settingStore', () => ({ + useSettingStore: jest.fn(), +})); + +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockIsDocumentValidForProving = + isDocumentValidForProving as jest.MockedFunction< + typeof isDocumentValidForProving + >; +const mockPickBestDocumentToSelect = + pickBestDocumentToSelect as jest.MockedFunction< + typeof pickBestDocumentToSelect + >; +const mockUsePassport = usePassport as jest.MockedFunction; +const mockUseSettingStore = useSettingStore as jest.MockedFunction< + typeof useSettingStore +>; +const mockReplace = jest.fn(); +const mockLoadDocumentCatalog = jest.fn(); +const mockGetAllDocuments = jest.fn(); +const mockSetSelectedDocument = jest.fn(); + +type MockDocumentEntry = { + metadata: DocumentMetadata; + data: IDDocument; +}; + +const createMetadata = ( + overrides: Partial & { id: string }, +): DocumentMetadata => ({ + id: overrides.id, + documentType: overrides.documentType ?? 'us', + documentCategory: overrides.documentCategory ?? 'passport', + data: overrides.data ?? 'mock-data', + mock: overrides.mock ?? false, + isRegistered: overrides.isRegistered, + registeredAt: overrides.registeredAt, +}); + +const createDocumentEntry = ( + metadata: DocumentMetadata, + expiryDateSlice?: string, +): MockDocumentEntry => ({ + metadata, + data: { + documentType: metadata.documentType as any, + documentCategory: metadata.documentCategory as any, + mock: metadata.mock, + expiryDateSlice, + } as unknown as IDDocument, +}); + +const createAllDocuments = (entries: MockDocumentEntry[]) => + entries.reduce< + Record + >((acc, entry) => { + acc[entry.metadata.id] = { + data: entry.data, + metadata: entry.metadata, + }; + return acc; + }, {}); + +describe('ProvingScreenRouter', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseNavigation.mockReturnValue({ replace: mockReplace } as any); + + mockUsePassport.mockReturnValue({ + loadDocumentCatalog: mockLoadDocumentCatalog, + getAllDocuments: mockGetAllDocuments, + setSelectedDocument: mockSetSelectedDocument, + } as any); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: false, + skipDocumentSelectorIfSingle: false, + } as any); + + mockIsDocumentValidForProving.mockImplementation( + (_metadata, documentData) => + (documentData as { expiryDateSlice?: string } | undefined) + ?.expiryDateSlice !== 'expired', + ); + }); + + it('routes to DocumentDataNotFound when no valid documents exist', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([ + createDocumentEntry(passport, 'expired'), + ]); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + render(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('DocumentDataNotFound'); + }); + }); + + it('auto-selects and routes to Prove when skipping the selector', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: true, + skipDocumentSelectorIfSingle: false, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + mockPickBestDocumentToSelect.mockReturnValue('doc-1'); + + render(); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockReplace).toHaveBeenCalledWith('Prove'); + }); + }); + + it('routes to the document selector when skipping is disabled', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + render(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving', { + documentType: 'Passport', + }); + }); + }); + + it('shows error state when document loading fails', async () => { + mockLoadDocumentCatalog.mockRejectedValue(new Error('failure')); + mockGetAllDocuments.mockResolvedValue({}); + + render(); + + // Verify that the load was attempted and navigation was NOT called + await waitFor(() => { + expect(mockLoadDocumentCatalog).toHaveBeenCalledTimes(1); + }); + + // The error path should NOT navigate anywhere + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('auto-selects when skipDocumentSelectorIfSingle is true with exactly 1 valid document', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: false, + skipDocumentSelectorIfSingle: true, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + mockPickBestDocumentToSelect.mockReturnValue('doc-1'); + + render(); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockReplace).toHaveBeenCalledWith('Prove'); + }); + }); + + it('shows document selector when skipDocumentSelectorIfSingle is true with multiple valid documents', async () => { + const passport1 = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const passport2 = createMetadata({ + id: 'doc-2', + documentType: 'gb', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport1, passport2], + }; + const allDocs = createAllDocuments([ + createDocumentEntry(passport1), + createDocumentEntry(passport2), + ]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: false, + skipDocumentSelectorIfSingle: true, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + + render(); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving', { + documentType: 'Passport', + }); + }); + + // Should NOT auto-select since there are multiple documents + expect(mockSetSelectedDocument).not.toHaveBeenCalled(); + }); + + it('falls back to document selector when setSelectedDocument fails', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + }; + const allDocs = createAllDocuments([createDocumentEntry(passport)]); + + mockUseSettingStore.mockReturnValue({ + skipDocumentSelector: true, + skipDocumentSelectorIfSingle: false, + } as any); + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue(allDocs); + mockPickBestDocumentToSelect.mockReturnValue('doc-1'); + mockSetSelectedDocument.mockRejectedValue(new Error('Selection failed')); + + render(); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + expect(mockReplace).toHaveBeenCalledWith('DocumentSelectorForProving', { + documentType: 'Passport', + }); + }); + }); +}); diff --git a/app/tests/src/services/analytics.test.ts b/app/tests/src/services/analytics.test.ts index 18e13f580..e0d599a8b 100644 --- a/app/tests/src/services/analytics.test.ts +++ b/app/tests/src/services/analytics.test.ts @@ -2,19 +2,17 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import analytics from '@/services/analytics'; +import { trackEvent, trackScreenView } from '@/services/analytics'; // Mock the Segment client jest.mock('@/config/segment', () => ({ createSegmentClient: jest.fn(() => ({ - track: jest.fn(), - screen: jest.fn(), + track: jest.fn().mockResolvedValue(undefined), + flush: jest.fn().mockResolvedValue(undefined), })), })); describe('analytics', () => { - const { trackEvent, trackScreenView } = analytics(); - beforeEach(() => { jest.clearAllMocks(); }); @@ -38,7 +36,7 @@ describe('analytics', () => { }); it('should handle event tracking with null properties', () => { - expect(() => trackEvent('test_event', null)).not.toThrow(); + expect(() => trackEvent('test_event', null as any)).not.toThrow(); }); it('should handle event tracking with undefined properties', () => { @@ -87,7 +85,7 @@ describe('analytics', () => { it('should handle invalid duration values gracefully', () => { const properties = { - duration_seconds: 'not_a_number', + duration_seconds: 'not_a_number' as any, }; expect(() => trackEvent('test_event', properties)).not.toThrow(); @@ -144,6 +142,22 @@ describe('analytics', () => { expect(() => trackEvent('test_event', properties)).not.toThrow(); }); + + it('should NOT transform regular event names (only screen views get "Viewed" prefix)', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + trackEvent('user_login', { method: 'google' }); + + expect(consoleSpy).toHaveBeenCalledWith( + '[DEV: Analytics EVENT]', + expect.objectContaining({ + name: 'user_login', // No "Viewed" prefix for regular events + properties: expect.objectContaining({ method: 'google' }), + }), + ); + + consoleSpy.mockRestore(); + }); }); describe('trackScreenView', () => { @@ -176,6 +190,98 @@ describe('analytics', () => { expect(() => trackScreenView('test_screen', properties)).not.toThrow(); }); + + it('should transform screen views to "Viewed ScreenName" format', () => { + // Mock console.log to capture dev mode output + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + trackScreenView('SplashScreen', { user_id: 123 }); + + expect(consoleSpy).toHaveBeenCalledWith( + '[DEV: Analytics SCREEN]', + expect.objectContaining({ + name: 'Viewed SplashScreen', + properties: expect.objectContaining({ user_id: 123 }), + }), + ); + + consoleSpy.mockRestore(); + }); + + it('should transform screen names correctly without properties', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + + trackScreenView('DocumentNFCScanScreen'); + + expect(consoleSpy).toHaveBeenCalledWith( + '[DEV: Analytics SCREEN]', + expect.objectContaining({ + name: 'Viewed DocumentNFCScanScreen', + properties: undefined, + }), + ); + + consoleSpy.mockRestore(); + }); + + it('should pass through properties unchanged', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const properties = { + referrer: 'home', + user_id: 456, + navigation_method: 'swipe', + }; + + trackScreenView('SettingsScreen', properties); + + expect(consoleSpy).toHaveBeenCalledWith( + '[DEV: Analytics SCREEN]', + expect.objectContaining({ + name: 'Viewed SettingsScreen', + properties: expect.objectContaining(properties), + }), + ); + + consoleSpy.mockRestore(); + }); + + it('should call segment client with transformed event name in production', () => { + // Temporarily mock __DEV__ to false for production testing + const originalDev = (global as any).__DEV__; + (global as any).__DEV__ = false; + + try { + // Reset modules first to clear the cache + jest.resetModules(); + + // Get the mocked segment client factory after reset + const segmentModule = require('@/config/segment'); + const mockTrack = jest.fn().mockResolvedValue(undefined); + + // Set up the mock implementation before re-requiring analytics + // This ensures the mock is properly configured when analytics module loads + segmentModule.createSegmentClient.mockImplementation(() => ({ + track: mockTrack, + flush: jest.fn().mockResolvedValue(undefined), + })); + + // Now re-require analytics to get a fresh segmentClient instance + // that uses our mocked createSegmentClient + const analyticsModule = require('@/services/analytics'); + + analyticsModule.trackScreenView('HomeScreen', { user_type: 'premium' }); + + expect(mockTrack).toHaveBeenCalledWith('Viewed HomeScreen', { + user_type: 'premium', + }); + } finally { + // Restore original __DEV__ value + (global as any).__DEV__ = originalDev; + + // Reset modules again to restore original state for other tests + jest.resetModules(); + } + }); }); describe('edge cases', () => { diff --git a/app/tests/src/services/logging.test.ts b/app/tests/src/services/logging.test.ts index 2c5aa3072..a141bba20 100644 --- a/app/tests/src/services/logging.test.ts +++ b/app/tests/src/services/logging.test.ts @@ -94,10 +94,9 @@ describe('Logging Service - Severity Updates', () => { }); }); - it('should update severity on root logger and all extended loggers when settings change', async () => { + it('should update severity on root logger when settings change', async () => { // Clear any calls from initialization mockRootSetSeverity.mockClear(); - mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear()); // Change the logging severity in the store useSettingStore.getState().setLoggingSeverity('debug'); @@ -106,30 +105,21 @@ describe('Logging Service - Severity Updates', () => { await new Promise(resolve => setTimeout(resolve, 10)); // Verify root logger was updated + // Extended loggers inherit severity from root logger automatically expect(mockRootSetSeverity).toHaveBeenCalledTimes(1); expect(mockRootSetSeverity).toHaveBeenCalledWith('debug'); - - // Verify each extended logger was updated - mockLoggerInstances.forEach(logger => { - expect(logger.setSeverity).toHaveBeenCalledTimes(1); - expect(logger.setSeverity).toHaveBeenCalledWith('debug'); - }); }); - it('should update each specific extended logger individually', async () => { + it('should update root logger severity which extends to all loggers', async () => { mockRootSetSeverity.mockClear(); - mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear()); useSettingStore.getState().setLoggingSeverity('info'); await new Promise(resolve => setTimeout(resolve, 10)); - // Verify specific loggers by name - const specificLoggers = ['APP', 'NFC', 'PASSPORT', 'PROOF']; - specificLoggers.forEach(loggerName => { - const logger = mockLoggerInstances.get(loggerName); - expect(logger).toBeDefined(); - expect(logger?.setSeverity).toHaveBeenCalledWith('info'); - }); + // Verify root logger was updated + // Extended loggers (APP, NFC, PASSPORT, PROOF, etc.) inherit from root + expect(mockRootSetSeverity).toHaveBeenCalledTimes(1); + expect(mockRootSetSeverity).toHaveBeenCalledWith('info'); }); it('should update severity for all severity levels', async () => { @@ -142,24 +132,18 @@ describe('Logging Service - Severity Updates', () => { for (const level of severityLevels) { mockRootSetSeverity.mockClear(); - mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear()); useSettingStore.getState().setLoggingSeverity(level); await new Promise(resolve => setTimeout(resolve, 10)); - // Verify root logger + // Verify root logger was updated + // Extended loggers inherit severity from root automatically expect(mockRootSetSeverity).toHaveBeenCalledWith(level); - - // Verify all extended loggers - mockLoggerInstances.forEach(logger => { - expect(logger.setSeverity).toHaveBeenCalledWith(level); - }); } }); it('should not call setSeverity if severity has not changed', async () => { mockRootSetSeverity.mockClear(); - mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear()); // Get current severity const currentSeverity = useSettingStore.getState().loggingSeverity; @@ -171,16 +155,10 @@ describe('Logging Service - Severity Updates', () => { // Should not call setSeverity on root logger expect(mockRootSetSeverity).not.toHaveBeenCalled(); - - // Should not call setSeverity on any extended logger - mockLoggerInstances.forEach(logger => { - expect(logger.setSeverity).not.toHaveBeenCalled(); - }); }); it('should handle rapid severity changes correctly', async () => { mockRootSetSeverity.mockClear(); - mockLoggerInstances.forEach(logger => logger.setSeverity.mockClear()); // Rapidly change severity multiple times useSettingStore.getState().setLoggingSeverity('debug'); @@ -192,15 +170,10 @@ describe('Logging Service - Severity Updates', () => { await new Promise(resolve => setTimeout(resolve, 50)); // Should have been called 4 times (once per change) + // Extended loggers inherit severity from root automatically expect(mockRootSetSeverity).toHaveBeenCalledTimes(4); // The last call should be 'error' expect(mockRootSetSeverity).toHaveBeenLastCalledWith('error'); - - // Each extended logger should also have been called 4 times - mockLoggerInstances.forEach(logger => { - expect(logger.setSeverity).toHaveBeenCalledTimes(4); - expect(logger.setSeverity).toHaveBeenLastCalledWith('error'); - }); }); }); diff --git a/app/tests/src/stores/database.test.ts b/app/tests/src/stores/database.test.ts index 5d7807eb8..5f6293e74 100644 --- a/app/tests/src/stores/database.test.ts +++ b/app/tests/src/stores/database.test.ts @@ -173,6 +173,43 @@ describe('database (SQLite)', () => { rowsAffected: 1, }); }); + + it('handles duplicate sessionId gracefully (INSERT OR IGNORE skips)', async () => { + const mockProof = { + appName: 'TestApp', + sessionId: 'session-123', + userId: 'user-456', + userIdType: 'uuid' as const, + endpointType: 'https' as const, + status: ProofStatus.PENDING, + disclosures: '{"test": "data"}', + logoBase64: 'base64-logo', + documentId: 'document-123', + endpoint: 'https://example.com/endpoint', + }; + + // Simulate INSERT OR IGNORE behavior when a duplicate sessionId exists + const mockInsertResult = { + insertId: 0, // SQLite returns 0 for ignored inserts + rowsAffected: 0, + }; + + mockDb.executeSql.mockResolvedValueOnce([mockInsertResult]); + + const result = await database.insertProof(mockProof); + + expect(mockDb.executeSql).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO proof_history'), + expect.any(Array), + ); + + // Should handle undefined/0 insertId gracefully + expect(result).toEqual({ + id: '0', + timestamp: expect.any(Number), + rowsAffected: 0, + }); + }); }); describe('updateProofStatus', () => { diff --git a/app/tests/src/stores/proofHistoryStore.test.ts b/app/tests/src/stores/proofHistoryStore.test.ts index 10d2c46d0..c34bf90ff 100644 --- a/app/tests/src/stores/proofHistoryStore.test.ts +++ b/app/tests/src/stores/proofHistoryStore.test.ts @@ -156,6 +156,35 @@ describe('proofHistoryStore', () => { expect(mockDatabase.insertProof).toHaveBeenCalledWith(mockProof); expect(useProofHistoryStore.getState().proofHistory).toHaveLength(0); }); + + it('handles duplicate insertion gracefully (rowsAffected = 0)', async () => { + const mockProof = { + appName: 'TestApp', + sessionId: 'session-123', + userId: 'user-456', + userIdType: 'uuid', + endpointType: 'celo', + status: ProofStatus.PENDING, + disclosures: '{"test": "data"}', + } as const; + + // Simulate INSERT OR IGNORE skipping the insertion due to duplicate sessionId + const mockInsertResult = { + id: '0', + timestamp: Date.now(), + rowsAffected: 0, + }; + + mockDatabase.insertProof.mockResolvedValue(mockInsertResult); + + await act(async () => { + await useProofHistoryStore.getState().addProofHistory(mockProof); + }); + + expect(mockDatabase.insertProof).toHaveBeenCalledWith(mockProof); + // Should not add to store when rowsAffected is 0 + expect(useProofHistoryStore.getState().proofHistory).toHaveLength(0); + }); }); describe('updateProofStatus', () => { diff --git a/app/tests/src/utils/keychainErrors.test.ts b/app/tests/src/utils/keychainErrors.test.ts new file mode 100644 index 000000000..7b12122f0 --- /dev/null +++ b/app/tests/src/utils/keychainErrors.test.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { + getKeychainErrorIdentity, + isKeychainCryptoError, + isUserCancellation, +} from '@/utils/keychainErrors'; + +describe('keychainErrors', () => { + it('identifies user cancellation errors', () => { + expect(isUserCancellation({ code: 'E_AUTHENTICATION_FAILED' })).toBe(true); + expect(isUserCancellation({ code: 'USER_CANCELED' })).toBe(true); + expect(isUserCancellation({ message: 'User canceled' })).toBe(true); + expect(isUserCancellation({ message: 'Authentication canceled' })).toBe( + true, + ); + expect(isUserCancellation({ message: 'cancelled by user' })).toBe(true); + }); + + it('does not classify non-cancellation errors as user cancellation', () => { + expect(isUserCancellation({ code: 'E_CRYPTO_FAILED' })).toBe(false); + expect(isUserCancellation({ message: 'Decryption failed' })).toBe(false); + expect(isUserCancellation({})).toBe(false); + }); + + it('identifies crypto failures and excludes user cancellations', () => { + expect(isKeychainCryptoError({ code: 'E_CRYPTO_FAILED' })).toBe(true); + expect( + isKeychainCryptoError({ + name: 'com.oblador.keychain.exceptions.CryptoFailedException', + }), + ).toBe(true); + expect( + isKeychainCryptoError({ + message: 'Authentication tag verification failed', + }), + ).toBe(true); + expect(isKeychainCryptoError({ message: 'Decryption failed' })).toBe(true); + expect( + isKeychainCryptoError({ + code: 'E_AUTHENTICATION_FAILED', + message: 'User canceled', + }), + ).toBe(false); + }); + + it('extracts keychain error identity safely', () => { + expect( + getKeychainErrorIdentity({ + code: 'E_CRYPTO_FAILED', + name: 'com.oblador.keychain.exceptions.CryptoFailedException', + }), + ).toEqual({ + code: 'E_CRYPTO_FAILED', + name: 'com.oblador.keychain.exceptions.CryptoFailedException', + }); + expect(getKeychainErrorIdentity({})).toEqual({ + code: undefined, + name: undefined, + }); + }); +}); diff --git a/app/version.json b/app/version.json index 0619495d8..12d4ce934 100644 --- a/app/version.json +++ b/app/version.json @@ -1,10 +1,10 @@ { "ios": { - "build": 197, - "lastDeployed": "2025-12-25T18:27:37.342Z" + "build": 205, + "lastDeployed": "2026-01-12T23:27:08.229Z" }, "android": { - "build": 127, - "lastDeployed": "2025-12-17T16:13:30.256Z" + "build": 134, + "lastDeployed": "2026-01-12T16:10:12.854Z" } } diff --git a/circuits/package.json b/circuits/package.json index a685fb730..b705547bc 100644 --- a/circuits/package.json +++ b/circuits/package.json @@ -5,13 +5,13 @@ "license": "MIT", "author": "self team", "scripts": { + "build:deps": "yarn workspaces foreach --from @selfxyz/circuits --topological-dev --recursive run build", "build-all": "bash scripts/build/build_register_circuits.sh && bash scripts/build/build_register_circuits_id.sh && bash scripts/build/build_register_aadhaar.sh && bash scripts/build/build_dsc_circuits.sh && bash scripts/build/build_disclose_circuits.sh", "build-disclose": "bash scripts/build/build_disclose_circuits.sh", "build-dsc": "bash scripts/build/build_dsc_circuits.sh", "build-gcp-jwt-verifier": "bash scripts/build/build_gcp_jwt_verifier.sh", "build-register": "bash scripts/build/build_register_circuits.sh", "build-register-id": "bash scripts/build/build_register_circuits_id.sh", - "build:deps": "yarn workspaces foreach --from @selfxyz/circuits --topological-dev --recursive run build", "download": "bash scripts/server/download_circuits_from_AWS.sh", "format": "prettier --write .", "install-circuits": "yarn workspaces focus @selfxyz/circuits", diff --git a/contracts/package.json b/contracts/package.json index 657bee537..9c3c0ce8d 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -70,11 +70,11 @@ "update:hub": "npx dotenv-cli -- bash -c 'NETWORK=${NETWORK} npx tsx scripts/setRegistry.ts'", "update:ofacroot": "npx dotenv-cli -- bash -c 'NETWORK=${NETWORK} npx tsx scripts/updateRegistryOfacRoot.ts'", "update:pcr0": "npx dotenv-cli -- bash -c 'PCR0_ACTION=${PCR0_ACTION:-add} PCR0_KEY=${PCR0_KEY} yarn hardhat ignition deploy ignition/modules/scripts/updatePCR0.ts --network ${NETWORK:-localhost} --reset'", + "upgrade": "npx dotenv-cli -- bash -c 'yarn hardhat upgrade --network ${NETWORK:-localhost}'", + "upgrade:history": "yarn hardhat upgrade:history", "upgrade:hub": "npx dotenv-cli -- bash -c 'yarn hardhat ignition deploy ignition/modules/upgrade/deployNewHubAndUpgrade.ts --network ${NETWORK:-localhost} ${VERIFY:+--verify}'", "upgrade:registry": "npx dotenv-cli -- bash -c 'yarn hardhat ignition deploy ignition/modules/upgrade/deployNewRegistryAndUpgrade.ts --network ${NETWORK:-localhost} ${VERIFY:+--verify}'", - "upgrade": "npx dotenv-cli -- bash -c 'yarn hardhat upgrade --network ${NETWORK:-localhost}'", - "upgrade:status": "npx dotenv-cli -- bash -c 'yarn hardhat upgrade:status --network ${NETWORK:-localhost}'", - "upgrade:history": "yarn hardhat upgrade:history" + "upgrade:status": "npx dotenv-cli -- bash -c 'yarn hardhat upgrade:status --network ${NETWORK:-localhost}'" }, "dependencies": { "@ashpect/smt": "https://github.com/ashpect/smt#main", diff --git a/package.json b/package.json index 8c8e06dce..2d0b2ee8d 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "build:demo": "yarn workspace mobile-sdk-demo build", "build:mobile-sdk": "yarn workspace @selfxyz/mobile-sdk-alpha build", "check:versions": "node scripts/check-package-versions.mjs", + "demo:mobile": "yarn build:mobile-sdk && yarn build:demo && yarn workspace mobile-sdk-demo start", "docstrings": "yarn docstrings:app && yarn docstrings:sdk", "docstrings:app": "yarn tsx scripts/docstring-report.ts \"app/src/**/*.{ts,tsx}\" --label \"Mobile App\" --write-report docs/coverage/app.json", "docstrings:sdk": "yarn tsx scripts/docstring-report.ts \"packages/mobile-sdk-alpha/src/**/*.{ts,tsx}\" --label \"Mobile SDK Alpha\" --write-report docs/coverage/sdk.json", - "demo:mobile": "yarn build:mobile-sdk && yarn build:demo && yarn workspace mobile-sdk-demo start", "format": "SKIP_BUILD_DEPS=1 yarn format:root && yarn format:github && SKIP_BUILD_DEPS=1 yarn workspaces foreach --parallel -i --all --exclude self-workspace-root run format", "format:github": "yarn prettier --parser yaml --write .github/**/*.yml --single-quote false", "format:root": "echo 'format markdown' && yarn prettier --parser markdown --write *.md && echo 'format yaml' && yarn prettier --parser yaml --write .*.{yml,yaml} --single-quote false && yarn prettier --write scripts/**/*.{js,mjs,ts} && yarn prettier --parser json --write scripts/**/*.json", diff --git a/packages/mobile-sdk-alpha/package.json b/packages/mobile-sdk-alpha/package.json index 2262c3301..a5a77be84 100644 --- a/packages/mobile-sdk-alpha/package.json +++ b/packages/mobile-sdk-alpha/package.json @@ -162,6 +162,7 @@ "zustand": "^4.5.2" }, "devDependencies": { + "@openpassport/zk-kit-lean-imt": "^0.0.6", "@testing-library/react": "^14.1.2", "@types/react": "^18.3.4", "@types/react-dom": "^18.3.0", @@ -177,6 +178,7 @@ "eslint-plugin-sort-exports": "^0.9.1", "jsdom": "^25.0.1", "lottie-react-native": "7.2.2", + "poseidon-lite": "^0.3.0", "prettier": "^3.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/mobile-sdk-alpha/src/client.ts b/packages/mobile-sdk-alpha/src/client.ts index a03337ad3..5903a7b8f 100644 --- a/packages/mobile-sdk-alpha/src/client.ts +++ b/packages/mobile-sdk-alpha/src/client.ts @@ -218,6 +218,7 @@ export function createSelfClient({ goTo: (routeName, params) => { adapters.navigation.goTo(routeName, params); }, + navigation: adapters.navigation, // for reactivity (if needed) useProvingStore, useSelfAppStore, diff --git a/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx b/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx index a9f8a45b6..dabffb598 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/AbstractButton.tsx @@ -15,16 +15,40 @@ export interface ButtonProps extends PressableProps { animatedComponent?: React.ReactNode; trackEvent?: string; borderWidth?: number; + borderColor?: string; + fontSize?: number; onLayout?: (event: LayoutChangeEvent) => void; } +/** + * Standard interface for extracting style props from button components. + * Use this to separate style-related props from other button props. + */ +export interface ExtractedButtonStyleProps { + borderWidth?: number; + borderColor?: string; + fontSize?: number; +} + interface AbstractButtonProps extends ButtonProps { bgColor: string; borderColor?: string; borderWidth?: number; + fontSize?: number; color: string; } +// Helper to extract border props from style object +function extractBorderFromStyle(style: ViewStyle | undefined): { + borderColor?: string; + borderWidth?: number; + restStyle: ViewStyle; +} { + if (!style) return { restStyle: {} }; + const { borderColor, borderWidth, ...restStyle } = style; + return { borderColor: borderColor as string | undefined, borderWidth, restStyle }; +} + /* Base Button component that can be used to create different types of buttons use PrimaryButton and SecondaryButton instead of this component or create a new button component @@ -35,8 +59,9 @@ export default function AbstractButton({ children, bgColor, color, - borderColor, - borderWidth = 4, + borderColor: propBorderColor, + borderWidth: propBorderWidth, + fontSize, style, animatedComponent, trackEvent, @@ -44,7 +69,15 @@ export default function AbstractButton({ ...props }: AbstractButtonProps) { const selfClient = useSelfClient(); - const hasBorder = borderColor ? true : false; + + // Extract border from style prop if provided there + const flatStyle = StyleSheet.flatten(style) as ViewStyle | undefined; + const { borderColor: styleBorderColor, borderWidth: styleBorderWidth, restStyle } = extractBorderFromStyle(flatStyle); + + // Props take precedence over style + const borderColor = propBorderColor ?? styleBorderColor; + const borderWidth = propBorderWidth ?? styleBorderWidth; + const hasBorder = borderColor != null; const handlePress = (e: GestureResponderEvent) => { if (trackEvent) { @@ -69,17 +102,16 @@ export default function AbstractButton({ { backgroundColor: bgColor }, hasBorder ? { - borderWidth: borderWidth, + borderWidth: borderWidth ?? 1, borderColor: borderColor, - padding: 20 - borderWidth, // Adjust padding to maintain total size } : Platform.select({ web: { borderWidth: 0 }, default: {} }), !animatedComponent && pressed ? pressedStyle : {}, - style as ViewStyle, + restStyle as ViewStyle, ]} > {animatedComponent} - {children} + {children} ); } diff --git a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx index 9ee8068a8..352997407 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx @@ -18,6 +18,7 @@ interface HeldPrimaryButtonProveScreenProps { onVerify: () => void; selectedAppSessionId: string | undefined | null; hasScrolledToBottom: boolean; + isScrollable: boolean; isReadyToProve: boolean; isDocumentExpired: boolean; } @@ -76,7 +77,11 @@ const buttonMachine = createMachine( }, { target: 'preparing', - guard: ({ context }) => context.hasScrolledToBottom, + guard: ({ context }) => context.hasScrolledToBottom && !context.isReadyToProve, + }, + { + target: 'ready', + guard: ({ context }) => context.hasScrolledToBottom && context.isReadyToProve && !context.isDocumentExpired, }, ], }, @@ -96,7 +101,7 @@ const buttonMachine = createMachine( }, ], after: { - 500: { target: 'preparing2' }, + 100: { target: 'preparing2' }, }, }, preparing2: { @@ -115,7 +120,7 @@ const buttonMachine = createMachine( }, ], after: { - 500: { target: 'preparing3' }, + 100: { target: 'preparing3' }, }, }, preparing3: { @@ -195,6 +200,7 @@ export const HeldPrimaryButtonProveScreen: React.FC { @@ -212,57 +218,42 @@ export const HeldPrimaryButtonProveScreen: React.FC = ({ text }) => ( + + + {text} + + ); const renderButtonContent = () => { if (isDocumentExpired) { return 'Document expired'; } if (state.matches('waitingForSession')) { - return ( - - - Waiting for app... - - ); + return ; } if (state.matches('needsScroll')) { - return 'Please read all disclosures'; + if (isScrollable) { + return 'Scroll to read full request'; + } + return ; } if (state.matches('preparing')) { - return ( - - - Accessing to Keychain data - - ); + return ; } if (state.matches('preparing2')) { - return ( - - - Parsing passport data - - ); + return ; } if (state.matches('preparing3')) { - return ( - - - Preparing for verification - - ); + return ; } if (state.matches('ready')) { - return 'Hold to verify'; + return 'Press and hold to verify'; } if (state.matches('verifying')) { - return ( - - - Generating proof - - ); + return ; } return null; }; diff --git a/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx b/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx index 99bfa3a28..74bee5fdf 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/PrimaryButton.tsx @@ -4,23 +4,41 @@ import { amber50, black, slate300, white } from '../../constants/colors'; import { normalizeBorderWidth } from '../../utils/styleUtils'; -import type { ButtonProps } from './AbstractButton'; +import type { ButtonProps, ExtractedButtonStyleProps } from './AbstractButton'; import AbstractButton from './AbstractButton'; +/** + * Extract standard style props for primary button. + * Separates border and font props from other button props. + */ +function extractPrimaryButtonStyleProps(props: Omit): { + styleProps: ExtractedButtonStyleProps; + restProps: Omit; +} { + const { borderWidth, borderColor, fontSize, ...restProps } = props; + return { + styleProps: { + borderWidth: normalizeBorderWidth(borderWidth), + borderColor, + fontSize, + }, + restProps, + }; +} + export function PrimaryButton({ children, ...props }: ButtonProps) { - const { borderWidth, ...restProps } = props; + const { styleProps, restProps } = extractPrimaryButtonStyleProps(props); const isDisabled = restProps.disabled; const bgColor = isDisabled ? white : black; const color = isDisabled ? slate300 : amber50; - const borderColor = isDisabled ? slate300 : undefined; - - const numericBorderWidth = normalizeBorderWidth(borderWidth); + const borderColor = isDisabled ? slate300 : styleProps.borderColor; return ( diff --git a/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx b/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx index 0212bd9c8..814674a06 100644 --- a/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx +++ b/packages/mobile-sdk-alpha/src/components/buttons/SecondaryButton.tsx @@ -4,25 +4,47 @@ import { slate200, slate300, slate500, white } from '../../constants/colors'; import { normalizeBorderWidth } from '../../utils/styleUtils'; -import type { ButtonProps } from './AbstractButton'; +import type { ButtonProps, ExtractedButtonStyleProps } from './AbstractButton'; import AbstractButton from './AbstractButton'; -export function SecondaryButton({ children, ...props }: ButtonProps) { - const { borderWidth, ...restProps } = props; +export interface SecondaryButtonProps extends ButtonProps { + textColor?: string; +} + +/** + * Extract standard style props for secondary button. + * Separates border and font props from other button props. + */ +function extractSecondaryButtonStyleProps(props: Omit): { + styleProps: ExtractedButtonStyleProps; + restProps: Omit; +} { + const { borderWidth, borderColor, fontSize, ...restProps } = props; + return { + styleProps: { + borderWidth: normalizeBorderWidth(borderWidth), + borderColor, + fontSize, + }, + restProps, + }; +} + +export function SecondaryButton({ children, textColor, ...props }: SecondaryButtonProps) { + const { styleProps, restProps } = extractSecondaryButtonStyleProps(props); const isDisabled = restProps.disabled; const bgColor = isDisabled ? white : slate200; - const color = isDisabled ? slate300 : slate500; - const borderColor = isDisabled ? slate200 : undefined; - - const numericBorderWidth = normalizeBorderWidth(borderWidth); + const color = textColor ?? (isDisabled ? slate300 : slate500); + const borderColor = isDisabled ? slate300 : styleProps.borderColor; return ( {children} diff --git a/packages/mobile-sdk-alpha/src/components/index.ts b/packages/mobile-sdk-alpha/src/components/index.ts index 9f76f5f5d..02a33013d 100644 --- a/packages/mobile-sdk-alpha/src/components/index.ts +++ b/packages/mobile-sdk-alpha/src/components/index.ts @@ -2,53 +2,33 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. +export type { ButtonProps, ExtractedButtonStyleProps } from './buttons/AbstractButton'; +// Type exports +export type { SecondaryButtonProps } from './buttons/SecondaryButton'; export type { ViewProps } from './layout/View'; +// Button components export { default as AbstractButton } from './buttons/AbstractButton'; - -// Typography components export { default as Additional } from './typography/Additional'; - -// Layout components export { BodyText } from './typography/BodyText'; export { Button } from './layout/Button'; export { default as ButtonsContainer } from './ButtonsContainer'; export { Caption } from './typography/Caption'; export { default as Caution } from './typography/Caution'; - export { default as Description } from './typography/Description'; - export { DescriptionTitle } from './typography/DescriptionTitle'; - export { HeldPrimaryButton } from './buttons/PrimaryButtonLongHold'; - export { HeldPrimaryButtonProveScreen } from './buttons/HeldPrimaryButtonProveScreen'; - export { MRZScannerView } from './MRZScannerView'; - -// Button components export { PrimaryButton } from './buttons/PrimaryButton'; - -// Flag components export { RoundFlag } from './flag/RoundFlag'; - export { SecondaryButton } from './buttons/SecondaryButton'; - export { SubHeader } from './typography/SubHeader'; - export { Text } from './layout/Text'; - export { default as TextsContainer } from './TextsContainer'; - export { Title } from './typography/Title'; - export { View } from './layout/View'; - export { XStack } from './layout/XStack'; - -// Export types export { YStack } from './layout/YStack'; - export { pressedStyle } from './buttons/pressedStyle'; - export { typography } from './typography/styles'; diff --git a/packages/mobile-sdk-alpha/src/constants/colors.ts b/packages/mobile-sdk-alpha/src/constants/colors.ts index dbb2062ad..1381b085f 100644 --- a/packages/mobile-sdk-alpha/src/constants/colors.ts +++ b/packages/mobile-sdk-alpha/src/constants/colors.ts @@ -19,6 +19,9 @@ export const cyan300 = '#67E8F9'; export const emerald500 = '#10B981'; export const green500 = '#22C55E'; +export const green600 = '#16A34A'; + +export const iosSeparator = 'rgba(60,60,67,0.36)'; export const neutral400 = '#A3A3A3'; diff --git a/packages/mobile-sdk-alpha/src/constants/index.ts b/packages/mobile-sdk-alpha/src/constants/index.ts new file mode 100644 index 000000000..eba2b56b7 --- /dev/null +++ b/packages/mobile-sdk-alpha/src/constants/index.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +export { + AadhaarEvents, + AppEvents, + AuthEvents, + BackupEvents, + DocumentEvents, + MockDataEvents, + NotificationEvents, + PassportEvents, + PointEvents, + ProofEvents, +} from './analytics'; + +export { NFC_IMAGE } from './images'; + +export { advercase, dinot, dinotBold, plexMono } from './fonts'; +export { + amber50, + amber500, + black, + blue100, + blue600, + blue700, + borderColor, + charcoal, + cyan300, + emerald500, + green500, + green600, + iosSeparator, + neutral400, + neutral700, + red500, + separatorColor, + sky500, + slate100, + slate200, + slate300, + slate400, + slate50, + slate500, + slate600, + slate700, + slate800, + slate900, + teal300, + teal500, + textBlack, + white, + yellow500, + zinc400, + zinc500, + zinc800, + zinc900, +} from './colors'; +export { extraYPadding } from './layout'; diff --git a/packages/mobile-sdk-alpha/src/documents/validation.ts b/packages/mobile-sdk-alpha/src/documents/validation.ts new file mode 100644 index 000000000..fb0f1bdd9 --- /dev/null +++ b/packages/mobile-sdk-alpha/src/documents/validation.ts @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import type { AadhaarData, DocumentMetadata, IDDocument } from '@selfxyz/common'; +import { attributeToPosition, attributeToPosition_ID } from '@selfxyz/common/constants'; +import type { PassportData } from '@selfxyz/common/types/passport'; +import type { DocumentCatalog } from '@selfxyz/common/utils/types'; +import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types'; + +export interface DocumentAttributes { + nameSlice: string; + dobSlice: string; + yobSlice: string; + issuingStateSlice: string; + nationalitySlice: string; + passNoSlice: string; + sexSlice: string; + expiryDateSlice: string; + isPassportType: boolean; +} + +/** + * Checks if a document expiration date (in YYMMDD format) has passed. + * We assume dateOfExpiry is this century because ICAO standard for biometric passport + * became standard around 2002. + * + * @param dateOfExpiry - Expiration date in YYMMDD format from MRZ + * @returns true if the document is expired, false otherwise + */ +export function checkDocumentExpiration(dateOfExpiry: string): boolean { + if (!dateOfExpiry || dateOfExpiry.length !== 6) { + return false; // Invalid format, don't treat as expired + } + + const year = parseInt(dateOfExpiry.slice(0, 2), 10); + const fullyear = 2000 + year; + const month = parseInt(dateOfExpiry.slice(2, 4), 10) - 1; // JS months are 0-indexed + const day = parseInt(dateOfExpiry.slice(4, 6), 10); + + const expiryDateUTC = new Date(Date.UTC(fullyear, month, day, 0, 0, 0, 0)); + const nowUTC = new Date(); + const todayUTC = new Date(Date.UTC(nowUTC.getFullYear(), nowUTC.getMonth(), nowUTC.getDate(), 0, 0, 0, 0)); + + return todayUTC >= expiryDateUTC; +} + +/** + * Extracts attributes from Aadhaar document data + */ +function getAadhaarAttributes(document: AadhaarData): DocumentAttributes { + const extractedFields = document.extractedFields; + // For Aadhaar, we format the name to work with the existing getNameAndSurname function + // We'll put the full name in the "surname" position and leave names empty + const fullName = extractedFields?.name || ''; + const nameSliceFormatted = fullName ? `${fullName}<<` : ''; // Format like MRZ + + // Format DOB to YYMMDD for consistency with passport format + let dobFormatted = ''; + if (extractedFields?.dob && extractedFields?.mob && extractedFields?.yob) { + const year = extractedFields.yob.length === 4 ? extractedFields.yob.slice(-2) : extractedFields.yob; + const month = extractedFields.mob.padStart(2, '0'); + const day = extractedFields.dob.padStart(2, '0'); + dobFormatted = `${year}${month}${day}`; + } + + return { + nameSlice: nameSliceFormatted, + dobSlice: dobFormatted, + yobSlice: extractedFields?.yob || '', + issuingStateSlice: extractedFields?.state || '', + nationalitySlice: 'IND', // Aadhaar is always Indian + passNoSlice: extractedFields?.aadhaarLast4Digits || '', + sexSlice: + extractedFields?.gender === 'M' ? 'M' : extractedFields?.gender === 'F' ? 'F' : extractedFields?.gender || '', + expiryDateSlice: '', // Aadhaar doesn't expire + isPassportType: false, + }; +} + +/** + * Extracts attributes from MRZ string (passport or ID card) + */ +function getPassportAttributes(mrz: string, documentCategory: string): DocumentAttributes { + const isPassportType = documentCategory === 'passport'; + const attributePositions = isPassportType ? attributeToPosition : attributeToPosition_ID; + + const nameSlice = mrz.slice(attributePositions.name[0], attributePositions.name[1]); + const dobSlice = mrz.slice(attributePositions.date_of_birth[0], attributePositions.date_of_birth[1] + 1); + const yobSlice = mrz.slice(attributePositions.date_of_birth[0], attributePositions.date_of_birth[0] + 2); + const issuingStateSlice = mrz.slice(attributePositions.issuing_state[0], attributePositions.issuing_state[1] + 1); + const nationalitySlice = mrz.slice(attributePositions.nationality[0], attributePositions.nationality[1] + 1); + const passNoSlice = mrz.slice(attributePositions.passport_number[0], attributePositions.passport_number[1] + 1); + const sexSlice = mrz.slice(attributePositions.gender[0], attributePositions.gender[1] + 1); + const expiryDateSlice = mrz.slice(attributePositions.expiry_date[0], attributePositions.expiry_date[1] + 1); + return { + nameSlice, + dobSlice, + yobSlice, + issuingStateSlice, + nationalitySlice, + passNoSlice, + sexSlice, + expiryDateSlice, + isPassportType, + }; +} + +/** + * Extracts document attributes from passport, ID card, or Aadhaar data. + * + * @param document - Document data (PassportData, AadhaarData, or IDDocument) + * @returns Document attributes including name, DOB, expiry date, etc. + */ +export function getDocumentAttributes(document: PassportData | AadhaarData): DocumentAttributes { + if (isAadhaarDocument(document)) { + return getAadhaarAttributes(document); + } else if (isMRZDocument(document)) { + return getPassportAttributes(document.mrz, document.documentCategory); + } else { + // Fallback for unknown document types + return { + nameSlice: '', + dobSlice: '', + yobSlice: '', + issuingStateSlice: '', + nationalitySlice: '', + passNoSlice: '', + sexSlice: '', + expiryDateSlice: '', + isPassportType: false, + }; + } +} + +/** + * Checks if a document is valid for use in proving flows. + * A document is valid if it is not expired. + * Mock documents are considered valid for testing with staging environments. + * + * @param metadata - Document metadata from catalog + * @param documentData - Full document data (optional, used for expiry check) + * @returns true if document can be used for proving + */ +export function isDocumentValidForProving(metadata: DocumentMetadata, documentData?: IDDocument): boolean { + // Check if expired + if (documentData) { + try { + const attributes = getDocumentAttributes(documentData); + if (attributes.expiryDateSlice && checkDocumentExpiration(attributes.expiryDateSlice)) { + return false; + } + } catch { + // If we can't check expiry, assume valid + } + } + + return true; +} + +/** + * Picks the best document to auto-select from a catalog. + * Prefers the currently selected document if valid, otherwise picks the first valid one. + * + * @param catalog - Document catalog + * @param documents - Map of document ID to document data + * @returns Document ID to select, or undefined if no valid documents + */ +export function pickBestDocumentToSelect( + catalog: DocumentCatalog, + documents: Record, +): string | undefined { + // Check if currently selected document is valid + if (catalog.selectedDocumentId) { + const selectedMeta = catalog.documents.find(doc => doc.id === catalog.selectedDocumentId); + const selectedData = selectedMeta ? documents[catalog.selectedDocumentId] : undefined; + + if (selectedMeta && isDocumentValidForProving(selectedMeta, selectedData?.data)) { + return catalog.selectedDocumentId; + } + } + + // Find first valid document + const firstValid = catalog.documents.find(doc => { + const docData = documents[doc.id]; + return isDocumentValidForProving(doc, docData?.data); + }); + + return firstValid?.id; +} diff --git a/packages/mobile-sdk-alpha/src/index.ts b/packages/mobile-sdk-alpha/src/index.ts index b1517f99c..7ebeae490 100644 --- a/packages/mobile-sdk-alpha/src/index.ts +++ b/packages/mobile-sdk-alpha/src/index.ts @@ -33,6 +33,8 @@ export type { BaseContext, NFCScanContext, ProofContext } from './proving/intern export type { DG1, DG2, ParsedNFCResponse } from './nfc'; +export type { DocumentAttributes } from './documents/validation'; + export type { DocumentData, DocumentMetadata, PassportCameraProps, ScreenProps } from './types/ui'; export type { HapticOptions, HapticType } from './haptic/shared'; @@ -97,7 +99,13 @@ export { triggerFeedback, } from './haptic'; -/** @deprecated Use createSelfClient().extractMRZInfo or import from './mrz' */ +export { + checkDocumentExpiration, + getDocumentAttributes, + isDocumentValidForProving, + pickBestDocumentToSelect, +} from './documents/validation'; + export { clearPassportData, getAllDocuments, @@ -114,9 +122,10 @@ export { defaultConfig } from './config/defaults'; export { defaultOptions } from './haptic/shared'; -export { extractMRZInfo, extractNameFromMRZ, formatDateToYYMMDD } from './mrz'; - +/** @deprecated Use createSelfClient().extractMRZInfo or import from './mrz' */ +export { extractMRZInfo } from './mrz'; export { extractNameFromDocument } from './documents/utils'; +export { extractNameFromMRZ, formatDateToYYMMDD } from './mrz'; export { generateMockDocument, signatureAlgorithmToStrictSignatureAlgorithm } from './mock/generator'; diff --git a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts index 50ffd4e6b..0aa503dfe 100644 --- a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts +++ b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts @@ -458,6 +458,9 @@ export const useProvingStore = create((set, get) => { if (get().circuitType === 'disclose') { selfClient.getSelfAppState().handleProofResult(true); } + + // Disable keychain error modal when proving flow ends + selfClient.navigation?.disableKeychainErrorModal?.(); } if (state.value === 'passport_not_supported') { @@ -482,6 +485,8 @@ export const useProvingStore = create((set, get) => { if (get().circuitType === 'disclose') { selfClient.getSelfAppState().handleProofResult(false, 'error', 'error'); } + // Disable keychain error modal when proving flow ends + selfClient.navigation?.disableKeychainErrorModal?.(); } }); } @@ -844,6 +849,10 @@ export const useProvingStore = create((set, get) => { selfClient.trackEvent(ProofEvents.PROVING_INIT); get()._closeConnections(selfClient); + // Enable keychain error modal for proving flows + // This ensures users are notified if keychain access fails during critical operations + selfClient.navigation?.enableKeychainErrorModal?.(); + if (actor) { try { actor.stop(); @@ -884,7 +893,6 @@ export const useProvingStore = create((set, get) => { } const { data: passportData } = selectedDocument; - const secret = await selfClient.getPrivateKey(); if (!secret) { console.error('Could not load secret'); diff --git a/packages/mobile-sdk-alpha/src/types/public.ts b/packages/mobile-sdk-alpha/src/types/public.ts index c24c40a61..df74c79a1 100644 --- a/packages/mobile-sdk-alpha/src/types/public.ts +++ b/packages/mobile-sdk-alpha/src/types/public.ts @@ -229,6 +229,8 @@ export type RouteName = export interface NavigationAdapter { goBack(): void; goTo(routeName: RouteName, params?: Record): void; + enableKeychainErrorModal?(): void; + disableKeychainErrorModal?(): void; } /** @@ -316,6 +318,7 @@ export interface SelfClient { extractMRZInfo(mrz: string): MRZInfo; goBack(): void; goTo(routeName: RouteName, params?: Record): void; + navigation: NavigationAdapter; /** * Convenience wrapper around {@link AnalyticsAdapter.trackEvent}. Calls are diff --git a/packages/mobile-sdk-alpha/tests/components/buttons/AbstractButton.test.tsx b/packages/mobile-sdk-alpha/tests/components/buttons/AbstractButton.test.tsx new file mode 100644 index 000000000..e10a0cf32 --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/components/buttons/AbstractButton.test.tsx @@ -0,0 +1,352 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +/* @vitest-environment jsdom */ +import type { ReactNode } from 'react'; +import { Platform } from 'react-native'; +import { describe, expect, it, vi } from 'vitest'; + +import AbstractButton from '../../../src/components/buttons/AbstractButton'; +import { SelfClientProvider } from '../../../src/index'; +import { mockAdapters } from '../../utils/testHelpers'; + +import { render } from '@testing-library/react'; + +// Helper to wrap component in SelfClientProvider +function TestWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +describe('AbstractButton', () => { + describe('borderColor prop', () => { + it('should apply borderColor from prop', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + // Note: In jsdom, styles are applied as inline styles or style objects + // The actual style checking depends on how react-native-web or mocks handle it + }); + + it('should apply borderColor from style prop', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + + it('should prioritize borderColor prop over style', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + + it('should handle borderWidth prop', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('fontSize prop', () => { + it('should apply fontSize from prop', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + const text = button?.querySelector('span'); + expect(text).toBeTruthy(); + expect(text?.textContent).toBe('Test Button'); + }); + + it('should use default fontSize of 18 when not provided', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + const text = button?.querySelector('span'); + expect(text).toBeTruthy(); + }); + + it('should accept various fontSize values', () => { + const fontSizes = [12, 16, 20, 24, 28, 32]; + + fontSizes.forEach(fontSize => { + const { container } = render( + + + Test {fontSize} + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + }); + + describe('Platform.select behavior', () => { + it('should apply borderWidth: 0 on web when no border is specified', () => { + // Platform is mocked as 'web' in setup.ts + expect(Platform.OS).toBe('web'); + + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + + it('should not apply borderWidth: 0 when border is specified', () => { + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('event tracking', () => { + it('should call trackEvent when trackEvent prop is provided', () => { + // This test verifies the trackEvent functionality exists + // The actual implementation is tested through the SelfClient + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + + it('should parse event category from trackEvent string', () => { + // Tests that "Category: Event" format gets parsed to "Event" + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('style prop handling', () => { + it('should merge style prop with internal styles', () => { + const customStyle = { + padding: 10, + backgroundColor: 'blue', + }; + + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + + it('should handle StyleSheet.flatten for style prop', () => { + const style1 = { padding: 10 }; + const style2 = { margin: 5 }; + const combinedStyle = [style1, style2]; + + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('disabled state', () => { + it('should accept disabled prop', () => { + const { container } = render( + + + Disabled Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('animatedComponent', () => { + it('should render animatedComponent when provided', () => { + const AnimatedComponent =
Animated
; + + const { container, getByTestId } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(getByTestId('animated')).toBeTruthy(); + }); + }); + + describe('cross-platform compatibility', () => { + it('should render consistently on web platform', () => { + // Verify Platform.OS is 'web' as expected from setup.ts + expect(Platform.OS).toBe('web'); + + const { container } = render( + + + Cross-Platform Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.textContent).toBe('Cross-Platform Button'); + }); + + it('should handle Platform.select correctly', () => { + // Verify that Platform.select returns web or default values + const result = Platform.select({ web: 'web-value', default: 'default-value' }); + expect(result).toBe('web-value'); + + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + }); + }); + + describe('onPress handling', () => { + it('should call onPress when button is pressed', () => { + const onPressMock = vi.fn(); + + const { container } = render( + + + Test Button + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + + // Note: In jsdom environment, we can't easily simulate Pressable's onPress + // This test verifies the button is renderable with onPress prop + }); + }); + + describe('children rendering', () => { + it('should render children as text', () => { + const { container } = render( + + + Button Text + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.textContent).toBe('Button Text'); + }); + + it('should render complex children', () => { + const { container } = render( + + + {'Button '} + {'with '} + {'multiple '} + {'parts'} + + , + ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.textContent).toBe('Button with multiple parts'); + }); + }); +}); diff --git a/packages/mobile-sdk-alpha/tests/documents/validation.test.ts b/packages/mobile-sdk-alpha/tests/documents/validation.test.ts new file mode 100644 index 000000000..7a5001324 --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/documents/validation.test.ts @@ -0,0 +1,314 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { describe, expect, it } from 'vitest'; + +import type { DocumentCatalog, DocumentMetadata } from '@selfxyz/common/types'; +import type { PassportData } from '@selfxyz/common/types/passport'; + +import { + checkDocumentExpiration, + isDocumentValidForProving, + pickBestDocumentToSelect, +} from '../../src/documents/validation'; + +describe('checkDocumentExpiration', () => { + it('returns false for invalid format (too short)', () => { + expect(checkDocumentExpiration('1234')).toBe(false); + }); + + it('returns false for invalid format (too long)', () => { + expect(checkDocumentExpiration('1234567')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(checkDocumentExpiration('')).toBe(false); + }); + + it('returns true for expired date (past date)', () => { + // Date in 2020 + expect(checkDocumentExpiration('200101')).toBe(true); + }); + + it('returns false for future date', () => { + // Date in 2050 + expect(checkDocumentExpiration('500101')).toBe(false); + }); + + it('returns true for today (expired as of today)', () => { + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); + const month = (now.getMonth() + 1).toString().padStart(2, '0'); + const day = now.getDate().toString().padStart(2, '0'); + const today = `${year}${month}${day}`; + // Document that expires today is considered expired + expect(checkDocumentExpiration(today)).toBe(true); + }); + + it('returns true for yesterday (expired)', () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const year = yesterday.getFullYear().toString().slice(-2); + const month = (yesterday.getMonth() + 1).toString().padStart(2, '0'); + const day = yesterday.getDate().toString().padStart(2, '0'); + const yesterdayStr = `${year}${month}${day}`; + expect(checkDocumentExpiration(yesterdayStr)).toBe(true); + }); +}); + +describe('isDocumentValidForProving', () => { + const mockMetadata: DocumentMetadata = { + id: 'test-id', + documentType: 'passport', + documentCategory: 'passport', + data: 'mock-data', + mock: false, + }; + + it('returns true for document without data (cannot check expiry)', () => { + expect(isDocumentValidForProving(mockMetadata)).toBe(true); + }); + + it('returns true for mock document', () => { + const mockDoc: DocumentMetadata = { + ...mockMetadata, + mock: true, + }; + expect(isDocumentValidForProving(mockDoc)).toBe(true); + }); + + it('returns true for valid passport with future expiry', () => { + // MRZ with expiry date 501231 (December 31, 2050) + const validPassport: PassportData = { + mrz: 'P { + // Passport expired in 2012 + const expiredPassport: PassportData = { + mrz: 'P { + const invalidDocument = { + documentType: 'passport', + documentCategory: 'passport', + mock: false, + } as any; + + expect(isDocumentValidForProving(mockMetadata, invalidDocument)).toBe(true); + }); +}); + +describe('pickBestDocumentToSelect', () => { + // MRZ with expiry date 501231 (December 31, 2050) + const validPassport: PassportData = { + mrz: 'P { + const catalog: DocumentCatalog = { + documents: [], + }; + expect(pickBestDocumentToSelect(catalog, {})).toBeUndefined(); + }); + + it('returns currently selected document if valid', () => { + const metadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [metadata], + selectedDocumentId: 'doc1', + }; + + const documents = { + doc1: { data: validPassport, metadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc1'); + }); + + it('returns first valid document if currently selected is expired', () => { + const expiredMetadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const validMetadata: DocumentMetadata = { + id: 'doc2', + documentType: 'passport', + documentCategory: 'passport', + data: 'data2', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [expiredMetadata, validMetadata], + selectedDocumentId: 'doc1', + }; + + const documents = { + doc1: { data: expiredPassport, metadata: expiredMetadata }, + doc2: { data: validPassport, metadata: validMetadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc2'); + }); + + it('returns first valid document if no document is selected', () => { + const metadata1: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const metadata2: DocumentMetadata = { + id: 'doc2', + documentType: 'passport', + documentCategory: 'passport', + data: 'data2', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [metadata1, metadata2], + }; + + const documents = { + doc1: { data: validPassport, metadata: metadata1 }, + doc2: { data: validPassport, metadata: metadata2 }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc1'); + }); + + it('returns undefined if all documents are expired', () => { + const metadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'data1', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [metadata], + }; + + const documents = { + doc1: { data: expiredPassport, metadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBeUndefined(); + }); + + it('selects mock document if it is the only option', () => { + const mockMetadata: DocumentMetadata = { + id: 'doc1', + documentType: 'passport', + documentCategory: 'passport', + data: 'mock-data', + mock: true, + }; + + const catalog: DocumentCatalog = { + documents: [mockMetadata], + }; + + const mockPassport: PassportData = { + ...validPassport, + mock: true, + }; + + const documents = { + doc1: { data: mockPassport, metadata: mockMetadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('doc1'); + }); + + it('prefers selected document even if it is mock', () => { + const mockMetadata: DocumentMetadata = { + id: 'mock1', + documentType: 'passport', + documentCategory: 'passport', + data: 'mock-data', + mock: true, + }; + + const realMetadata: DocumentMetadata = { + id: 'real1', + documentType: 'passport', + documentCategory: 'passport', + data: 'real-data', + mock: false, + }; + + const catalog: DocumentCatalog = { + documents: [mockMetadata, realMetadata], + selectedDocumentId: 'mock1', + }; + + const mockPassport: PassportData = { + ...validPassport, + mock: true, + }; + + const documents = { + mock1: { data: mockPassport, metadata: mockMetadata }, + real1: { data: validPassport, metadata: realMetadata }, + }; + + expect(pickBestDocumentToSelect(catalog, documents)).toBe('mock1'); + }); +}); diff --git a/packages/mobile-sdk-alpha/tests/proving/internal/payloadGenerator.test.ts b/packages/mobile-sdk-alpha/tests/proving/internal/payloadGenerator.test.ts new file mode 100644 index 000000000..e12b5384c --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/proving/internal/payloadGenerator.test.ts @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import type { SelfClient } from '../../../src'; +import { useProvingStore } from '../../../src/proving/provingMachine'; +import { useProtocolStore } from '../../../src/stores/protocolStore'; +import { useSelfAppStore } from '../../../src/stores/selfAppStore'; +import { actorMock } from '../actorMock'; + +vitest.mock('xstate', () => { + return { + createActor: vitest.fn(() => actorMock), + createMachine: vitest.fn(), + assign: vitest.fn(), + send: vitest.fn(), + spawn: vitest.fn(), + interpret: vitest.fn(), + fromPromise: vitest.fn(), + fromObservable: vitest.fn(), + fromEventObservable: vitest.fn(), + fromCallback: vitest.fn(), + fromTransition: vitest.fn(), + fromReducer: vitest.fn(), + fromRef: vitest.fn(), + }; +}); + +vitest.mock('@selfxyz/common/utils/proving', async () => { + const actual = await vitest.importActual( + '@selfxyz/common/utils/proving', + ); + return { + ...actual, + getPayload: vitest.fn(() => ({ payload: true })), + encryptAES256GCM: vitest.fn(() => ({ + nonce: [1], + cipher_text: [2], + auth_tag: [3], + })), + }; +}); + +vitest.mock('@selfxyz/common/utils/circuits/registerInputs', async () => { + const actual = (await vitest.importActual('@selfxyz/common/utils/circuits/registerInputs')) as any; + return { + ...actual, + generateTEEInputsRegister: vitest.fn(async () => ({ + inputs: { reg: true }, + circuitName: 'register_circuit', + endpointType: 'celo', + endpoint: 'https://register', + })), + generateTEEInputsDSC: vitest.fn(() => ({ + inputs: { dsc: true }, + circuitName: 'dsc_circuit', + endpointType: 'celo', + endpoint: 'https://dsc', + })), + generateTEEInputsDiscloseStateless: vitest.fn(() => ({ + inputs: { disclose: true }, + circuitName: 'disclose_circuit', + endpointType: 'https', + endpoint: 'https://disclose', + })), + }; +}); + +describe('payload generator (refactor guardrail via _generatePayload)', () => { + const selfClient: SelfClient = { + trackEvent: vitest.fn(), + emit: vitest.fn(), + logProofEvent: vitest.fn(), + getPrivateKey: vitest.fn(), + getSelfAppState: () => useSelfAppStore.getState(), + getProvingState: () => useProvingStore.getState(), + getProtocolState: () => useProtocolStore.getState(), + } as unknown as SelfClient; + + beforeEach(() => { + vitest.clearAllMocks(); + useSelfAppStore.setState({ + selfApp: { + chainID: 42220, + userId: '12345678-1234-1234-1234-123456789abc', + userDefinedData: '0x0', + selfDefinedData: '', + endpointType: 'https', + endpoint: 'https://endpoint', + scope: 'scope', + sessionId: '', + appName: '', + logoBase64: '', + header: '', + userIdType: 'uuid', + devMode: false, + disclosures: {}, + version: 1, + deeplinkCallback: '', + }, + }); + }); + + it('builds a submit request payload with the encrypted payload', async () => { + useProvingStore.setState({ + circuitType: 'register', + passportData: { documentCategory: 'passport', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + const payload = await useProvingStore.getState()._generatePayload(selfClient); + + expect(payload).toEqual({ + jsonrpc: '2.0', + method: 'openpassport_submit_request', + id: 2, + params: { + uuid: 'uuid-123', + nonce: [1], + cipher_text: [2], + auth_tag: [3], + }, + }); + }); + + it('throws when dsc is requested for aadhaar documents', async () => { + useProvingStore.setState({ + circuitType: 'dsc', + passportData: { documentCategory: 'aadhaar', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await expect(useProvingStore.getState()._generatePayload(selfClient)).rejects.toThrow( + 'DSC circuit type is not supported for Aadhaar documents', + ); + }); + + it('throws when disclose circuit is requested without a SelfApp', async () => { + useSelfAppStore.setState({ selfApp: null }); + useProvingStore.setState({ + circuitType: 'disclose', + passportData: { documentCategory: 'passport', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await expect(useProvingStore.getState()._generatePayload(selfClient)).rejects.toThrow( + 'SelfApp context not initialized', + ); + }); + + it('throws on invalid circuit types', async () => { + useProvingStore.setState({ + circuitType: 'invalid' as any, + passportData: { documentCategory: 'passport', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await expect(useProvingStore.getState()._generatePayload(selfClient)).rejects.toThrow( + 'Invalid circuit type:invalid', + ); + }); + + it('uses register_id for id cards', async () => { + const { getPayload } = await import('@selfxyz/common/utils/proving'); + + useProvingStore.setState({ + circuitType: 'register', + passportData: { documentCategory: 'id_card', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await useProvingStore.getState()._generatePayload(selfClient); + + expect(getPayload).toHaveBeenCalledWith( + { reg: true }, + 'register_id', + 'register_circuit', + 'celo', + 'https://register', + 1, + expect.any(String), + '', + ); + }); + + it('keeps dsc circuit type for passport documents', async () => { + const { getPayload } = await import('@selfxyz/common/utils/proving'); + + useProvingStore.setState({ + circuitType: 'dsc', + passportData: { documentCategory: 'passport', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await useProvingStore.getState()._generatePayload(selfClient); + + expect(getPayload).toHaveBeenCalledWith( + { dsc: true }, + 'dsc', + 'dsc_circuit', + 'celo', + 'https://dsc', + 1, + expect.any(String), + '', + ); + }); + + it('always uses disclose for disclosure flows', async () => { + const { getPayload } = await import('@selfxyz/common/utils/proving'); + + useProvingStore.setState({ + circuitType: 'disclose', + passportData: { documentCategory: 'passport', mock: false } as any, + secret: 'secret', + uuid: 'uuid-123', + sharedKey: Buffer.alloc(32, 1), + env: 'prod', + }); + + await useProvingStore.getState()._generatePayload(selfClient); + + expect(getPayload).toHaveBeenCalledWith( + { disclose: true }, + 'disclose', + 'disclose_circuit', + 'https', + 'https://disclose', + 1, + expect.any(String), + '', + ); + }); +}); diff --git a/packages/mobile-sdk-alpha/tests/proving/internal/statusListener.test.ts b/packages/mobile-sdk-alpha/tests/proving/internal/statusListener.test.ts new file mode 100644 index 000000000..728a68a0c --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/proving/internal/statusListener.test.ts @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { EventEmitter } from 'events'; +import type { Socket } from 'socket.io-client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useProvingStore } from '../../../src/proving/provingMachine'; +import { actorMock } from '../actorMock'; + +vi.mock('socket.io-client'); +vi.mock('../../../src/constants/analytics', () => ({ + ProofEvents: { + SOCKETIO_CONN_STARTED: 'SOCKETIO_CONN_STARTED', + SOCKETIO_SUBSCRIBED: 'SOCKETIO_SUBSCRIBED', + SOCKETIO_STATUS_RECEIVED: 'SOCKETIO_STATUS_RECEIVED', + SOCKETIO_PROOF_FAILURE: 'SOCKETIO_PROOF_FAILURE', + SOCKETIO_PROOF_SUCCESS: 'SOCKETIO_PROOF_SUCCESS', + REGISTER_COMPLETED: 'REGISTER_COMPLETED', + }, + PassportEvents: {}, +})); + +vi.mock('../../../src/proving/internal/logging', () => ({ + logProofEvent: vi.fn(), + createProofContext: vi.fn(() => ({})), +})); +vi.mock('@selfxyz/common/utils/proving', () => ({ + getWSDbRelayerUrl: vi.fn(() => 'ws://test-url'), + getPayload: vi.fn(), + encryptAES256GCM: vi.fn(), + clientKey: {}, + clientPublicKeyHex: 'test-key', + ec: {}, +})); + +vi.mock('../../../src/documents/utils', () => ({ + loadSelectedDocument: vi.fn(() => + Promise.resolve({ + data: { mockData: true }, + version: '1.0.0', + }), + ), + hasAnyValidRegisteredDocument: vi.fn(() => Promise.resolve(true)), + clearPassportData: vi.fn(), + markCurrentDocumentAsRegistered: vi.fn(), + reStorePassportDataWithRightCSCA: vi.fn(), +})); + +vi.mock('../../../src/types/events', () => ({ + SdkEvents: { + PASSPORT_DATA_NOT_FOUND: 'PASSPORT_DATA_NOT_FOUND', + }, +})); + +vi.mock('@selfxyz/common/utils', () => ({ + getCircuitNameFromPassportData: vi.fn(() => 'register'), + getSolidityPackedUserContextData: vi.fn(() => '0x123'), +})); + +vi.mock('@selfxyz/common/utils/attest', () => ({ + getPublicKey: vi.fn(), + verifyAttestation: vi.fn(), +})); + +vi.mock('@selfxyz/common/utils/circuits/registerInputs', () => ({ + generateTEEInputsDSC: vi.fn(), + generateTEEInputsRegister: vi.fn(), +})); + +vi.mock('@selfxyz/common/utils/passports/validate', () => ({ + checkDocumentSupported: vi.fn(() => Promise.resolve(true)), + checkIfPassportDscIsInTree: vi.fn(() => Promise.resolve(true)), + isDocumentNullified: vi.fn(() => Promise.resolve(false)), + isUserRegistered: vi.fn(() => Promise.resolve(false)), + isUserRegisteredWithAlternativeCSCA: vi.fn(() => Promise.resolve(false)), +})); + +vi.mock('xstate', () => ({ + createActor: vi.fn(() => actorMock), + createMachine: vi.fn(() => ({})), +})); + +describe('Socket.IO status handler wiring', () => { + const mockSelfClient = { + trackEvent: vi.fn(), + emit: vi.fn(), + getPrivateKey: vi.fn(() => Promise.resolve('mock-private-key')), + logProofEvent: vi.fn(), + getSelfAppState: () => ({ + selfApp: {}, + }), + getProtocolState: () => ({ + isUserLoggedIn: true, + }), + getProvingState: () => useProvingStore.getState(), + } as any; + + let mockSocket: EventEmitter & Partial; + let socketIoMock: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + useProvingStore.setState({ + socketConnection: null, + error_code: null, + reason: null, + circuitType: 'register', + } as any); + + mockSocket = new EventEmitter() as EventEmitter & Partial; + vi.spyOn(mockSocket as any, 'emit'); + mockSocket.disconnect = vi.fn(); + + const socketIo = await import('socket.io-client'); + socketIoMock = vi.mocked(socketIo.default || socketIo); + socketIoMock.mockReturnValue(mockSocket); + + const store = useProvingStore.getState(); + await store.init(mockSelfClient, 'register', true); + actorMock.send.mockClear(); + }); + + it('applies success updates and emits PROVE_SUCCESS', async () => { + const store = useProvingStore.getState(); + store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient); + + await new Promise(resolve => setImmediate(resolve)); + + (mockSocket as any).emit('status', { status: 4 }); + + const finalState = useProvingStore.getState(); + expect(finalState.socketConnection).toBe(null); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PROVE_SUCCESS' }); + }); + + it('applies failure updates and emits PROVE_FAILURE', async () => { + const store = useProvingStore.getState(); + store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient); + + await new Promise(resolve => setImmediate(resolve)); + + (mockSocket as any).emit('status', { + status: 5, + error_code: 'E001', + reason: 'TEE failed', + }); + + const finalState = useProvingStore.getState(); + expect(finalState.error_code).toBe('E001'); + expect(finalState.reason).toBe('TEE failed'); + expect(finalState.socketConnection).toBe(null); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PROVE_FAILURE' }); + }); + + it('emits PROVE_ERROR without updating state for retryable errors', async () => { + const store = useProvingStore.getState(); + store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient); + + await new Promise(resolve => setImmediate(resolve)); + + (mockSocket as any).emit('status', '{"invalid": json}'); + + const finalState = useProvingStore.getState(); + expect(finalState.socketConnection).toBe(mockSocket); + expect(finalState.error_code).toBe(null); + expect(finalState.reason).toBe(null); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PROVE_ERROR' }); + }); +}); diff --git a/packages/mobile-sdk-alpha/tests/proving/internal/websocketHandlers.test.ts b/packages/mobile-sdk-alpha/tests/proving/internal/websocketHandlers.test.ts new file mode 100644 index 000000000..42b4f7c43 --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/proving/internal/websocketHandlers.test.ts @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import type { SelfClient } from '../../../src'; +import * as documentUtils from '../../../src/documents/utils'; +import { useProvingStore } from '../../../src/proving/provingMachine'; +import { useProtocolStore } from '../../../src/stores/protocolStore'; +import { useSelfAppStore } from '../../../src/stores/selfAppStore'; +import { actorMock } from '../actorMock'; + +vitest.mock('uuid', () => ({ + v4: vitest.fn(() => 'uuid-123'), +})); + +vitest.mock('xstate', () => { + return { + createActor: vitest.fn(() => actorMock), + createMachine: vitest.fn(), + assign: vitest.fn(), + send: vitest.fn(), + spawn: vitest.fn(), + interpret: vitest.fn(), + fromPromise: vitest.fn(), + fromObservable: vitest.fn(), + fromEventObservable: vitest.fn(), + fromCallback: vitest.fn(), + fromTransition: vitest.fn(), + fromReducer: vitest.fn(), + fromRef: vitest.fn(), + }; +}); + +vitest.mock('@selfxyz/common/utils/attest', () => { + return { + validatePKIToken: vitest.fn(() => ({ + userPubkey: Buffer.from('abcd', 'hex'), + serverPubkey: 'server-key', + imageHash: 'hash', + verified: true, + })), + checkPCR0Mapping: vitest.fn(async () => true), + }; +}); + +vitest.mock('@selfxyz/common/utils/proving', async () => { + const actual = await vitest.importActual( + '@selfxyz/common/utils/proving', + ); + return { + ...actual, + clientPublicKeyHex: 'abcd', + clientKey: { + derive: vitest.fn(() => ({ + toArray: () => Array(32).fill(7), + })), + }, + ec: { + keyFromPublic: vitest.fn(() => ({ + getPublic: vitest.fn(() => 'server-public'), + })), + }, + }; +}); + +describe('websocket handlers (refactor guardrail via proving store)', () => { + const selfClient: SelfClient = { + trackEvent: vitest.fn(), + emit: vitest.fn(), + logProofEvent: vitest.fn(), + getPrivateKey: vitest.fn().mockResolvedValue('secret'), + getSelfAppState: () => useSelfAppStore.getState(), + getProvingState: () => useProvingStore.getState(), + getProtocolState: () => useProtocolStore.getState(), + } as unknown as SelfClient; + let loadSelectedDocumentSpy: any; + + beforeEach(() => { + vitest.clearAllMocks(); + (globalThis as { __DEV__?: boolean }).__DEV__ = true; + useSelfAppStore.setState({ selfApp: null, sessionId: null, socket: null }); + if (!loadSelectedDocumentSpy) { + loadSelectedDocumentSpy = vitest.spyOn(documentUtils, 'loadSelectedDocument'); + } + loadSelectedDocumentSpy.mockResolvedValue({ + data: { + documentCategory: 'passport', + mock: false, + dsc_parsed: { authorityKeyIdentifier: 'aki' }, + } as any, + } as any); + }); + + it('does nothing when actor is missing or wsConnection is null', () => { + useProvingStore.setState({ wsConnection: null } as any); + + useProvingStore.getState()._handleWsOpen(selfClient); + + expect(actorMock.send).not.toHaveBeenCalled(); + }); + + it('does nothing when wsConnection is null even if actor exists', async () => { + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + useProvingStore.setState({ wsConnection: null } as any); + + useProvingStore.getState()._handleWsOpen(selfClient); + + expect(actorMock.send).not.toHaveBeenCalled(); + }); + + it('sends hello message and stores uuid on open', async () => { + const wsConnection = { + send: vitest.fn(), + } as unknown as WebSocket; + + await useProvingStore.getState().init(selfClient, 'register'); + useProvingStore.setState({ wsConnection }); + + useProvingStore.getState()._handleWsOpen(selfClient); + + expect(useProvingStore.getState().uuid).toBe('uuid-123'); + const [sentMessage] = (wsConnection.send as unknown as { mock: { calls: string[][] } }).mock.calls[0]; + const parsedMessage = JSON.parse(sentMessage); + expect(parsedMessage).toMatchObject({ + jsonrpc: '2.0', + method: 'openpassport_hello', + id: 1, + params: { + uuid: 'uuid-123', + user_pubkey: expect.any(Array), + }, + }); + }); + + it('handles attestation messages by deriving shared key and emitting CONNECT_SUCCESS', async () => { + const { clientPublicKeyHex } = await import('@selfxyz/common/utils/proving'); + const { validatePKIToken } = await import('@selfxyz/common/utils/attest'); + + await useProvingStore.getState().init(selfClient, 'register'); + useProvingStore.setState({ currentState: 'init_tee_connexion' } as any); + + const event = { data: JSON.stringify({ result: { attestation: [1, 2, 3] } }) } as MessageEvent; + + await useProvingStore.getState()._handleWebSocketMessage(event, selfClient); + + expect(clientPublicKeyHex).toBe('abcd'); + expect(validatePKIToken).toHaveBeenCalled(); + expect(useProvingStore.getState().sharedKey).toEqual(Buffer.from(Array(32).fill(7))); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'CONNECT_SUCCESS' }); + }); + + it('starts socket listener on hello ack', async () => { + await useProvingStore.getState().init(selfClient, 'register'); + const startListener = vitest.fn(); + useProvingStore.setState({ + endpointType: 'https', + uuid: 'uuid-123', + _startSocketIOStatusListener: startListener, + } as any); + + const event = new MessageEvent('message', { + data: JSON.stringify({ id: 2, result: 'status-uuid' }), + }); + + await useProvingStore.getState()._handleWebSocketMessage(event, selfClient); + + expect(startListener).toHaveBeenCalledWith('status-uuid', 'https', selfClient); + }); + + it('uses hello ack uuid when it differs from stored uuid', async () => { + await useProvingStore.getState().init(selfClient, 'register'); + const startListener = vitest.fn(); + useProvingStore.setState({ + endpointType: 'https', + uuid: 'uuid-123', + _startSocketIOStatusListener: startListener, + } as any); + + const event = new MessageEvent('message', { + data: JSON.stringify({ id: 2, result: 'uuid-456' }), + }); + + await useProvingStore.getState()._handleWebSocketMessage(event, selfClient); + + expect(startListener).toHaveBeenCalledWith('uuid-456', 'https', selfClient); + }); + + it('emits PROVE_ERROR on websocket error payloads', async () => { + await useProvingStore.getState().init(selfClient, 'register'); + + const event = new MessageEvent('message', { + data: JSON.stringify({ error: 'bad' }), + }); + + await useProvingStore.getState()._handleWebSocketMessage(event, selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PROVE_ERROR' }); + }); + + it.each([ + { state: 'init_tee_connexion', expected: 'PROVE_ERROR' }, + { state: 'proving', expected: 'PROVE_ERROR' }, + { state: 'listening_for_status', expected: 'PROVE_ERROR' }, + ])('emits $expected when websocket closes during $state', async ({ state, expected }) => { + await useProvingStore.getState().init(selfClient, 'register'); + useProvingStore.setState({ currentState: state } as any); + + const event = { code: 1000, reason: 'closed' } as CloseEvent; + + useProvingStore.getState()._handleWsClose(event, selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: expected }); + }); + + it.each([ + { state: 'init_tee_connexion', expected: 'PROVE_ERROR' }, + { state: 'proving', expected: 'PROVE_ERROR' }, + ])('emits $expected when websocket errors during $state', async ({ state, expected }) => { + await useProvingStore.getState().init(selfClient, 'register'); + useProvingStore.setState({ currentState: state } as any); + + useProvingStore.getState()._handleWsError(new Event('error'), selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: expected }); + }); +}); diff --git a/packages/mobile-sdk-alpha/tests/proving/internal/websocketUrlResolver.test.ts b/packages/mobile-sdk-alpha/tests/proving/internal/websocketUrlResolver.test.ts new file mode 100644 index 000000000..5af469c6f --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/proving/internal/websocketUrlResolver.test.ts @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import type { SelfClient } from '../../../src'; +import * as documentUtils from '../../../src/documents/utils'; +import { useProvingStore } from '../../../src/proving/provingMachine'; +import { useProtocolStore } from '../../../src/stores/protocolStore'; +import { actorMock, emitState } from '../actorMock'; + +vitest.mock('xstate', () => { + return { + createActor: vitest.fn(() => actorMock), + createMachine: vitest.fn(), + assign: vitest.fn(), + send: vitest.fn(), + spawn: vitest.fn(), + interpret: vitest.fn(), + fromPromise: vitest.fn(), + fromObservable: vitest.fn(), + fromEventObservable: vitest.fn(), + fromCallback: vitest.fn(), + fromTransition: vitest.fn(), + fromReducer: vitest.fn(), + fromRef: vitest.fn(), + }; +}); + +vitest.mock('@selfxyz/common/utils', async () => { + const actual = await vitest.importActual('@selfxyz/common/utils'); + return { + ...actual, + getCircuitNameFromPassportData: vitest.fn(() => 'mock-circuit'), + }; +}); + +describe('websocket URL resolution (refactor guardrail via initTeeConnection)', () => { + const wsSend = vitest.fn(); + const wsAddEventListener = vitest.fn(); + const wsMock = vitest.fn(() => ({ + addEventListener: wsAddEventListener, + send: wsSend, + })); + let loadSelectedDocumentSpy: any; + + const makeSelfClient = (): SelfClient => + ({ + getPrivateKey: vitest.fn().mockResolvedValue('secret'), + trackEvent: vitest.fn(), + logProofEvent: vitest.fn(), + getProvingState: () => useProvingStore.getState(), + getProtocolState: () => useProtocolStore.getState(), + getSelfAppState: () => ({ selfApp: null }), + }) as unknown as SelfClient; + + const setCircuitsMapping = (documentCategory: 'passport' | 'id_card' | 'aadhaar', mapping: any) => { + useProtocolStore.setState(state => ({ + [documentCategory]: { + ...state[documentCategory], + circuits_dns_mapping: mapping, + }, + })); + }; + + beforeEach(() => { + vitest.restoreAllMocks(); + vitest.clearAllMocks(); + global.WebSocket = wsMock as unknown as typeof WebSocket; + loadSelectedDocumentSpy = vitest.spyOn(documentUtils, 'loadSelectedDocument'); + }); + + it.each([ + { + label: 'disclose passport -> DISCLOSE', + circuitType: 'disclose' as const, + documentCategory: 'passport' as const, + circuitName: 'disclose', + mappingKey: 'DISCLOSE', + }, + { + label: 'disclose id_card -> DISCLOSE_ID', + circuitType: 'disclose' as const, + documentCategory: 'id_card' as const, + circuitName: 'disclose', + mappingKey: 'DISCLOSE_ID', + }, + { + label: 'disclose aadhaar -> DISCLOSE_AADHAAR', + circuitType: 'disclose' as const, + documentCategory: 'aadhaar' as const, + circuitName: 'disclose_aadhaar', + mappingKey: 'DISCLOSE_AADHAAR', + }, + { + label: 'register passport -> REGISTER', + circuitType: 'register' as const, + documentCategory: 'passport' as const, + circuitName: 'mock-circuit', + mappingKey: 'REGISTER', + }, + { + label: 'register id_card -> REGISTER_ID', + circuitType: 'register' as const, + documentCategory: 'id_card' as const, + circuitName: 'mock-circuit', + mappingKey: 'REGISTER_ID', + }, + { + label: 'register aadhaar -> REGISTER_AADHAAR', + circuitType: 'register' as const, + documentCategory: 'aadhaar' as const, + circuitName: 'mock-circuit', + mappingKey: 'REGISTER_AADHAAR', + }, + { + label: 'dsc passport -> DSC', + circuitType: 'dsc' as const, + documentCategory: 'passport' as const, + circuitName: 'mock-circuit', + mappingKey: 'DSC', + }, + { + label: 'dsc id_card -> DSC_ID', + circuitType: 'dsc' as const, + documentCategory: 'id_card' as const, + circuitName: 'mock-circuit', + mappingKey: 'DSC_ID', + }, + ])('$label resolves expected WebSocket URL', async ({ circuitType, documentCategory, circuitName, mappingKey }) => { + const selfClient = makeSelfClient(); + const wsUrl = `wss://example/${mappingKey}`; + + loadSelectedDocumentSpy.mockResolvedValue({ + data: { + documentCategory, + mock: false, + dsc_parsed: { authorityKeyIdentifier: 'aki' }, + } as any, + } as any); + + setCircuitsMapping(documentCategory, { + [mappingKey]: { + [circuitName]: wsUrl, + }, + }); + + await useProvingStore.getState().init(selfClient, circuitType); + + const initPromise = useProvingStore.getState().initTeeConnection(selfClient); + emitState('ready_to_prove'); + await initPromise; + + expect(wsMock).toHaveBeenCalledWith(wsUrl); + }); + + it('throws when mapping is missing for the circuit', async () => { + const selfClient = makeSelfClient(); + + loadSelectedDocumentSpy.mockResolvedValue({ + data: { + documentCategory: 'passport', + mock: false, + dsc_parsed: { authorityKeyIdentifier: 'aki' }, + } as any, + } as any); + + setCircuitsMapping('passport', { + REGISTER: { + other: 'wss://missing', + }, + }); + + await useProvingStore.getState().init(selfClient, 'register'); + + await expect(useProvingStore.getState().initTeeConnection(selfClient)).rejects.toThrow( + 'No WebSocket URL available for TEE connection', + ); + }); + + it('throws for unsupported document categories', async () => { + const selfClient = makeSelfClient(); + const invalidCategory = 'driver_license'; + + loadSelectedDocumentSpy.mockResolvedValue({ + data: { + documentCategory: invalidCategory, + mock: false, + dsc_parsed: { authorityKeyIdentifier: 'aki' }, + } as any, + } as any); + + useProtocolStore.setState(state => ({ + ...(state as any), + [invalidCategory]: { + circuits_dns_mapping: {}, + }, + })); + + await useProvingStore.getState().init(selfClient, 'disclose'); + + await expect(useProvingStore.getState().initTeeConnection(selfClient)).rejects.toThrow( + 'Unsupported document category for disclose: driver_license', + ); + }); +}); diff --git a/packages/mobile-sdk-alpha/tests/proving/provingMachine.documentProcessor.test.ts b/packages/mobile-sdk-alpha/tests/proving/provingMachine.documentProcessor.test.ts new file mode 100644 index 000000000..28b7b77d5 --- /dev/null +++ b/packages/mobile-sdk-alpha/tests/proving/provingMachine.documentProcessor.test.ts @@ -0,0 +1,920 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { poseidon2 } from 'poseidon-lite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AadhaarData, PassportData } from '@selfxyz/common'; +import { + generateCommitment, + genMockIdDoc, + getCircuitNameFromPassportData, + getLeafDscTree, + isMRZDocument, +} from '@selfxyz/common/utils'; +import * as commonUtils from '@selfxyz/common/utils'; +import { generateCommitmentInAppAadhaar } from '@selfxyz/common/utils/passports/validate'; +import { AttestationIdHex } from '@selfxyz/common/utils/types'; + +import { PassportEvents, ProofEvents } from '../../src/constants/analytics'; +import * as documentUtils from '../../src/documents/utils'; +import { useProvingStore } from '../../src/proving/provingMachine'; +import { fetchAllTreesAndCircuits } from '../../src/stores'; +import type { SelfClient } from '../../src/types/public'; +import { actorMock } from './actorMock'; + +import { LeanIMT } from '@openpassport/zk-kit-lean-imt'; + +vi.mock('xstate', async () => { + const actual = await vi.importActual('xstate'); + return { + ...actual, + createActor: vi.fn(() => actorMock), + }; +}); + +vi.mock('../../src/documents/utils', async () => { + const actual = await vi.importActual('../../src/documents/utils'); + return { + ...actual, + loadSelectedDocument: vi.fn(), + storePassportData: vi.fn(), + clearPassportData: vi.fn(), + reStorePassportDataWithRightCSCA: vi.fn(), + markCurrentDocumentAsRegistered: vi.fn(), + }; +}); + +vi.mock('../../src/stores', async () => { + const actual = await vi.importActual('../../src/stores'); + return { + ...actual, + fetchAllTreesAndCircuits: vi.fn(), + }; +}); + +const createCommitmentTree = (commitments: string[]) => { + const tree = new LeanIMT((a, b) => poseidon2([a, b])); + if (commitments.length > 0) { + tree.insertMany(commitments.map(commitment => BigInt(commitment))); + } + return tree.export(); +}; + +const createDscTree = (leaves: string[]) => createCommitmentTree(leaves); + +const buildPassportFixture = (): PassportData => + ({ + mrz: 'P; + publicKeys?: string[]; +}) => ({ + passport: { + commitment_tree: commitmentTree, + dsc_tree: dscTree, + csca_tree: null, + deployed_circuits: deployedCircuits, + circuits_dns_mapping: null, + alternative_csca: alternativeCsca ?? {}, + ofac_trees: null, + fetch_all: vi.fn(), + fetch_deployed_circuits: vi.fn(), + fetch_circuits_dns_mapping: vi.fn(), + fetch_csca_tree: vi.fn(), + fetch_dsc_tree: vi.fn(), + fetch_identity_tree: vi.fn(), + fetch_alternative_csca: vi.fn(), + fetch_ofac_trees: vi.fn(), + }, + id_card: { + commitment_tree: commitmentTree, + dsc_tree: dscTree, + csca_tree: null, + deployed_circuits: deployedCircuits, + circuits_dns_mapping: null, + alternative_csca: alternativeCsca ?? {}, + ofac_trees: null, + fetch_all: vi.fn(), + fetch_deployed_circuits: vi.fn(), + fetch_circuits_dns_mapping: vi.fn(), + fetch_csca_tree: vi.fn(), + fetch_dsc_tree: vi.fn(), + fetch_identity_tree: vi.fn(), + fetch_alternative_csca: vi.fn(), + fetch_ofac_trees: vi.fn(), + }, + aadhaar: { + commitment_tree: commitmentTree, + dsc_tree: null, + csca_tree: null, + deployed_circuits: deployedCircuits, + circuits_dns_mapping: null, + public_keys: publicKeys ?? [], + ofac_trees: null, + fetch_all: vi.fn(), + fetch_deployed_circuits: vi.fn(), + fetch_circuits_dns_mapping: vi.fn(), + fetch_csca_tree: vi.fn(), + fetch_dsc_tree: vi.fn(), + fetch_identity_tree: vi.fn(), + fetch_alternative_csca: vi.fn(), + fetch_ofac_trees: vi.fn(), + }, +}); + +const createSelfClient = (protocolState: ReturnType) => + ({ + trackEvent: vi.fn(), + logProofEvent: vi.fn(), + emit: vi.fn(), + getPrivateKey: vi.fn().mockResolvedValue('123456789'), + getProvingState: () => useProvingStore.getState(), + getSelfAppState: () => ({ selfApp: null }), + getProtocolState: () => protocolState, + }) as unknown as SelfClient; + +describe('parseIDDocument', () => { + const loadSelectedDocumentMock = vi.mocked(documentUtils.loadSelectedDocument); + const storePassportDataMock = vi.mocked(documentUtils.storePassportData); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('parses passport data successfully and updates state with parsed result', async () => { + const passportData = genMockIdDoc({ idType: 'mock_passport' }) as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + const getSKIPEMSpy = vi.spyOn(commonUtils, 'getSKIPEM').mockResolvedValue({}); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + await useProvingStore.getState().parseIDDocument(selfClient); + + const state = useProvingStore.getState(); + expect(getSKIPEMSpy).toHaveBeenCalledWith('staging'); + expect(storePassportDataMock).toHaveBeenCalledWith(selfClient, state.passportData); + if (state.passportData && isMRZDocument(state.passportData)) { + expect(state.passportData.passportMetadata).toBeDefined(); + } + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_SUCCESS' }); + if (state.passportData && isMRZDocument(state.passportData)) { + expect(selfClient.trackEvent).toHaveBeenCalledWith( + PassportEvents.PASSPORT_PARSED, + expect.objectContaining({ + success: true, + country_code: state.passportData.passportMetadata?.countryCode, + }), + ); + } + }); + + it('handles missing passport data with PARSE_ERROR and analytics event', async () => { + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: genMockIdDoc({ idType: 'mock_passport' }) } as any); + + vi.spyOn(commonUtils, 'getSKIPEM').mockResolvedValue({}); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: null }); + + await useProvingStore.getState().parseIDDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(PassportEvents.PASSPORT_PARSE_FAILED, { + error: 'PassportData is not available', + }); + }); + + it('surfaces parsing failures when the DSC cannot be parsed', async () => { + const passportData = { + ...(genMockIdDoc({ idType: 'mock_passport' }) as PassportData), + dsc: 'invalid-certificate', + } as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + vi.spyOn(commonUtils, 'getSKIPEM').mockResolvedValue({}); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + await useProvingStore.getState().parseIDDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith( + PassportEvents.PASSPORT_PARSE_FAILED, + expect.objectContaining({ + error: expect.stringMatching(/asn\\.1|parsing/i), + }), + ); + }); + + it('continues when DSC metadata cannot be read and logs empty dsc payload', async () => { + const passportData = genMockIdDoc({ idType: 'mock_passport' }) as PassportData; + let metadataProxy: PassportData['passportMetadata']; + Object.defineProperty(passportData, 'passportMetadata', { + get() { + return metadataProxy; + }, + set(value) { + metadataProxy = new Proxy(value, { + get(target, prop) { + if (prop === 'dsc') { + throw new Error('dsc parse failed'); + } + return target[prop as keyof typeof target]; + }, + }); + }, + configurable: true, + }); + + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + vi.spyOn(commonUtils, 'getSKIPEM').mockResolvedValue({}); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + await useProvingStore.getState().parseIDDocument(selfClient); + + const parsedEvent = vi + .mocked(selfClient.trackEvent) + .mock.calls.find(([event]) => event === PassportEvents.PASSPORT_PARSED)?.[1]; + + expect(parsedEvent).toEqual( + expect.objectContaining({ + dsc: {}, + }), + ); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_SUCCESS' }); + }); + + it('emits PARSE_ERROR when storing parsed passport data fails', async () => { + const passportData = genMockIdDoc({ idType: 'mock_passport' }) as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + vi.spyOn(commonUtils, 'getSKIPEM').mockResolvedValue({}); + + storePassportDataMock.mockRejectedValue(new Error('storage unavailable')); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + await useProvingStore.getState().parseIDDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PARSE_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(PassportEvents.PASSPORT_PARSE_FAILED, { + error: 'storage unavailable', + }); + }); +}); + +describe('startFetchingData', () => { + const loadSelectedDocumentMock = vi.mocked(documentUtils.loadSelectedDocument); + const fetchAllTreesMock = vi.mocked(fetchAllTreesAndCircuits); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches trees and circuits for passport documents', async () => { + const passportData = { + ...(genMockIdDoc({ idType: 'mock_passport' }) as PassportData), + dsc_parsed: { authorityKeyIdentifier: 'KEY123' } as any, + documentCategory: 'passport', + } as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, env: 'prod' }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(fetchAllTreesMock).toHaveBeenCalledWith(selfClient, 'passport', 'prod', 'KEY123'); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_SUCCESS' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_SUCCESS); + }); + + it('fetches trees and circuits for id cards', async () => { + const idCardData = { + ...(genMockIdDoc({ idType: 'mock_id_card' }) as PassportData), + dsc_parsed: { authorityKeyIdentifier: 'IDKEY' } as any, + documentCategory: 'id_card', + } as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: idCardData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: idCardData, env: 'stg' }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(fetchAllTreesMock).toHaveBeenCalledWith(selfClient, 'id_card', 'stg', 'IDKEY'); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_SUCCESS' }); + }); + + it('fetches aadhaar protocol data via aadhaar fetcher', async () => { + const aadhaarData = genMockIdDoc({ idType: 'mock_aadhaar' }) as AadhaarData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: aadhaarData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: aadhaarData, env: 'prod' }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(protocolState.aadhaar.fetch_all).toHaveBeenCalledWith('prod'); + expect(fetchAllTreesMock).not.toHaveBeenCalled(); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_SUCCESS' }); + }); + + it('emits FETCH_ERROR when passport data is missing', async () => { + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: genMockIdDoc({ idType: 'mock_passport' }) } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: null }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_FAILED, { + message: 'PassportData is not available', + }); + }); + + it('emits FETCH_ERROR when DSC data is missing for passports', async () => { + const passportData = { + ...(genMockIdDoc({ idType: 'mock_passport' }) as PassportData), + dsc_parsed: undefined, + documentCategory: 'passport', + } as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, env: 'stg' }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_FAILED, { + message: 'Missing parsed DSC in passport data', + }); + }); + + it('emits FETCH_ERROR when protocol fetch fails', async () => { + const passportData = { + ...(genMockIdDoc({ idType: 'mock_passport' }) as PassportData), + dsc_parsed: { authorityKeyIdentifier: 'KEY123' } as any, + documentCategory: 'passport', + } as PassportData; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + fetchAllTreesMock.mockRejectedValue(new Error('network down')); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, env: 'prod' }); + + await useProvingStore.getState().startFetchingData(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'FETCH_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.FETCH_DATA_FAILED, { + message: 'network down', + }); + }); +}); + +describe('validatingDocument', () => { + const loadSelectedDocumentMock = vi.mocked(documentUtils.loadSelectedDocument); + const clearPassportDataMock = vi.mocked(documentUtils.clearPassportData); + const reStorePassportDataWithRightCSCMock = vi.mocked(documentUtils.reStorePassportDataWithRightCSCA); + const markCurrentDocumentAsRegisteredMock = vi.mocked(documentUtils.markCurrentDocumentAsRegistered); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('clears data and emits PASSPORT_NOT_SUPPORTED when document is unsupported', async () => { + const passportData = buildPassportFixture(); + const unsupportedCircuits = { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }; + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: unsupportedCircuits, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, secret: '123456789', circuitType: 'register' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(clearPassportDataMock).toHaveBeenCalledWith(selfClient); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PASSPORT_NOT_SUPPORTED' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith( + PassportEvents.COMING_SOON, + expect.objectContaining({ status: 'registration_circuit_not_supported' }), + ); + }); + + it('validates disclose when the user is registered', async () => { + const passportData = buildPassportFixture(); + const secret = '123456789'; + const commitment = generateCommitment(secret, AttestationIdHex.passport, passportData); + const commitmentTree = createCommitmentTree([commitment]); + + const registerCircuit = getCircuitNameFromPassportData(passportData, 'register'); + const dscCircuit = getCircuitNameFromPassportData(passportData, 'dsc'); + const deployedCircuits = { + REGISTER: [registerCircuit], + REGISTER_ID: [], + REGISTER_AADHAAR: ['register_aadhaar'], + DSC: [dscCircuit], + DSC_ID: [], + }; + + const protocolState = buildProtocolState({ + commitmentTree, + dscTree: null, + deployedCircuits, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + await useProvingStore.getState().init(selfClient, 'disclose'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, secret, circuitType: 'disclose' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'VALIDATION_SUCCESS' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.VALIDATION_SUCCESS); + }); + + it('emits PASSPORT_DATA_NOT_FOUND when disclose document is not registered', async () => { + const passportData = buildPassportFixture(); + const secret = '123456789'; + const commitmentTree = createCommitmentTree([]); + const registerCircuit = getCircuitNameFromPassportData(passportData, 'register'); + const dscCircuit = getCircuitNameFromPassportData(passportData, 'dsc'); + const deployedCircuits = { + REGISTER: [registerCircuit], + REGISTER_ID: [], + REGISTER_AADHAAR: ['register_aadhaar'], + DSC: [dscCircuit], + DSC_ID: [], + }; + + const protocolState = buildProtocolState({ + commitmentTree, + dscTree: null, + deployedCircuits, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + await useProvingStore.getState().init(selfClient, 'disclose'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, secret, circuitType: 'disclose' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'PASSPORT_DATA_NOT_FOUND' }); + }); + + it('restores data when aadhaar is already registered with alternative keys', async () => { + const aadhaarData = genMockIdDoc({ idType: 'mock_aadhaar' }) as AadhaarData; + const secret = '123456789'; + const { commitment_list: commitmentList } = generateCommitmentInAppAadhaar( + secret, + AttestationIdHex.aadhaar, + aadhaarData, + { + public_key_0: aadhaarData.publicKey, + }, + ); + const commitmentTree = createCommitmentTree([commitmentList[0]]); + const deployedCircuits = { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: ['register_aadhaar'], + DSC: [], + DSC_ID: [], + }; + + const protocolState = buildProtocolState({ + commitmentTree, + dscTree: null, + deployedCircuits, + publicKeys: [aadhaarData.publicKey], + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: aadhaarData } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: aadhaarData, secret, circuitType: 'register' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(reStorePassportDataWithRightCSCMock).toHaveBeenCalledWith(selfClient, aadhaarData, aadhaarData.publicKey); + expect(markCurrentDocumentAsRegisteredMock).toHaveBeenCalledWith(selfClient); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'ALREADY_REGISTERED' }); + }); + + it('routes to account recovery when nullifier is on chain', async () => { + const passportData = buildPassportFixture(); + const secret = '123456789'; + const registerCircuit = getCircuitNameFromPassportData(passportData, 'register'); + const dscCircuit = getCircuitNameFromPassportData(passportData, 'dsc'); + const deployedCircuits = { + REGISTER: [registerCircuit], + REGISTER_ID: [], + REGISTER_AADHAAR: ['register_aadhaar'], + DSC: [dscCircuit], + DSC_ID: [], + }; + + const protocolState = buildProtocolState({ + commitmentTree: createCommitmentTree([]), + dscTree: null, + deployedCircuits, + alternativeCsca: {}, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ data: true }), + } as Response), + ); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, secret, circuitType: 'register' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'ACCOUNT_RECOVERY_CHOICE' }); + + globalThis.fetch = originalFetch; + }); + + it('switches to register circuit when DSC is already in the tree', async () => { + const passportData = buildPassportFixture(); + const secret = '123456789'; + const registerCircuit = getCircuitNameFromPassportData(passportData, 'register'); + const dscCircuit = getCircuitNameFromPassportData(passportData, 'dsc'); + const deployedCircuits = { + REGISTER: [registerCircuit], + REGISTER_ID: [], + REGISTER_AADHAAR: ['register_aadhaar'], + DSC: [dscCircuit], + DSC_ID: [], + }; + const dscLeaf = getLeafDscTree(passportData.dsc_parsed!, passportData.csca_parsed!); + const dscTree = createDscTree([dscLeaf]); + + const protocolState = buildProtocolState({ + commitmentTree: createCommitmentTree([]), + dscTree, + deployedCircuits, + alternativeCsca: {}, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: passportData } as any); + + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ data: false }), + } as Response), + ); + + await useProvingStore.getState().init(selfClient, 'dsc'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData, secret, circuitType: 'dsc' }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(useProvingStore.getState().circuitType).toBe('register'); + expect(actorMock.send).toHaveBeenCalledWith({ type: 'VALIDATION_SUCCESS' }); + + globalThis.fetch = originalFetch; + }); + + it('emits VALIDATION_ERROR when validation throws', async () => { + const protocolState = buildProtocolState({ + commitmentTree: null, + dscTree: null, + deployedCircuits: { + REGISTER: [], + REGISTER_ID: [], + REGISTER_AADHAAR: [], + DSC: [], + DSC_ID: [], + }, + }); + const selfClient = createSelfClient(protocolState); + + loadSelectedDocumentMock.mockResolvedValue({ data: buildPassportFixture() } as any); + + await useProvingStore.getState().init(selfClient, 'register'); + actorMock.send.mockClear(); + vi.mocked(selfClient.trackEvent).mockClear(); + + useProvingStore.setState({ passportData: null }); + + await useProvingStore.getState().validatingDocument(selfClient); + + expect(actorMock.send).toHaveBeenCalledWith({ type: 'VALIDATION_ERROR' }); + expect(selfClient.trackEvent).toHaveBeenCalledWith(ProofEvents.VALIDATION_FAILED, { + message: 'PassportData is not available', + }); + }); +}); diff --git a/packages/mobile-sdk-alpha/tests/setup.ts b/packages/mobile-sdk-alpha/tests/setup.ts index 5eeea990e..b249fdcad 100644 --- a/packages/mobile-sdk-alpha/tests/setup.ts +++ b/packages/mobile-sdk-alpha/tests/setup.ts @@ -7,6 +7,8 @@ * Reduces console noise during testing and mocks React Native modules */ +import { createElement } from 'react'; + const originalConsole = { warn: console.warn, error: console.error, @@ -48,10 +50,22 @@ vi.mock('react-native', () => ({ requireNativeComponent: vi.fn(() => 'div'), StyleSheet: { create: vi.fn(styles => styles), + flatten: vi.fn(style => { + if (!style) return {}; + if (Array.isArray(style)) { + return style.reduce((acc, s) => ({ ...acc, ...s }), {}); + } + return style; + }), }, Image: 'div', Text: 'span', View: 'div', + Pressable: vi.fn(({ children, style, ...props }) => { + // Handle style as function (for pressed state) + const computedStyle = typeof style === 'function' ? style({ pressed: false }) : style; + return createElement('button', { ...props, style: computedStyle }, children); + }), TouchableOpacity: 'button', ScrollView: 'div', FlatList: 'div', diff --git a/yarn.lock b/yarn.lock index 4c438ead4..85f78edea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8657,12 +8657,13 @@ __metadata: react-native-cloud-storage: "npm:^2.2.2" react-native-device-info: "npm:^14.0.4" react-native-dotenv: "npm:^3.4.11" - react-native-edge-to-edge: "npm:^1.6.2" + react-native-edge-to-edge: "npm:^1.7.0" react-native-gesture-handler: "npm:2.19.0" react-native-get-random-values: "npm:^1.11.0" react-native-haptic-feedback: "npm:^2.3.3" react-native-inappbrowser-reborn: "npm:^3.7.0" react-native-keychain: "npm:^10.0.0" + react-native-linear-gradient: "npm:^2.8.3" react-native-localize: "npm:^3.5.2" react-native-logs: "npm:^5.3.0" react-native-nfc-manager: "npm:3.16.3" @@ -8699,6 +8700,7 @@ __metadata: resolution: "@selfxyz/mobile-sdk-alpha@workspace:packages/mobile-sdk-alpha" dependencies: "@babel/runtime": "npm:^7.28.3" + "@openpassport/zk-kit-lean-imt": "npm:^0.0.6" "@selfxyz/common": "workspace:^" "@selfxyz/euclid": "npm:^0.6.1" "@testing-library/react": "npm:^14.1.2" @@ -8718,6 +8720,7 @@ __metadata: jsdom: "npm:^25.0.1" lottie-react-native: "npm:7.2.2" node-forge: "npm:^1.3.1" + poseidon-lite: "npm:^0.3.0" prettier: "npm:^3.5.3" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" @@ -29818,7 +29821,7 @@ __metadata: languageName: node linkType: hard -"react-native-edge-to-edge@npm:^1.6.2": +"react-native-edge-to-edge@npm:^1.7.0": version: 1.7.0 resolution: "react-native-edge-to-edge@npm:1.7.0" peerDependencies: @@ -29892,6 +29895,16 @@ __metadata: languageName: node linkType: hard +"react-native-linear-gradient@npm:^2.8.3": + version: 2.8.3 + resolution: "react-native-linear-gradient@npm:2.8.3" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/cd41bf28e9f468173f1e5e768685128ebf8bbf9077710e43b63482c1a76f37bff8ab3d1d6adfd7b4d54e648672356c02bea46c47cdbdb1844ebe5c5caf720114 + languageName: node + linkType: hard + "react-native-localize@npm:^3.5.2, react-native-localize@npm:^3.5.4": version: 3.6.0 resolution: "react-native-localize@npm:3.6.0"