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 (
+ <>
+ setOpen(true)}
+ >
+
+
+ {currentLevel.toUpperCase()}
+
+
+
+
+
+
+
+
+
+
+
+ Select log level
+
+ setOpen(false)}
+ padding="$2"
+ backgroundColor="transparent"
+ >
+
+
+
+
+ {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 }) => (
-
-
-
-
- {label}
-
-
-
-
-
- ))}
-
-
}
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 => (
- {
- setLoggingSeverity(level);
- }}
- flexDirection="row"
- justifyContent="space-between"
- paddingHorizontal="$4"
- pressStyle={{
- opacity: 0.8,
- scale: 0.98,
- }}
- >
-
- {level.toUpperCase()}
-
- {loggingSeverity === 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 = () => {
- {
- e.stopPropagation();
- handleDeleteButtonPress(metadata.id);
- }}
- >
-
-
+
+ {metadata.isRegistered !== true && (
+ {
+ e.stopPropagation();
+ handleRegisterDocument(metadata.id);
+ }}
+ >
+
+
+ )}
+ {
+ e.stopPropagation();
+ handleDeleteButtonPress(metadata.id, metadata.isRegistered);
+ }}
+ >
+
+
+
))}
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