Merge pull request #1986 from selfxyz/release/staging-2026-04-17

Release to Staging v2.9.17 - 2026-04-17
This commit is contained in:
Justin Hernandez
2026-04-19 16:55:56 -07:00
committed by GitHub
91 changed files with 4028 additions and 538 deletions

View File

@@ -1,10 +1,43 @@
name: Find iOS Simulator
description: Finds an available iPhone simulator on the runner and outputs its UUID.
description: >-
Finds an iPhone simulator to use for an iOS build. If a workspace and scheme
are provided, uses `xcodebuild -showdestinations` so only simulators the
scheme can actually build for are eligible. Falls back to `simctl list`
when no scheme is supplied.
inputs:
preferred-device:
description: Preferred iPhone device name (substring match allowed).
required: false
default: iPhone SE (3rd generation)
preferred-runtime:
description: >-
Preferred iOS runtime version (for example 18.4). Soft preference —
used as a tiebreaker, not a hard filter, so runner image bumps don't
break the pipeline.
required: false
default: ""
workspace:
description: >-
Path to the .xcworkspace. When set together with `scheme`, eligible
simulators are restricted to destinations the scheme supports.
required: false
default: ""
scheme:
description: Scheme to query via xcodebuild -showdestinations.
required: false
default: ""
outputs:
id:
description: UUID of the first available iPhone simulator
description: UUID of the selected iPhone simulator
value: ${{ steps.find.outputs.id }}
name:
description: Name of the selected iPhone simulator
value: ${{ steps.find.outputs.name }}
runtime:
description: Runtime identifier (or OS version) of the selected simulator
value: ${{ steps.find.outputs.runtime }}
runs:
using: composite
@@ -12,14 +45,138 @@ runs:
- id: find
shell: bash
run: |
SIM_ID=$(xcrun simctl list devices available -j | python3 -c "
import json, sys
data = json.load(sys.stdin)
for runtime, devices in data['devices'].items():
if 'iOS' in runtime:
for d in devices:
if 'iPhone' in d['name'] and d['isAvailable']:
print(d['udid']); sys.exit(0)
sys.exit(1)")
echo "Found simulator: $SIM_ID"
set -u
PREFERRED_DEVICE="${{ inputs.preferred-device }}"
PREFERRED_RUNTIME="${{ inputs.preferred-runtime }}"
WORKSPACE="${{ inputs.workspace }}"
SCHEME="${{ inputs.scheme }}"
SOURCE=""
PAYLOAD=""
if [ -n "$WORKSPACE" ] && [ -n "$SCHEME" ]; then
echo "Querying xcodebuild -showdestinations for $WORKSPACE / $SCHEME"
# -showdestinations writes destinations to stdout; also prints build
# settings chatter we don't care about. Capture everything; the
# parser filters by `platform:iOS Simulator`.
if PAYLOAD=$(xcodebuild -showdestinations -workspace "$WORKSPACE" -scheme "$SCHEME" 2>&1); then
SOURCE="xcodebuild"
else
echo "xcodebuild -showdestinations failed; falling back to simctl" >&2
fi
fi
if [ -z "$SOURCE" ]; then
echo "Listing simulators via simctl"
PAYLOAD=$(xcrun simctl list devices available -j)
SOURCE="simctl"
fi
SIM_SELECTION=$(
SOURCE="$SOURCE" \
PAYLOAD="$PAYLOAD" \
PREFERRED_DEVICE="$PREFERRED_DEVICE" \
PREFERRED_RUNTIME="$PREFERRED_RUNTIME" \
python3 - <<'PY'
import json
import os
import re
import sys
source = os.environ["SOURCE"]
payload = os.environ["PAYLOAD"]
preferred_device = os.environ.get("PREFERRED_DEVICE", "").strip().lower()
preferred_runtime = os.environ.get("PREFERRED_RUNTIME", "").strip()
# Tolerate full runtime identifiers like
# "com.apple.CoreSimulator.SimRuntime.iOS-18-4" — reduce to "18.4".
rt_match = re.search(r"(\d+(?:[-.]\d+)+)", preferred_runtime)
preferred_runtime = rt_match.group(1).replace("-", ".") if rt_match else ""
candidates = []
if source == "xcodebuild":
# Lines look like:
# { platform:iOS Simulator, arch:arm64, id:UDID, OS:18.6, name:iPhone 16 }
pattern = re.compile(
r"platform:iOS Simulator[^}]*?id:([0-9A-Fa-f-]+)[^}]*?OS:([0-9.]+)[^}]*?name:([^,}]+?)\s*(?:,|})"
)
for match in pattern.finditer(payload):
udid, os_version, name = match.groups()
name = name.strip()
if "iPhone" not in name:
continue
candidates.append(
{
"id": udid,
"name": name,
"runtime": os_version,
"runtime_version": os_version,
}
)
else:
data = json.loads(payload)
for runtime, devices in data["devices"].items():
if "iOS" not in runtime:
continue
version_match = re.search(r"iOS[- ](\d+(?:[-.]\d+)*)", runtime)
runtime_version = (
version_match.group(1).replace("-", ".") if version_match else ""
)
for device in devices:
if not device.get("isAvailable"):
continue
if "iPhone" not in device.get("name", ""):
continue
candidates.append(
{
"id": device["udid"],
"name": device["name"],
"runtime": runtime,
"runtime_version": runtime_version,
}
)
if not candidates:
sys.exit(1)
def version_tuple(candidate):
version = candidate["runtime_version"]
if not version:
return ()
return tuple(int(part) for part in version.split("."))
def score(candidate):
name_lower = candidate["name"].lower()
exact_name = bool(preferred_device) and name_lower == preferred_device
partial_name = bool(preferred_device) and preferred_device in name_lower
runtime_match = (
bool(preferred_runtime)
and candidate["runtime_version"] == preferred_runtime
)
# Higher tuple wins. Name match dominates so we never pick a random
# iPhone over the preferred one; runtime match is a soft tiebreaker;
# newest OS breaks remaining ties.
return (
int(exact_name),
int(partial_name),
int(runtime_match),
version_tuple(candidate),
)
best = max(candidates, key=score)
print(f'{best["id"]}\t{best["name"]}\t{best["runtime"]}')
PY
)
if [ -z "$SIM_SELECTION" ]; then
echo "No eligible iPhone simulators found (source=$SOURCE)"
exit 1
fi
SIM_ID=$(printf '%s' "$SIM_SELECTION" | cut -f1)
SIM_NAME=$(printf '%s' "$SIM_SELECTION" | cut -f2)
SIM_RUNTIME=$(printf '%s' "$SIM_SELECTION" | cut -f3)
echo "Selected simulator: $SIM_NAME ($SIM_ID) [$SIM_RUNTIME] via $SOURCE"
echo "id=$SIM_ID" >> "$GITHUB_OUTPUT"
echo "name=$SIM_NAME" >> "$GITHUB_OUTPUT"
echo "runtime=$SIM_RUNTIME" >> "$GITHUB_OUTPUT"

View File

@@ -5,15 +5,3 @@
## Test plan
<!-- How was this tested? -->
---
### Native Consolidation Checklist
<!-- Check items that apply to this PR. Delete section if not touching native code. -->
- [ ] CONTRACTS.md reviewed - no unintended contract changes
- [ ] Layer 1 bridge contract tests pass (`cd app && yarn jest:run` / `yarn workspace @selfxyz/rn-sdk-test-app test`)
- [ ] Layer 3 builds pass (app iOS, RN test app iOS, RN test app Android)
- [ ] Layer 4 manual smoke test signed off (if consolidation PR)
- [ ] No new native business logic added (logic belongs in TypeScript)

View File

@@ -4,7 +4,7 @@ env:
# Node version is read from .nvmrc during workflow execution
RUBY_VERSION: 3.2
JAVA_VERSION: 17
ANDROID_NDK_VERSION: 27.0.12077973
ANDROID_NDK_VERSION: 28.0.13004108
XCODE_VERSION: 26
# Path configuration
WORKSPACE: ${{ github.workspace }}
@@ -13,7 +13,7 @@ env:
GH_CACHE_VERSION: v2 # Global cache version - bumped to invalidate caches
GH_GEMS_CACHE_VERSION: v1 # Ruby gems cache version
# Performance optimizations
GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=4 -Dorg.gradle.parallel=true -Dorg.gradle.configureondemand=true -Dorg.gradle.caching=true
GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=2 -Dorg.gradle.parallel=true -Dorg.gradle.configureondemand=true -Dorg.gradle.caching=true -Dorg.gradle.jvmargs='-Xmx6144m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8'
CI: true
on:
pull_request:

View File

@@ -97,6 +97,11 @@ on:
required: false
type: boolean
default: false
pr_number:
description: "PR number for per-PR preview build. When set (Android only), uploads to Play Store Internal App Sharing and posts a Slack message with the install URL. Skips the version-bump PR."
required: false
type: string
default: ""
pull_request:
types: [closed]
@@ -131,10 +136,11 @@ on:
default: false
concurrency:
# Group by deployment track or ref name to allow different tracks to run in parallel
# cancel-in-progress: false ensures we don't cancel ongoing deployments
# Branch-locking in create-version-bump-pr prevents duplicate PRs for same version
group: mobile-deploy-${{ inputs.deployment_track || github.ref_name }}
# Group by PR number (when set) so multiple PR preview builds run in parallel,
# otherwise by deployment track or ref name to allow different tracks to run in parallel.
# cancel-in-progress: false ensures we don't cancel ongoing deployments.
# Branch-locking in create-version-bump-pr prevents duplicate PRs for same version.
group: mobile-deploy-${{ inputs.pr_number != '' && format('pr-{0}', inputs.pr_number) || inputs.deployment_track || github.ref_name }}
cancel-in-progress: false
jobs:
@@ -263,7 +269,9 @@ jobs:
permissions:
contents: read
actions: write
# PR preview builds are Android-only for now — always skip iOS when pr_number is set.
if: |
inputs.pr_number == '' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'closed' && github.event.pull_request.merged == true)) &&
(
@@ -1236,8 +1244,20 @@ jobs:
if: inputs.platform != 'ios'
uses: ./.github/actions/cleanup-gradle-artifacts
- name: Upload to Google Play Store using WIF
if: inputs.platform != 'ios' && inputs.test_mode != true
- name: Upload to Play Store Internal App Sharing (PR preview)
id: ias-upload
if: inputs.platform != 'ios' && inputs.test_mode != true && inputs.pr_number != ''
timeout-minutes: 10
run: |
cd ${{ env.APP_PATH }}
echo "🧪 PR preview build for #${{ inputs.pr_number }} — uploading to Internal App Sharing..."
python scripts/upload_to_play_store.py \
--aab "android/app/build/outputs/bundle/release/app-release.aab" \
--package-name "${{ secrets.ANDROID_PACKAGE_NAME }}" \
--mode ias
- name: Upload to Google Play Store track using WIF
if: inputs.platform != 'ios' && inputs.test_mode != true && inputs.pr_number == ''
timeout-minutes: 10
run: |
cd ${{ env.APP_PATH }}
@@ -1251,6 +1271,96 @@ jobs:
--package-name "${{ secrets.ANDROID_PACKAGE_NAME }}" \
--track "$DEPLOYMENT_TRACK"
- name: Post PR preview build link to Slack
if: inputs.platform != 'ios' && inputs.test_mode != true && inputs.pr_number != '' && steps.ias-upload.outputs.download_url != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_QA_BUILDS }}
PR_NUMBER: ${{ inputs.pr_number }}
DOWNLOAD_URL: ${{ steps.ias-upload.outputs.download_url }}
APP_VERSION: ${{ needs.bump-version.outputs.version }}
BUILD_NUMBER: ${{ needs.bump-version.outputs.android_build }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [ -z "$SLACK_WEBHOOK_URL" ]; then
echo "⚠️ SLACK_WEBHOOK_QA_BUILDS not configured — skipping Slack notify"
echo " (Install URL: $DOWNLOAD_URL)"
exit 0
fi
# Fetch PR metadata so the Slack card is informative
PR_JSON=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}" --jq '{title,html_url,user:.user.login,body}')
export PR_TITLE=$(echo "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["title"])')
export PR_URL=$(echo "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["html_url"])')
export PR_USER=$(echo "$PR_JSON" | python3 -c 'import json,sys;print(json.load(sys.stdin)["user"])')
python3 <<'PY'
import json, os, urllib.request
payload = {
"text": f"Android preview build ready for PR #{os.environ['PR_NUMBER']}",
"blocks": [
{
"type": "header",
"text": {"type": "plain_text", "text": f"🤖 Android preview — PR #{os.environ['PR_NUMBER']}"},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*<{os.environ['PR_URL']}|{os.environ.get('PR_TITLE','(no title)')}>*\n_by {os.environ.get('PR_USER','?')}_",
},
},
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": f"*Version*\n`{os.environ.get('APP_VERSION','?')} ({os.environ.get('BUILD_NUMBER','?')})`"},
{"type": "mrkdwn", "text": f"*Source*\n<{os.environ['RUN_URL']}|CI run>"},
],
},
{
"type": "actions",
"elements": [
{
"type": "button",
"style": "primary",
"text": {"type": "plain_text", "text": "📱 Install on Android"},
"url": os.environ["DOWNLOAD_URL"],
},
{
"type": "button",
"text": {"type": "plain_text", "text": "View PR"},
"url": os.environ["PR_URL"],
},
],
},
{
"type": "context",
"elements": [
{"type": "mrkdwn", "text": "Open the install link on your Android device (must be signed into a Play Store tester account)."},
],
},
],
}
req = urllib.request.Request(
os.environ["SLACK_WEBHOOK_URL"],
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
print(f"Slack webhook status: {resp.status}")
PY
# Also export to the run summary so the URL is visible in the GH Actions UI
{
echo "## 📱 Android preview build"
echo ""
echo "- PR: [#$PR_NUMBER]($PR_URL) — $PR_TITLE"
echo "- Version: \`$APP_VERSION ($BUILD_NUMBER)\`"
echo "- Install URL: $DOWNLOAD_URL"
} >> "$GITHUB_STEP_SUMMARY"
- name: Monitor cache usage
if: always()
run: |
@@ -1290,8 +1400,11 @@ jobs:
contents: write
pull-requests: write
needs: [bump-version, build-ios, build-android]
# Skip when this is a per-PR preview build — preview builds must not mutate
# the repo's version lineage.
if: |
always() &&
inputs.pr_number == '' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'closed' && github.event.pull_request.merged == true)) &&
(needs.build-ios.result == 'success' || needs.build-android.result == 'success')

View File

@@ -4,14 +4,14 @@ env:
# Build environment versions
JAVA_VERSION: 17
ANDROID_API_LEVEL: 34
ANDROID_NDK_VERSION: 27.0.12077973
ANDROID_NDK_VERSION: 28.0.13004108
XCODE_VERSION: 26
# Cache versions
GH_CACHE_VERSION: v2 # Global cache version - bumped to invalidate caches
GH_GEMS_CACHE_VERSION: v1 # Ruby gems cache version
# Performance optimizations
YARN_TASK_POOL_CONCURRENCY: 2 # Limit parallel native module builds to prevent OOM on self-hosted runners
GRADLE_OPTS: -Dorg.gradle.workers.max=4 -Dorg.gradle.parallel=true -Dorg.gradle.caching=true
GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=2 -Dorg.gradle.parallel=true -Dorg.gradle.caching=true -Dorg.gradle.jvmargs='-Xmx6144m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8'
CI: true
# Disable Maestro analytics in CI
MAESTRO_CLI_NO_ANALYTICS: true
@@ -328,10 +328,27 @@ jobs:
run: |
echo "Building Android APK..."
chmod +x app/android/gradlew
# Resource monitor so we have memory data if the build is killed
(
while true; do
ts="$(date -u +%H:%M:%S)"
mem="$(free -h | awk '/^Mem:/{print "used="$3" free="$4" avail="$7}')"
cg_cur="$(cat /sys/fs/cgroup/memory.current 2>/dev/null || echo n/a)"
cg_max="$(cat /sys/fs/cgroup/memory.max 2>/dev/null || echo n/a)"
echo "[gradle-monitor] ${ts} | ${mem} | cgroup=${cg_cur}/${cg_max}"
sleep 30
done
) &
MONITOR_PID=$!
trap 'kill "${MONITOR_PID}" 2>/dev/null || true' EXIT
(
cd app/android &&
E2E_BUILD=true ./gradlew assembleDebug -PbundleInDebug=true --quiet --parallel --build-cache --no-configuration-cache
E2E_BUILD=true ./gradlew assembleDebug -PbundleInDebug=true --quiet --build-cache --no-configuration-cache
) || { echo "❌ Android build failed"; exit 1; }
kill "${MONITOR_PID}" 2>/dev/null || true
echo "✅ Android build succeeded"
- name: Clean up Gradle build artifacts
@@ -442,7 +459,7 @@ jobs:
force-avd-creation: true
emulator-options: >-
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
-camera-back none -camera-front none -memory 8192 -cores 4
-camera-back none -camera-front none -memory 4096 -cores 4
${{ steps.kvm.outputs.available == 'true' && '-accel on' || '-accel off' }}
disable-animations: true
script: /tmp/run-e2e.sh
@@ -468,6 +485,8 @@ jobs:
# mobile-deploy.yml uses secrets for production deployment
IOS_PROJECT_NAME: "Self"
IOS_PROJECT_SCHEME: "OpenPassport"
IOS_SIMULATOR_DEVICE: "iPhone SE (3rd generation)"
IOS_SIMULATOR_RUNTIME: "18.4"
steps:
- uses: actions/checkout@v6
- name: Read and sanitize Node.js version
@@ -679,79 +698,6 @@ jobs:
env:
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
E2E_TESTING: 1
- name: Setup iOS Simulator
run: |
echo "Setting up iOS Simulator..."
# Ensure simulator directories exist
mkdir -p "$HOME/Library/Developer/CoreSimulator/Devices"
# First, check what simulators are actually available
echo "Available simulators:"
xcrun simctl list devices available || {
echo "❌ Failed to list available devices"
echo "Trying to list all devices:"
xcrun simctl list devices || {
echo "❌ Failed to list any devices"
exit 1
}
}
# Find iPhone SE (3rd generation) simulator
echo "Finding iPhone SE (3rd generation) simulator..."
AVAILABLE_SIMULATOR=$(xcrun simctl list devices available | grep "iPhone SE (3rd generation)" | head -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/')
if [ -z "$AVAILABLE_SIMULATOR" ]; then
echo "iPhone SE (3rd generation) not found, trying any iPhone SE..."
AVAILABLE_SIMULATOR=$(xcrun simctl list devices available | grep "iPhone SE" | head -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/')
fi
if [ -z "$AVAILABLE_SIMULATOR" ]; then
echo "No iPhone SE found, trying any iPhone..."
AVAILABLE_SIMULATOR=$(xcrun simctl list devices available | grep "iPhone" | head -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/')
fi
if [ -z "$AVAILABLE_SIMULATOR" ]; then
echo "❌ No available iPhone simulator found"
echo "Creating a new iPhone SE (3rd generation) simulator..."
# Create a new iPhone SE (3rd generation) simulator
xcrun simctl create "iPhone SE (3rd generation)" "iPhone SE (3rd generation)" || {
echo "❌ Failed to create iPhone SE (3rd generation) simulator"
echo "Trying to create any iPhone SE simulator..."
xcrun simctl create "iPhone SE" "iPhone SE" || {
echo "❌ Failed to create simulator"
exit 1
}
}
AVAILABLE_SIMULATOR=$(xcrun simctl list devices | grep "iPhone SE" | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/')
fi
echo "Using simulator: $AVAILABLE_SIMULATOR"
# Get simulator name for display
SIMULATOR_NAME=$(xcrun simctl list devices | grep "$AVAILABLE_SIMULATOR" | sed -E 's/^[[:space:]]*([^(]+).*/\1/' | xargs)
echo "Simulator name: $SIMULATOR_NAME"
# Boot simulator and wait for it to be ready
echo "Booting simulator..."
xcrun simctl boot "$AVAILABLE_SIMULATOR" || {
echo "❌ Failed to boot simulator"
exit 1
}
echo "Waiting for simulator to be ready..."
xcrun simctl bootstatus "$AVAILABLE_SIMULATOR" -b
# Wait for simulator to be fully ready
echo "Waiting for simulator to be fully ready..."
sleep 15
echo "Simulator status:"
xcrun simctl list devices | grep "$AVAILABLE_SIMULATOR"
# Store simulator ID for later use
echo "IOS_SIMULATOR_ID=$AVAILABLE_SIMULATOR" >> $GITHUB_ENV
echo "IOS_SIMULATOR_NAME=$SIMULATOR_NAME" >> $GITHUB_ENV
- name: Resolve iOS workspace
run: |
WORKSPACE_OPEN="app/ios/OpenPassport.xcworkspace"
@@ -765,6 +711,63 @@ jobs:
echo "WORKSPACE_PATH=$WORKSPACE_PATH" >> "$GITHUB_ENV"
echo "Resolved workspace: $WORKSPACE_PATH"
- name: Find iOS Simulator
id: find-ios-simulator
uses: ./.github/actions/find-ios-simulator
with:
preferred-device: ${{ env.IOS_SIMULATOR_DEVICE }}
preferred-runtime: ${{ env.IOS_SIMULATOR_RUNTIME }}
workspace: ${{ env.WORKSPACE_PATH }}
scheme: ${{ env.IOS_PROJECT_SCHEME }}
- name: Setup iOS Simulator
run: |
echo "Setting up iOS Simulator..."
# Ensure simulator directories exist
mkdir -p "$HOME/Library/Developer/CoreSimulator/Devices"
wait_for_simulator() {
local simulator_id="$1"
for attempt in {1..24}; do
if xcrun simctl spawn "$simulator_id" launchctl print system >/dev/null 2>&1; then
return 0
fi
echo "Simulator not ready yet (attempt $attempt/24)..."
sleep 5
done
return 1
}
echo "Available simulators:"
xcrun simctl list devices available
SIMULATOR_ID="${{ steps.find-ios-simulator.outputs.id }}"
SIMULATOR_NAME="${{ steps.find-ios-simulator.outputs.name }}"
SIMULATOR_RUNTIME="${{ steps.find-ios-simulator.outputs.runtime }}"
if [ -z "$SIMULATOR_ID" ]; then
echo "❌ No matching iPhone simulator found for ${IOS_SIMULATOR_DEVICE} on iOS ${IOS_SIMULATOR_RUNTIME}"
exit 1
fi
echo "Using simulator: $SIMULATOR_NAME ($SIMULATOR_ID) [$SIMULATOR_RUNTIME]"
echo "Resetting simulator to a clean state before boot..."
xcrun simctl shutdown "$SIMULATOR_ID" 2>/dev/null || true
xcrun simctl erase "$SIMULATOR_ID"
xcrun simctl boot "$SIMULATOR_ID" || true
xcrun simctl bootstatus "$SIMULATOR_ID" -b
echo "Waiting for simulator services to be ready..."
wait_for_simulator "$SIMULATOR_ID" || {
echo "❌ Simulator failed readiness checks"
exit 1
}
echo "Simulator status:"
xcrun simctl list devices | grep "$SIMULATOR_ID"
echo "IOS_SIMULATOR_ID=$SIMULATOR_ID" >> "$GITHUB_ENV"
echo "IOS_SIMULATOR_NAME=$SIMULATOR_NAME" >> "$GITHUB_ENV"
echo "IOS_SIMULATOR_RUNTIME=$SIMULATOR_RUNTIME" >> "$GITHUB_ENV"
- name: Build iOS App
env:
E2E_TESTING: 1
@@ -810,6 +813,25 @@ jobs:
continue-on-error: true
id: maestro-test
run: |
wait_for_app_install() {
local simulator_id="$1"
local bundle_id="$2"
for attempt in {1..12}; do
if xcrun simctl get_app_container "$simulator_id" "$bundle_id" app >/dev/null 2>&1; then
return 0
fi
echo "App container not ready yet (attempt $attempt/12)..."
sleep 5
done
return 1
}
run_maestro() {
MAESTRO_DEVICE_ID="$SIMULATOR_ID" \
MAESTRO_DRIVER_STARTUP_TIMEOUT=180000 \
maestro test app/tests/e2e/launch.ios.flow.yaml --format junit --output app/maestro-results.xml 2>&1
}
echo "Installing app on simulator..."
APP_PATH=$(find app/ios/build/Build/Products/Debug-iphonesimulator -name "*.app" | head -1)
[ -z "$APP_PATH" ] && { echo "❌ Could not find built iOS app"; exit 1; }
@@ -824,12 +846,6 @@ jobs:
SIMULATOR_ID="${IOS_SIMULATOR_ID:-iPhone SE (3rd generation)}"
echo "Installing on simulator: $SIMULATOR_ID"
echo "Erasing simulator to ensure clean state..."
xcrun simctl shutdown "$SIMULATOR_ID" 2>/dev/null || true
xcrun simctl erase "$SIMULATOR_ID"
xcrun simctl boot "$SIMULATOR_ID" || true
xcrun simctl bootstatus "$SIMULATOR_ID" -b
echo "Removing any existing app installation..."
xcrun simctl uninstall "$SIMULATOR_ID" "$IOS_BUNDLE_ID" 2>/dev/null || true
@@ -858,9 +874,7 @@ jobs:
}
echo "⏰ Checking simulator readiness..."
sleep 10
# Final readiness check (suppress errors to avoid annotations)
xcrun simctl get_app_container "$SIMULATOR_ID" "$IOS_BUNDLE_ID" app >/dev/null 2>&1 || sleep 5
wait_for_app_install "$SIMULATOR_ID" "$IOS_BUNDLE_ID" || echo "⚠️ App container readiness check timed out"
echo ""
echo "╔════════════════════════════════════════════════════════════════╗"
@@ -965,10 +979,16 @@ jobs:
echo ""
set +e # Don't exit on error
# Run with explicit device ID, increased timeout, and verbose output
# Note: exit code 2 typically means Maestro couldn't connect to device or daemon
MAESTRO_OUTPUT=$(MAESTRO_DEVICE_ID="$SIMULATOR_ID" MAESTRO_DRIVER_STARTUP_TIMEOUT=180000 maestro test app/tests/e2e/launch.ios.flow.yaml --format junit --output app/maestro-results.xml 2>&1)
# Run with explicit device ID, increased timeout, and retry once for known startup flakes.
MAESTRO_OUTPUT=$(run_maestro)
MAESTRO_EXIT_CODE=$?
if [ $MAESTRO_EXIT_CODE -ne 0 ] && printf '%s' "$MAESTRO_OUTPUT" | grep -Eq "MaestroDriverStartupException|IOSDriverTimeoutException"; then
echo "⚠️ Maestro driver startup failed, retrying once after 30s..."
sleep 30
MAESTRO_OUTPUT_RETRY=$(run_maestro)
MAESTRO_EXIT_CODE=$?
MAESTRO_OUTPUT=$(printf '%s\n\n===== RETRY AFTER DRIVER STARTUP FAILURE =====\n\n%s' "$MAESTRO_OUTPUT" "$MAESTRO_OUTPUT_RETRY")
fi
set -e
echo "Maestro command completed with exit code: $MAESTRO_EXIT_CODE"
@@ -1095,12 +1115,22 @@ jobs:
fi
fi
- name: Upload test results
if: always() && env.ENABLE_MAESTRO_RECORDING == 'true'
if: always()
uses: actions/upload-artifact@v4
with:
name: maestro-results-ios
path: app/maestro-results.xml
if-no-files-found: warn
- name: Upload test logs
if: always()
uses: actions/upload-artifact@v4
with:
name: maestro-logs-ios
path: |
app/test-artifacts/maestro-output.log
app/test-artifacts/simulator-system.log
app/test-artifacts/*.crash
if-no-files-found: warn
- name: Upload test artifacts (video and screenshots)
if: always() && env.ENABLE_MAESTRO_RECORDING == 'true'
uses: actions/upload-artifact@v4

View File

@@ -45,6 +45,10 @@ nvm use && corepack enable && yarn install
- **Constraint tie-breaker.** If rules conflict: correctness and security first, then scope/clarity (small PRs, small files), then reuse. Document the tradeoff in the spec.
- **Linear issue descriptions are immutable after creation.** Never overwrite an issue description with `save_issue` to add updates, status notes, or context. Issue descriptions are the original scope set at creation time. All subsequent updates — status changes, progress notes, discovered context, blockers, decision records — go in **comments** via `save_comment`. The only valid use of `save_issue` on an existing issue is to change structured fields (status, priority, assignee, labels). If you need to correct a factual error in the description, add a comment explaining the correction rather than silently rewriting history.
## Project Dictionary
When asked about an unfamiliar project term, **look it up in the [Self Dictionary](https://www.notion.so/34257801cd1280a4b348d01fac82a2be) in Notion first** — before searching the codebase. The dictionary is the authoritative source for project terminology.
## Specs & Planning
**Every feature — even minor ones — needs a spec.** For SDK work (`packages/`, `webview-app`, `webview-bridge`), specs live in **both** the repo (`specs/`) and Linear. The repo spec is the canonical, version-controlled execution plan. The Linear issue is the tracking and discovery layer. For app-only or non-SDK work, a Linear issue with inline scope is sufficient — no repo spec required.

View File

@@ -123,12 +123,6 @@ android {
namespace "com.proofofpassportapp"
// Build optimizations
dexOptions {
javaMaxHeapSize "4g"
preDexLibraries false
}
buildFeatures {
buildConfig = true
viewBinding = true
@@ -138,7 +132,7 @@ android {
applicationId "com.proofofpassportapp"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 147
versionCode 148
versionName "2.9.17"
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
externalNativeBuild {

View File

@@ -49,7 +49,6 @@ allprojects {
substitute(platform(module('com.gemalto.jp2:jp2-android'))) using module('com.github.Tgo1014:JP2ForAndroid:1.0.4')
substitute module('io.fotoapparat:fotoapparat') using module('com.github.fotoapparat:fotoapparat:2.7.0')
}
resolutionStrategy.force 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava'
}
configurations.all {
resolutionStrategy {

View File

@@ -83,6 +83,42 @@ def using_https_git_auth?
end
end
# ── WalletConnect Pay / YttriumWrapper removal ──────────────────────────────
# react-native-compat v2.23+ bundles WalletConnect Pay which depends on
# YttriumWrapper (Yttrium UniFFI bindings). The Self app does not use
# WalletConnect Pay and the binary pod fails to build. Patch the podspec
# and remove the Pay bridge Swift file before CocoaPods resolves deps.
wc_compat_dir = File.join(__dir__, "..", "node_modules", "@walletconnect", "react-native-compat")
wc_podspec = File.join(wc_compat_dir, "react-native-compat.podspec")
if File.exist?(wc_podspec)
podspec_text = File.read(wc_podspec)
if podspec_text.include?('YttriumWrapper')
podspec_text.gsub!(/^\s*s\.dependency\s+"YttriumWrapper".*$/, ' # s.dependency "YttriumWrapper" — removed (not used)')
File.write(wc_podspec, podspec_text)
Pod::UI.puts "Patched react-native-compat.podspec: removed YttriumWrapper dependency".yellow
end
end
files_removed = Dir.glob(File.join(wc_compat_dir, "ios", "RNWalletConnectPay*")).each do |f|
FileUtils.rm(f)
end
Pod::UI.puts "Removed WalletConnect Pay source files (not used)".yellow if files_removed.any?
# ── Haptic feedback AudioToolbox fix ────────────────────────────────────────
# react-native-haptic-feedback uses AudioServicesPlaySystemSound but its
# podspec doesn't declare the AudioToolbox framework. Patch it.
haptic_podspec = File.join(__dir__, "..", "node_modules", "react-native-haptic-feedback", "RNReactNativeHapticFeedback.podspec")
if File.exist?(haptic_podspec)
haptic_text = File.read(haptic_podspec)
if !haptic_text.include?("AudioToolbox")
haptic_text.gsub!(/s\.requires_arc\s*=\s*true/, "s.requires_arc = true\n s.frameworks = \"AudioToolbox\"")
File.write(haptic_podspec, haptic_text)
Pod::UI.puts "Patched RNReactNativeHapticFeedback.podspec: added AudioToolbox framework".yellow
end
end
# ─────────────────────────────────────────────────────────────────────────────
target "Self" do
use_expo_modules!
# Native module exclusion for E2E testing is handled in react-native.config.cjs
@@ -108,7 +144,7 @@ target "Self" do
nfc_repo_url = "git@github.com:selfxyz/NFCPassportReader.git"
end
pod "SelfNFCPassportReader", git: nfc_repo_url, commit: "2cdc50a5c27b75594b94b27fdc4bb6172ada0f96"
pod "SelfNFCPassportReader", git: nfc_repo_url, commit: "b478e1f1320e86f72c5755bc5adf156fff950585"
end
# Explicitly declare Mixpanel to ensure it's available even in E2E builds

View File

@@ -27,6 +27,10 @@ PODS:
- React-Core
- ExpoAsset (11.0.5):
- ExpoModulesCore
- ExpoCamera (16.0.18):
- ExpoModulesCore
- ZXingObjC/OneD
- ZXingObjC/PDF417
- ExpoFileSystem (18.0.12):
- ExpoModulesCore
- ExpoFont (13.0.4):
@@ -1495,7 +1499,6 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- YttriumWrapper (= 0.10.50)
- react-native-get-random-values (1.11.0):
- React-Core
- react-native-netinfo (11.4.1):
@@ -2218,6 +2221,11 @@ PODS:
- SwiftyTesseract (3.1.3)
- Yoga (0.0.0)
- YttriumWrapper (0.10.50)
- ZXingObjC/Core (3.6.9)
- ZXingObjC/OneD (3.6.9):
- ZXingObjC/Core
- ZXingObjC/PDF417 (3.6.9):
- ZXingObjC/Core
DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
@@ -2229,6 +2237,7 @@ DEPENDENCIES:
- Expo (from `../node_modules/expo`)
- "ExpoAdapterGoogleSignIn (from `../node_modules/@react-native-google-signin/google-signin/expo/ios`)"
- ExpoAsset (from `../node_modules/expo-asset/ios`)
- ExpoCamera (from `../node_modules/expo-camera/ios`)
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
- ExpoFont (from `../node_modules/expo-font/ios`)
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
@@ -2334,7 +2343,7 @@ DEPENDENCIES:
- RNSVG (from `../node_modules/react-native-svg`)
- "SdkReactNative (from `../node_modules/@didit-protocol/sdk-react-native`)"
- "segment-analytics-react-native (from `../node_modules/@segment/analytics-react-native`)"
- "SelfNFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`, commit `2cdc50a5c27b75594b94b27fdc4bb6172ada0f96`)"
- SelfNFCPassportReader (from `https://github.com/selfxyz/NFCPassportReader.git`, commit `2cdc50a5c27b75594b94b27fdc4bb6172ada0f96`)
- "sovran-react-native (from `../node_modules/@segment/sovran-react-native`)"
- SwiftQRScanner (from `https://github.com/vinodiOS/SwiftQRScanner`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
@@ -2371,6 +2380,7 @@ SPEC REPOS:
- SocketRocket
- SwiftyTesseract
- YttriumWrapper
- ZXingObjC
EXTERNAL SOURCES:
boost:
@@ -2391,6 +2401,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-google-signin/google-signin/expo/ios"
ExpoAsset:
:path: "../node_modules/expo-asset/ios"
ExpoCamera:
:path: "../node_modules/expo-camera/ios"
ExpoFileSystem:
:path: "../node_modules/expo-file-system/ios"
ExpoFont:
@@ -2592,7 +2604,7 @@ EXTERNAL SOURCES:
:path: "../node_modules/@segment/analytics-react-native"
SelfNFCPassportReader:
:commit: 2cdc50a5c27b75594b94b27fdc4bb6172ada0f96
:git: "git@github.com:selfxyz/NFCPassportReader.git"
:git: https://github.com/selfxyz/NFCPassportReader.git
sovran-react-native:
:path: "../node_modules/@segment/sovran-react-native"
SwiftQRScanner:
@@ -2603,7 +2615,7 @@ EXTERNAL SOURCES:
CHECKOUT OPTIONS:
SelfNFCPassportReader:
:commit: 2cdc50a5c27b75594b94b27fdc4bb6172ada0f96
:git: "git@github.com:selfxyz/NFCPassportReader.git"
:git: https://github.com/selfxyz/NFCPassportReader.git
SwiftQRScanner:
:commit: c71ff91297640a944de4bca61434155c3f9b0979
:git: https://github.com/vinodiOS/SwiftQRScanner
@@ -2612,7 +2624,7 @@ SPEC CHECKSUMS:
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70
BVLinearGradient: 880f91a7854faff2df62518f0281afb1c60d49a3
DiditSDK: 3113b1aa1e5f67e84e84bd8449273257e5d9eff0
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
EXApplication: 4c72f6017a14a65e338c5e74fca418f35141e819
@@ -2620,6 +2632,7 @@ SPEC CHECKSUMS:
Expo: 4bb70893882e6382b41d1e910d7226c6a1b85f0a
ExpoAdapterGoogleSignIn: ab4d9fc38cb91077a4138d178395525ec65d0c2e
ExpoAsset: 48386d40d53a8c1738929b3ed509bcad595b5516
ExpoCamera: 0a3e78de7b1ca8c438ee6784fa6f22c5b1b36966
ExpoFileSystem: 42d363d3b96f9afab980dcef60d5657a4443c655
ExpoFont: f354e926f8feae5e831ec8087f36652b44a0b188
ExpoKeepAwake: b0171a73665bfcefcfcc311742a72a956e6aa680
@@ -2647,111 +2660,112 @@ SPEC CHECKSUMS:
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
hermes-engine: 1f783c3d53940aed0d2c84586f0b7a85ab7827ef
lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3
lottie-react-native: d73a798e26348851f0ef349df3d40f2e27fd239b
lottie-react-native: 7f9c8ce134aeaa6530f8b78f8cabed4aab6b5a9a
Mixpanel-swift: e9bef28a9648faff384d5ba6f48ecc2787eb24c0
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
OpenSSL-Universal: 84efb8a29841f2764ac5403e0c4119a28b713346
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
QKMRZParser: 6b419b6f07d6bff6b50429b97de10846dc902c29
QKMRZScanner: cf2348fd6ce441e758328da4adf231ef2b51d769
RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82
RCT-Folly: 36fe2295e44b10d831836cc0d1daec5f8abcf809
RCTDeprecation: f5c19ebdb8804b53ed029123eb69914356192fc8
RCTRequired: 6ae6cebe470486e0e0ce89c1c0eabb998e7c51f4
RCTTypeSafety: 50d6ec72a3d13cf77e041ff43a0617050fb98e3f
React: e46fdbd82d2de942970c106677056f3bdd438d82
React-callinvoker: b027ad895934b5f27ce166d095ed0d272d7df619
React-Core: 92733c8280b1642afed7ebfb3c523feaec946ece
React-CoreModules: e2dfd87b6fdb9d969b16871655885a4d89a2a9f4
React-cxxreact: d1a70e78543bb5b159fdaf6c52cadd33c1ae3244
React-Core: 36b7f20f655d47a35046e2b02c9aa5a8f1bcb61e
React-CoreModules: 7fac6030d37165c251a7bd4bde3333212544da3c
React-cxxreact: 0ead442ecaa248e7f71719e286510676495ae26d
React-debug: c17d400ddcb2c45aa4f5efedeb443c72b58b40aa
React-defaultsnativemodule: af13e4f2629106aede1d6286921f852715017d64
React-domnativemodule: b6785fc507cfcbdf24509a0be26fdac7454f7ea3
React-Fabric: 5f8c48a36ff906a0e8761ff914ef368f67a25b59
React-FabricComponents: 2ba16205b15ce80460a1dcc3725b3926493b47f8
React-FabricImage: d1b0c203284c0ab077277a54830e4de4c0134908
React-defaultsnativemodule: d8ddce2020fede6b0a6d3cccc3fbb1fedf1aab37
React-domnativemodule: 17da9148ba917807b9bab6c4e1fddbc11303be64
React-Fabric: fda27452bab6f8b5213f33c1d59a24f6c6b66579
React-FabricComponents: 10623f84dcb5ae9b2bbe98f577546b10fa459fdb
React-FabricImage: 2237e1c2089eb4e55541485e173f96af43afca7d
React-featureflags: 94805545eda554c548e3615f248f4f4c65ef279e
React-featureflagsnativemodule: 0ab7272372052fe9dc561dc2e4bbd4fd8ab11ea4
React-graphics: 6800e73b337075ad0cb9226c1592ed1a91703244
React-hermes: bf50c8272cb562300a54a621aa69dc12a0b4fcf2
React-idlecallbacksnativemodule: 57d5b25440ed0478966710675354eac676508ff5
React-ImageManager: fff4c0c50041d7b8f67d6f435e7a4b1e9125ad27
React-jserrorhandler: 4abc5dfa7d5fb7bfba328faddfa97dc90441c276
React-jsi: 19e77567e235d06b7e8f425d2a6c1e948ab286e9
React-jsiexecutor: fe6ad8b9a2bf97e435fc1c969c80ed7f447ed68e
React-jsinspector: 01aa56b6037c65a6ec4432a120aa74cc6fdf514f
React-jsitracing: cb05a2c5c36eb212be028e26c38028f0d352c16b
React-logger: 02e5802824aa9b15cb7df42e10a91abead83cd8d
React-Mapbuffer: bbd3be71ef32e8198ac0f78b841662103e032ffe
React-microtasksnativemodule: 8e65fc37744388153b9bca94552d04955d852058
react-native-app-auth: e21c8ee920876b960e38c9381971bd189ebea06b
react-native-biometrics: 43ed5b828646a7862dbc7945556446be00798e7d
react-native-blur: 745703f35133ed6a1210d4bbff358a631911f002
react-native-cloud-storage: 796c793dc354bb49f9df27ca25eed0f79a15549e
react-native-compat: ad6a412f03632d1c4d97d47e56b22d0597116085
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-nfc-manager: c8891e460b4943b695d63f7f4effc6345bbefc83
react-native-passkey: 3c07a93dc2608929d794b7298c0d29d01b379f01
react-native-safe-area-context: bf457bf5b3a617e9a3930d1ecd954a3335303cc7
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
react-native-webview: 05734d99f1e422c5ddfeefbd083d53abd78fccb1
React-featureflagsnativemodule: b71dc56c26b09c5becaabc59d90eb6715a76d01e
React-graphics: f81c5369a01264f5e5f2ab7b2e7fbe769c94ed42
React-hermes: 13e1c1c9222503bcd7ad450370c5a26dc9b46ebe
React-idlecallbacksnativemodule: 16c2ade55cf3537f7d6d1afb7acb230d65b1d63c
React-ImageManager: 130248847aada2e9485db30cef63284ffc2f0846
React-jserrorhandler: ef0948d6835b991094660d93cb7dcf3446d065f5
React-jsi: 931610846e52e5d157f4bc3f71a14f9a53573abd
React-jsiexecutor: 3f5fb21d47c5c72c13a1710b288d78c8209a38f9
React-jsinspector: 231977808d975ea2ad045b910623651ef7219657
React-jsitracing: 9b717dd9c91915ccf51af10df94e8c38de722786
React-logger: 9a0c4e1e41cd640ac49d69aacadab783f7e0096b
React-Mapbuffer: 257e617e7554c0ec448d13d38b13ee3cbdd3c5eb
React-microtasksnativemodule: fa9db75d61e2053274057767ced1a2e2c485b0fa
react-native-app-auth: 9b0a0e3ca279c3426a451e2607c8483808b8ed4a
react-native-biometrics: 352e5a794bfffc46a0c86725ea7dc62deb085bdc
react-native-blur: 6782cb12b39a0200ad2a782fb9a5529c2c83c33b
react-native-cloud-storage: 8dc640aac2cf6e8a6231cc49696e8f8405b716bf
react-native-compat: ef7486ca6f41481467445f4e0fd997d42a84460f
react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06
react-native-netinfo: f0a9899081c185db1de5bb2fdc1c88c202a059ac
react-native-nfc-manager: ef3b44c4f1975ab16d6109bb1671ab68068aba58
react-native-passkey: 8a3ecd4c44e020323841b9d90e779e9dd9e1db27
react-native-safe-area-context: f73c45199e5df289e0655c8fceb4e6f4fcfab256
react-native-sqlite-storage: f6d515e1c446d1e6d026aa5352908a25d4de3261
react-native-webview: de5205a97121427588aff27de2ddea4cc9fc0a19
React-nativeconfig: 334c9961d74ddd3bc203afb92ee574ed01c7c755
React-NativeModulesApple: bf996c9e3b86e579e6e8635633b721c165a60b2c
React-perflogger: 721172bda31a65ce7b7a0c3bf3de96f12ef6f45d
React-performancetimeline: a23bfc89694e13ead855f25049bb9d60ce3704a2
React-NativeModulesApple: e55f72e014482edd711542815a98b865ee6de9a1
React-perflogger: 15a7bcb6c46eae8a981f7add8c9f4172e2372324
React-performancetimeline: 9fb03db27775ddef6a98e3d22811acf210f07ba4
React-RCTActionSheet: 25eb72eabade4095bfaf6cd9c5c965c76865daa8
React-RCTAnimation: 8efbd0a4a71fd3dbe84e6d08b92bec5728b7524b
React-RCTAppDelegate: 8ff6da817adefd15d4e25ade53a477c344f9b213
React-RCTBlob: 6056bd62a56a6d2dad55cdf195949db1de623e14
React-RCTFabric: 113fe8b6532ac21a6a46700b2650b8d458020ee4
React-RCTFBReactNativeSpec: 4214925b1c4829fb1e73bfbacb301244b522dc11
React-RCTImage: 7b3f38c77e183bdcb43dbcd7b5842b96c814889a
React-RCTLinking: 6cca74db71b23f670b72e45603e615c2b72b2235
React-RCTNetwork: 5791b0718eff20c12f6f3d62e2ad50cff4b5c8a0
React-RCTSettings: 84154e31a232b5b03b6b7a89924a267c431ccf16
React-RCTText: cd49cb4442ee7f64b0415b27745d2495cb40cfaa
React-RCTVibration: 2a7432e61d42f802716bd67edc793b5e5f58971a
React-RCTAnimation: 04c987fa858fa16169f543d29edb4140bd35afa9
React-RCTAppDelegate: b2707904e4f8ad92fd052e62684bf0c3b88381cc
React-RCTBlob: 1f214a7211632515805dd1f1b81fac70d12f812d
React-RCTFabric: 0838a13e11c221d1d5648257b2ca31fede22874b
React-RCTFBReactNativeSpec: 60d72b45a150ca35748b9a77028674b1e56a2e43
React-RCTImage: e516d72739797fb7c1dac5c691f02a0f5445c290
React-RCTLinking: 1e5554afe4f959696ad3285738c1510f2592f220
React-RCTNetwork: 65e1e52c8614dcab342fa1eaec750ca818160e74
React-RCTSettings: e86c204b481ef9264929fe00d1fdd04ce561748a
React-RCTText: 15f14d6f9b75e64ffe749c75e30ff047cf0fa1be
React-RCTVibration: 8d9078d5432972fe12d9f1526b38f504ad3d45cb
React-rendererconsistency: 9da9009da0eafdf005a77a260b1dbea274a90aa8
React-rendererdebug: 4b9e70532888e08f41c5fcbcbc050e99a590839c
React-rendererdebug: bb56856ce3901396c959ddcf0991f7a3a162f4c5
React-rncore: d380e5c97ec669c0bd097612cd98247597a32679
React-RuntimeApple: 0088247d510e7eb4a3a2ecc0964411266730d10d
React-RuntimeCore: 0e45d29ad4057b029db38e92ab24d4294253c6e3
React-RuntimeApple: 559b3d8f068335e896224b8365fd8cee814e6652
React-RuntimeCore: 87c25d97233f61b68bb254360e2724c01eb93198
React-runtimeexecutor: f9ae11481be048438640085c1e8266d6afebae44
React-RuntimeHermes: 4d6bbb8c4832794c34fc2a0301a885a9e8c936d5
React-runtimescheduler: bb1282886aa8ba594ff5704c14ba19af1551149f
React-RuntimeHermes: 0cba4a2b329dcb8392754dd20a839709c7e3389f
React-runtimescheduler: 62d73526c3471884a896328e11a930ea4b42dfe1
React-timing: 9b94f0fb713587a697ce56b0fc7cb31cb5be70a5
React-utils: 07c3365e9dcbb8940e912ce099b20fb0e56dbacf
ReactAppDependencyProvider: 6e8d68583f39dc31ee65235110287277eb8556ef
ReactCodegen: 58a974a1a86362975fd49596480c5f0f17ee06a2
ReactCommon: e686c5766f0ebe5293be5a3957b833645cdac8ad
RNAppleAuthentication: a89c9804592b38ed4ab11f0aee68d05ba12ad432
RNCAsyncStorage: 6a8127b6987dc9fbce778669b252b14c8355c7ce
RNCClipboard: 9f7b908de4bf4353871fb454c15fc03db4917b88
RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e
RNFBAnalytics: 03c83ba4617a3754c99e66267983efcc908932a9
RNFBApp: a448037d2df74af9d374a0b765be12ff1e844dc0
RNFBMessaging: 0f0498a95c605e3afcf13ac5f349d0b201ea65f6
RNFBRemoteConfig: 4eb5fc9f21dc324153c7d3f5b48c935ab9031876
RNGestureHandler: 36aca36e4ef19f55dbf97239199d00fd58494e34
RNGoogleSignin: 60c3f470558dbff0ae54f2f164ef82a89d3eb561
RNInAppBrowser: 904d24dc75e8e6c6c98a3160329192608946f9df
RNKeychain: 35beaa17938f7d8e4990d8a38fad5f8a748fc47c
RNLocalize: aa57bee9fcd545b98ce773a8e2404f9a36115b4a
RNReactNativeHapticFeedback: eb5395b503c7a8f10de5e6722ef8afd3c61bc4f5
RNScreens: b0811b109e1a0b8b579f3348018e177bee374840
RNSentry: 98ab9f6a16c9596e36565ccf1a5871323f334766
RNSVG: d926926b169d8b81eb06aeb69734076e1dd566a3
SdkReactNative: 34ba85b3f3060892c548b7415b06f0bd66fcba1c
segment-analytics-react-native: 8ab9c49df1859bbd6be93cf90a91ade17f20a0aa
React-utils: 9e73840482020d1914b68089e807b3f2f56b10a3
ReactAppDependencyProvider: 3d947e9d62f351c06c71497e1be897e6006dc303
ReactCodegen: e92a1659b32705bd8ee0d2ba016d6993a4ade05b
ReactCommon: a02340b2a1a76f3703298a4680bb03277ca87440
RNAppleAuthentication: d6fe579e5f43cf8db54bdc48518bccea61c592a4
RNCAsyncStorage: 481acf401089f312189e100815088ea5dafc583c
RNCClipboard: 4d8c76e488f1491e5235901b7028ff53a678bd94
RNDeviceInfo: d79872e11c8e9c4de0d65b0ee6e0cee719f37fea
RNFBAnalytics: caec446c723d33cfdcf75aab53fa1287e499b2d0
RNFBApp: fda4a8b08fe31bea8492808106aa638d1bb595b7
RNFBMessaging: 099972ab397d61815f32610514b0573d1db2b1e1
RNFBRemoteConfig: ce28355c29a432ac29c9a9d10816a906c0fee938
RNGestureHandler: 75a1894590b15c560094c2b09c5dce6a64eefa29
RNGoogleSignin: bd5e55072fc89c69e3eb139be2a9c8935d0a0f2c
RNInAppBrowser: b53e6f6072c931115bc22ac9dc9510ac2cbea62d
RNKeychain: 774184659ed098fd715a4976d44e2003c829934f
RNLocalize: af6fb26fab9def1513da0afacc1cb6781200871e
RNReactNativeHapticFeedback: 5e0b136ae9fa95f0227ef5d6216d732f680d2904
RNScreens: cc97e4382039563c725394067185356352df69ad
RNSentry: f343c58d33eb8351a5b5cfbb157d3527e2f59645
RNSVG: 2b1b9e597b2a0847e2963aefe17d976d5c882f3f
SdkReactNative: bec23c7789377abaf8218494b3b2e41a690c2c07
segment-analytics-react-native: ab16aeb1731acc05670dcec1aaed13b6bcbc1b6d
SelfNFCPassportReader: 8b53f9d483e0dd1f1a275953e3dc6dfc733694c5
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
sovran-react-native: a3ad3f8ff90c2002b2aa9790001a78b0b0a38594
sovran-react-native: eec37f82e4429f0e3661f46aaf4fcd85d1b54f60
SwiftQRScanner: e85a25f9b843e9231dab89a96e441472fe54a724
SwiftyTesseract: 1f3d96668ae92dc2208d9842c8a59bea9fad2cbb
Yoga: c34725819ab0a5962e85455b9e56679b306910ee
YttriumWrapper: d7f63336830536f1da41b745ed8bacedb04228c4
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
PODFILE CHECKSUM: 83f631d1b6308502a035e656b2f9dfab30431ae3
PODFILE CHECKSUM: 52fe8b0692d175ef85e813596606f198bb5d3728
COCOAPODS: 1.16.2

View File

@@ -477,7 +477,7 @@
CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassportDebug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 217;
CURRENT_PROJECT_VERSION = 218;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_APP_SANDBOX = NO;
ENABLE_BITCODE = NO;
@@ -620,7 +620,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassport.entitlements;
CURRENT_PROJECT_VERSION = 217;
CURRENT_PROJECT_VERSION = 218;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_APP_SANDBOX = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = YES;

View File

@@ -36,6 +36,7 @@ module.exports = {
'^@$': '<rootDir>/src',
'^@tests/(.*)$': '<rootDir>/tests/src/$1',
'^@tests$': '<rootDir>/tests/src',
'^expo-camera$': '<rootDir>/tests/__setup__/expoCameraMock.js',
// Map react-native-svg to app's node_modules for all packages
'^react-native-svg$': '<rootDir>/node_modules/react-native-svg',
'^@selfxyz/mobile-sdk-alpha$':

View File

@@ -127,6 +127,7 @@
"ethers": "^6.16.0",
"expo": "~52.0.40",
"expo-application": "~6.0.2",
"expo-camera": "~16.0.18",
"hash.js": "^1.1.7",
"js-sha1": "^0.7.0",
"js-sha256": "^0.11.1",

View File

@@ -79,6 +79,56 @@ def should_hold_for_manual_review(track):
return track == 'production'
def upload_to_internal_app_sharing(aab_path, package_name, credentials):
"""Upload AAB to Google Play Internal App Sharing.
Returns a unique downloadUrl per upload. Designed for per-PR/per-build
preview distribution: does NOT advance any track, does NOT require a
unique versionCode, and does NOT go through review.
"""
print(f"📤 Uploading {aab_path} to Internal App Sharing...")
try:
service = build('androidpublisher', 'v3', credentials=credentials)
media = MediaFileUpload(aab_path, mimetype='application/octet-stream')
request = service.internalappsharingartifacts().uploadbundle(
packageName=package_name,
media_body=media,
)
response = request.execute()
download_url = response.get('downloadUrl')
sha256 = response.get('sha256')
cert_fingerprint = response.get('certificateFingerprint')
if not download_url:
print("❌ IAS upload returned no downloadUrl")
print(f"Response: {response}")
return False
print(f"✅ Uploaded to Internal App Sharing")
print(f"🔗 downloadUrl: {download_url}")
if sha256:
print(f"🔐 sha256: {sha256}")
if cert_fingerprint:
print(f"📜 certificateFingerprint: {cert_fingerprint}")
# Expose the URL to GitHub Actions via $GITHUB_OUTPUT when available
github_output = os.environ.get('GITHUB_OUTPUT')
if github_output:
with open(github_output, 'a') as f:
f.write(f"download_url={download_url}\n")
if sha256:
f.write(f"sha256={sha256}\n")
return True
except Exception as e:
print(f"❌ IAS upload failed: {e}")
return False
def upload_to_play_store(aab_path, package_name, track, credentials):
"""Upload AAB to Google Play Store"""
print(f"📤 Uploading {aab_path} to Play Store...")
@@ -160,7 +210,9 @@ def main():
parser = argparse.ArgumentParser(description='Upload Android AAB to Google Play Store using WIF')
parser.add_argument('--aab', required=True, help='Path to the AAB file')
parser.add_argument('--package-name', required=True, help='Android package name')
parser.add_argument('--track', default='internal', help='Release track (internal, alpha, beta, production)')
parser.add_argument('--track', default='internal', help='Release track (internal, alpha, beta, production). Ignored when --mode=ias.')
parser.add_argument('--mode', default='track', choices=['track', 'ias'],
help='Upload mode: "track" promotes to a Play Store track; "ias" uploads to Internal App Sharing and returns a unique downloadUrl.')
args = parser.parse_args()
@@ -170,15 +222,20 @@ def main():
print(f"❌ Error: AAB file not found: {aab_path}")
sys.exit(1)
print("🚀 Starting Google Play Store upload with Workload Identity Federation")
print("🚀 Starting Google Play upload with Workload Identity Federation")
print(f"📦 AAB: {aab_path}")
print(f"📱 Package: {args.package_name}")
print(f"🎯 Track: {args.track}")
print(f"🧭 Mode: {args.mode}")
if args.mode == 'track':
print(f"🎯 Track: {args.track}")
print()
# Get credentials and upload
credentials = get_credentials()
success = upload_to_play_store(str(aab_path), args.package_name, args.track, credentials)
if args.mode == 'ias':
success = upload_to_internal_app_sharing(str(aab_path), args.package_name, credentials)
else:
success = upload_to_play_store(str(aab_path), args.package_name, args.track, credentials)
if success:
print("\n🎉 Upload completed successfully!")

View File

@@ -4,22 +4,34 @@
import type { FC } from 'react';
import React from 'react';
import { Image } from 'react-native';
import { YStack } from 'tamagui';
import { Dimensions, Image } from 'react-native';
import { Separator, Text, XStack, YStack } from 'tamagui';
import { deserializeApplicantInfo } from '@selfxyz/common';
import { commonNames } from '@selfxyz/common/constants/countries';
import type { KycData } from '@selfxyz/common/utils/types';
import { RoundFlag } from '@selfxyz/mobile-sdk-alpha/components';
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import {
black,
separatorColor,
slate100,
slate400,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import CardBackgroundId1 from '@/assets/images/card_background_id1.png';
import LogoGray from '@/assets/images/logo_gray.svg';
import SelfLogoPending from '@/assets/images/self_logo_pending.svg';
import CardBottomContent from '@/components/homescreen/CardBottomContent';
import CardHeader from '@/components/homescreen/CardHeader';
import { cardStyles } from '@/components/homescreen/cardStyles';
import IdAttribute from '@/components/homescreen/IdAttribute';
import { useCardDimensions } from '@/hooks/useCardDimensions';
import { getCountryAdjective } from '@/utils/countryDemonyms';
import {
getCountryAdjective,
getCountryDemonym,
} from '@/utils/countryDemonyms';
interface KycIdCardProps {
idDocument: KycData;
@@ -44,18 +56,23 @@ function getKycDocTitle(idType: string): string {
}
/**
* KYC document card - matches IdCard design exactly but shows "STANDARD" badge.
* Used for documents verified through KYC flow (drivers license, etc.).
* Format YYYYMMDD to DD/MM/YYYY for display.
*/
const KycIdCard: FC<KycIdCardProps> = ({
idDocument,
selected,
hidden: _hidden,
}) => {
function formatKycDate(date: string): string {
if (date.length !== 8) return date;
return `${date.slice(6, 8)}/${date.slice(4, 6)}/${date.slice(0, 4)}`;
}
const KycIdCard: FC<KycIdCardProps> = ({ idDocument, selected, hidden }) => {
// Extract KYC fields from serialized applicant info with error handling
let country = '';
let idType = '';
let idNumber = '';
let fullName = '';
let dob = '';
let gender = '';
let expiryDate = '';
let issuanceDate = '';
try {
const applicantInfo = deserializeApplicantInfo(
@@ -64,12 +81,16 @@ const KycIdCard: FC<KycIdCardProps> = ({
country = applicantInfo.country || '';
idType = applicantInfo.idType || '';
idNumber = applicantInfo.idNumber || '';
fullName = applicantInfo.fullName || '';
dob = applicantInfo.dob || '';
gender = applicantInfo.gender || '';
expiryDate = applicantInfo.expiryDate || '';
issuanceDate = applicantInfo.issuanceDate || '';
} catch (error) {
console.error(
'[KycIdCard] Failed to deserialize applicant info, using fallback values:',
error,
);
// Fallback to safe defaults - component will render generic "ID CARD" display
}
const docTitle = getKycDocTitle(idType);
@@ -106,6 +127,164 @@ const KycIdCard: FC<KycIdCardProps> = ({
// Bottom label (e.g., "US DRIVERS LICENSE")
const bottomLabel = `${countryAdj} ${docTitle}`;
// Revealed data view
if (!hidden && selected) {
const { width: screenWidth } = Dimensions.get('window');
const revealedWidth = screenWidth * 0.95 - 16;
const revealedHeight = revealedWidth * 0.645;
const revealedBorderRadius = revealedWidth * 0.04;
const revealedPadding = revealedWidth * 0.035;
const revealedFontSize = {
large: revealedWidth * 0.045,
small: revealedWidth * 0.028,
};
const imageSize = {
width: revealedWidth * 0.2,
height: revealedWidth * 0.29,
};
const contentLeftOffset = imageSize.width + revealedPadding;
const countryName = getCountryDemonym(country);
return (
<YStack width="100%" alignItems="center" justifyContent="center">
<YStack
width={revealedWidth}
height={revealedHeight}
backgroundColor={white}
borderRadius={revealedBorderRadius}
borderWidth={0.75}
borderColor={separatorColor}
padding={revealedPadding}
shadowColor={black}
shadowOffset={{ width: 0, height: 2 }}
shadowOpacity={0.1}
shadowRadius={4}
elevation={4}
marginBottom={8}
justifyContent="center"
>
{/* Header */}
<XStack alignItems="center">
<RoundFlag
countryCode={country}
size={revealedFontSize.large * 2}
/>
<YStack marginLeft={revealedPadding}>
<Text
fontWeight="bold"
fontFamily={dinot}
fontSize={revealedFontSize.large * 1.4}
color="black"
>
{docTitle}
</Text>
<Text
fontSize={revealedFontSize.small}
color={slate400}
fontFamily={dinot}
>
Verified {countryName} {docTitle}
</Text>
</YStack>
</XStack>
<Separator
backgroundColor={separatorColor}
height={1}
width={revealedWidth - 1}
marginLeft={-revealedPadding}
marginTop={revealedPadding}
/>
{/* Attributes Grid */}
<XStack height="60%" paddingVertical={revealedPadding}>
<YStack
width={imageSize.width}
height={imageSize.height}
backgroundColor={slate100}
borderRadius={revealedBorderRadius * 0.5}
justifyContent="center"
alignItems="center"
marginRight={revealedPadding}
>
<LogoGray
width={imageSize.width * 0.5}
height={imageSize.height * 0.5}
/>
</YStack>
<YStack
flex={1}
justifyContent="space-between"
height={imageSize.height}
>
<XStack flex={1} gap={revealedPadding * 0.3}>
<YStack flex={1}>
<IdAttribute name="TYPE" value={docTitle} />
</YStack>
<YStack flex={1}>
<IdAttribute name="CODE" value="SELF ID" />
</YStack>
<YStack flex={1}>
<IdAttribute name="DOC NO." value={idNumber} />
</YStack>
</XStack>
<XStack flex={1} gap={revealedPadding * 0.3}>
<YStack flex={2}>
<IdAttribute name="NAME" value={fullName} />
</YStack>
<YStack flex={1}>
<IdAttribute name="SEX" value={gender} />
</YStack>
</XStack>
<XStack flex={1} gap={revealedPadding * 0.3}>
<YStack flex={1}>
<IdAttribute name="NATIONALITY" value={countryName} />
</YStack>
<YStack flex={1}>
<IdAttribute name="DOB" value={formatKycDate(dob)} />
</YStack>
<YStack flex={1}>
<IdAttribute
name="EXPIRY DATE"
value={formatKycDate(expiryDate)}
/>
</YStack>
</XStack>
<XStack flex={1} gap={revealedPadding * 0.3}>
<YStack flex={1}>
<IdAttribute
name="ISSUE DATE"
value={formatKycDate(issuanceDate)}
/>
</YStack>
<YStack flex={1} />
<YStack flex={1} />
</XStack>
</YStack>
</XStack>
{/* Footer */}
<XStack
alignItems="center"
backgroundColor={slate100}
borderRadius={revealedBorderRadius / 3}
paddingHorizontal={revealedPadding / 2}
paddingVertical={revealedPadding / 4}
minHeight={revealedFontSize.large * 1.5}
>
<XStack width={contentLeftOffset} alignItems="center">
<LogoGray
width={revealedFontSize.large}
height={revealedFontSize.large}
/>
</XStack>
</XStack>
</YStack>
</YStack>
);
}
return (
<YStack width="100%" alignItems="center" justifyContent="center">
<YStack

View File

@@ -2,36 +2,13 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback } from 'react';
import type { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native';
import { PixelRatio, Platform, requireNativeComponent } from 'react-native';
import { RCTFragment } from '@/components/native/RCTFragment';
interface NativeQRCodeScannerViewProps {
onQRData: (event: NativeSyntheticEvent<{ data: string }>) => void;
onError: (
event: NativeSyntheticEvent<{
error: string;
errorMessage: string;
stackTrace: string;
}>,
) => void;
style?: StyleProp<ViewStyle>;
}
const QRCodeNativeComponent = Platform.select({
ios: requireNativeComponent<NativeQRCodeScannerViewProps>(
'QRCodeScannerView',
),
android: requireNativeComponent<NativeQRCodeScannerViewProps>(
'QRCodeScannerViewManager',
),
});
if (!QRCodeNativeComponent) {
throw new Error('QRCodeScannerView not registered for this platform');
}
import {
type BarcodeScanningResult,
CameraView,
useCameraPermissions,
} from 'expo-camera';
import React, { useCallback, useEffect, useRef } from 'react';
import { StyleSheet, View } from 'react-native';
export interface QRCodeScannerViewProps {
isMounted: boolean;
@@ -42,70 +19,62 @@ export const QRCodeScannerView: React.FC<QRCodeScannerViewProps> = ({
onQRData,
isMounted,
}) => {
const _onError = useCallback(
(
event: NativeSyntheticEvent<{
error: string;
errorMessage: string;
stackTrace: string;
}>,
) => {
if (!isMounted) {
const [permission, requestPermission] = useCameraPermissions();
useEffect(() => {
if (permission && !permission.granted) {
if (permission.canAskAgain) {
requestPermission();
} else {
onQRData(new Error('Camera permission denied'));
}
}
}, [permission, requestPermission, onQRData]);
const hasScanned = useRef(false);
const handleBarcodeScanned = useCallback(
(result: BarcodeScanningResult) => {
if (!isMounted || hasScanned.current) {
return;
}
const {
error: nativeError,
errorMessage,
stackTrace,
} = event.nativeEvent;
const e = new Error(errorMessage);
e.name = nativeError;
e.stack = stackTrace;
onQRData(e);
hasScanned.current = true;
onQRData(null, result.data);
},
[onQRData, isMounted],
);
const _onQRData = useCallback(
(event: NativeSyntheticEvent<{ data: string }>) => {
if (!isMounted) {
return;
}
onQRData(null, event.nativeEvent.data);
const handleMountError = useCallback(
(event: { message: string }) => {
if (!isMounted) return;
onQRData(new Error(event.message));
},
[onQRData, isMounted],
[isMounted, onQRData],
);
if (Platform.OS === 'ios') {
return (
<QRCodeNativeComponent
onQRData={_onQRData}
onError={_onError}
style={{
width: '110%',
height: '110%',
}}
/>
);
} else {
// For Android, wrap the native component inside your RCTFragment to preserve existing functionality.
const Fragment = RCTFragment as React.FC<
React.ComponentProps<typeof RCTFragment> & NativeQRCodeScannerViewProps
>;
return (
<Fragment
RCTFragmentViewManager={
QRCodeNativeComponent as ReturnType<typeof requireNativeComponent>
}
fragmentComponentName="QRCodeScannerViewManager"
isMounted={isMounted}
style={{
height: PixelRatio.getPixelSizeForLayoutSize(800),
width: PixelRatio.getPixelSizeForLayoutSize(400),
}}
onError={_onError}
onQRData={_onQRData}
/>
);
if (!permission?.granted) {
return null;
}
return (
<View style={styles.container}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarcodeScanned}
onMountError={handleMountError}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
width: '110%',
height: '110%',
},
camera: {
flex: 1,
},
});

View File

@@ -56,6 +56,7 @@ const Points: React.FC = () => {
const incomingPoints = useIncomingPoints();
const { amount: points } = usePoints();
const loadEvents = usePointEventStore(state => state.loadEvents);
const events = usePointEventStore(state => state.events);
const { hasCompletedBackupForPoints, setBackupForPointsCompleted } =
useSettingStore();
const [isBackingUp, setIsBackingUp] = useState(false);
@@ -76,18 +77,15 @@ const Points: React.FC = () => {
navigation.navigate('PointsInfo');
};
//TODO - uncomment after merging - https://github.com/selfxyz/self/pull/1363/
// useEffect(() => {
// const backupEvent = usePointEventStore
// .getState()
// .events.find(
// event => event.type === 'backup' && event.status === 'completed',
// );
useEffect(() => {
const backupEvent = events.find(
event => event.type === 'backup' && event.status === 'completed',
);
// if (backupEvent && !hasCompletedBackupForPoints) {
// setBackupForPointsCompleted();
// }
// }, [setBackupForPointsCompleted, hasCompletedBackupForPoints]);
if (backupEvent && !hasCompletedBackupForPoints) {
setBackupForPointsCompleted();
}
}, [events, setBackupForPointsCompleted, hasCompletedBackupForPoints]);
// Track if we should check for backup completion on next focus
const shouldCheckBackupRef = React.useRef(false);

View File

@@ -8,6 +8,7 @@ import type { NativeStackHeaderProps } from '@react-navigation/native-stack';
import { Text, View } from '@selfxyz/mobile-sdk-alpha/components';
import { black, slate50 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { NavBar } from '@/components/navbar/BaseNavBar';
import { buttonTap } from '@/integrations/haptics';
@@ -39,7 +40,7 @@ export const PointsNavBar = (props: NativeStackHeaderProps) => {
color={black}
fontSize={15}
fontWeight="500"
fontFamily="DINOT-Medium"
fontFamily={dinot}
textAlign="center"
style={{
letterSpacing: 0.6,

View File

@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback, useState } from 'react';
import { Alert } from 'react-native';
import { Button, XStack, YStack } from 'tamagui';
import { BodyText } from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
slate200,
slate500,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { useSupportUuid } from '@/hooks/useSupportUuid';
interface SupportUuidRowProps {
collapsedByDefault?: boolean;
title?: string;
}
const SupportUuidRow: React.FC<SupportUuidRowProps> = ({
collapsedByDefault = true,
title = 'Diagnostic ID',
}) => {
const [expanded, setExpanded] = useState(!collapsedByDefault);
const { supportUuid, copy } = useSupportUuid();
const diagnosticIdText = supportUuid ?? 'Loading diagnostic ID...';
const handleCopy = useCallback(() => {
copy();
Alert.alert('Copied', 'Diagnostic ID copied to clipboard.');
}, [copy]);
const toggle = useCallback(() => setExpanded(prev => !prev), []);
if (!expanded) {
return (
<Button
unstyled
onPress={toggle}
borderWidth={1}
borderColor={slate200}
borderRadius={12}
paddingVertical={10}
paddingHorizontal={12}
>
<BodyText style={{ color: slate500 }}>Show diagnostic info</BodyText>
</Button>
);
}
return (
<YStack
borderWidth={1}
borderColor={slate200}
borderRadius={12}
padding={12}
gap={8}
backgroundColor={white}
>
<Button unstyled onPress={toggle} hitSlop={6}>
<BodyText style={{ color: black, fontSize: 14 }}>{title}</BodyText>
</Button>
<BodyText style={{ color: slate500, fontSize: 13 }}>
{diagnosticIdText}
</BodyText>
<XStack justifyContent="flex-end" alignItems="center">
<Button unstyled onPress={handleCopy}>
<BodyText style={{ color: black }}>Copy</BodyText>
</Button>
</XStack>
</YStack>
);
};
export default SupportUuidRow;

View File

@@ -30,6 +30,7 @@ export {
logEvent,
logNFCEvent,
logProofEvent,
setSupportUuidInSentry,
wrapWithSentry,
} from '@/config/sentry';

View File

@@ -14,6 +14,7 @@ import {
feedbackIntegration,
init as sentryInit,
mobileReplayIntegration,
setTag,
withScope,
wrap,
} from '@sentry/react-native';
@@ -227,9 +228,6 @@ export const isIosSimulator = () =>
export const isSentryDisabled = !SENTRY_DSN;
type LogLevel = 'info' | 'warn' | 'error';
type LogCategory = 'proof' | 'nfc';
export const logEvent = (
level: LogLevel,
category: LogCategory,
@@ -285,6 +283,9 @@ export const logEvent = (
}
};
type LogLevel = 'info' | 'warn' | 'error';
type LogCategory = 'proof' | 'nfc';
export const logNFCEvent = (
level: LogLevel,
message: string,
@@ -299,6 +300,14 @@ export const logProofEvent = (
extra?: Record<string, unknown>,
) => logEvent(level, 'proof', message, context, extra);
export const setSupportUuidInSentry = (supportUuid: string | null) => {
if (isSentryDisabled) {
return;
}
setTag('support_uuid', supportUuid ?? 'unset');
};
export const wrapWithSentry = (App: React.ComponentType) => {
return isSentryDisabled ? App : wrap(App);
};

View File

@@ -195,9 +195,6 @@ export const initSentry = () => {
export const isSentryDisabled = !SENTRY_DSN;
type LogLevel = 'info' | 'warn' | 'error';
type LogCategory = 'proof' | 'nfc';
export const logEvent = (
level: LogLevel,
category: LogCategory,
@@ -253,6 +250,9 @@ export const logEvent = (
}
};
type LogLevel = 'info' | 'warn' | 'error';
type LogCategory = 'proof' | 'nfc';
export const logNFCEvent = (
level: LogLevel,
message: string,
@@ -267,6 +267,8 @@ export const logProofEvent = (
extra?: Record<string, unknown>,
) => logEvent(level, 'proof', message, context, extra);
export const setSupportUuidInSentry = (_supportUuid: string | null) => {};
export const wrapWithSentry = (App: React.ComponentType) => {
return isSentryDisabled ? App : withProfiler(App);
};

View File

@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback, useState } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import { usePassport } from '@/providers/passportDataProvider';
/**
* Returns whether the user has at least one real (non-mock) document in their
* catalog. `null` means the catalog has not loaded yet — callers gating UI
* should treat `null` as "unknown" and typically hide the gated affordance
* until the value resolves. Refreshes on screen focus.
*/
const useHasRealDocument = (
logScope?: string,
): { hasRealDocument: boolean | null; refresh: () => Promise<void> } => {
const { loadDocumentCatalog } = usePassport();
const [hasRealDocument, setHasRealDocument] = useState<boolean | null>(null);
const refresh = useCallback(async () => {
try {
const catalog = await loadDocumentCatalog();
if (!catalog?.documents || !Array.isArray(catalog.documents)) {
if (logScope) {
console.warn(`${logScope}: invalid catalog structure`);
}
setHasRealDocument(false);
return;
}
setHasRealDocument(catalog.documents.some(doc => !doc.mock));
} catch {
if (logScope) {
console.warn(`${logScope}: failed to load document catalog`);
}
setHasRealDocument(false);
}
}, [loadDocumentCatalog, logScope]);
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
);
return { hasRealDocument, refresh };
};
export default useHasRealDocument;

View File

@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useEffect } from 'react';
import {
copySupportUuid,
getSupportUuid,
regenerateSupportUuid,
} from '@/services/supportUuid';
import { useSettingStore } from '@/stores/settingStore';
export interface UseSupportUuidResult {
supportUuid: string | null;
isReady: boolean;
copy: () => string;
regenerate: () => string;
}
export function useSupportUuid(): UseSupportUuidResult {
const supportUuid = useSettingStore(state => state.supportUuid);
useEffect(() => {
if (!supportUuid) getSupportUuid();
}, [supportUuid]);
return {
supportUuid,
isReady: supportUuid != null,
copy: copySupportUuid,
regenerate: regenerateSupportUuid,
};
}

View File

@@ -41,6 +41,8 @@ type AndroidPassportReaderModule = {
trackEvent?: (name: string, properties?: Record<string, unknown>) => void;
flush?: () => void | Promise<void>;
reset?: () => void;
resetIdentity?: () => void;
setDistinctId?: (distinctId: string) => void;
scan?: (options: ScanOptions) => Promise<AndroidScanResponse>;
};
@@ -48,6 +50,8 @@ type IOSPassportReaderModule = {
configure?: (token: string, enableDebug?: boolean) => void;
trackEvent?: (name: string, properties?: Record<string, unknown>) => void;
flush?: () => void | Promise<void>;
resetIdentity?: () => void;
setDistinctId?: (distinctId: string) => void;
scanPassport?: (
passportNumber: string,
dateOfBirth: string,

View File

@@ -17,8 +17,10 @@ import DocumentDataNotFoundScreen from '@/screens/account/recovery/DocumentDataN
import RecoverWithPhraseScreen from '@/screens/account/recovery/RecoverWithPhraseScreen';
import CloudBackupScreen from '@/screens/account/settings/CloudBackupScreen';
import { ProofSettingsScreen } from '@/screens/account/settings/ProofSettingsScreen';
import SecurityAndBackupScreen from '@/screens/account/settings/SecurityAndBackupScreen';
import SettingsScreen from '@/screens/account/settings/SettingsScreen';
import ShowRecoveryPhraseScreen from '@/screens/account/settings/ShowRecoveryPhraseScreen';
import SupportScreen from '@/screens/account/settings/SupportScreen';
import { IS_EUCLID_ENABLED } from '@/utils/devUtils';
const accountScreens = {
@@ -80,6 +82,32 @@ const accountScreens = {
},
} as NativeStackNavigationOptions,
},
Support: {
screen: SupportScreen,
options: {
title: 'Get support',
headerTintColor: black,
headerStyle: {
backgroundColor: white,
},
headerTitleStyle: {
color: black,
},
} as NativeStackNavigationOptions,
},
SecurityAndBackup: {
screen: SecurityAndBackupScreen,
options: {
title: 'Security & backup',
headerTintColor: black,
headerStyle: {
backgroundColor: white,
},
headerTitleStyle: {
color: black,
},
} as NativeStackNavigationOptions,
},
Settings: {
screen: SettingsScreen,
options: {

View File

@@ -7,8 +7,24 @@ import type { NativeStackNavigationOptions } from '@react-navigation/native-stac
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import SettingsScreen from '@/screens/account/settings/SettingsScreen';
import SupportScreen from '@/screens/account/settings/SupportScreen';
const accountScreens = {
Support: {
screen: SupportScreen,
options: {
title: 'Get support',
headerStyle: {
backgroundColor: white,
},
headerTitleStyle: {
color: black,
},
} as NativeStackNavigationOptions,
config: {
screens: {},
},
},
Settings: {
screen: SettingsScreen,
options: {

View File

@@ -6,6 +6,7 @@ import React from 'react';
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import type { DocumentCategory } from '@selfxyz/common/utils/types';
import { black } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { SystemBars } from '@/components/SystemBars';
import DeferredLinkingInfoScreen from '@/screens/app/DeferredLinkingInfoScreen';
@@ -54,7 +55,7 @@ const appScreens = {
screen: GratificationScreen,
options: {
headerShown: false,
contentStyle: { backgroundColor: '#000000' },
contentStyle: { backgroundColor: black },
} as NativeStackNavigationOptions,
params: {} as {
points?: number;

View File

@@ -44,6 +44,8 @@ export type AccountRoutesParamList = {
}
| undefined;
ProofSettings: undefined;
Support: undefined;
SecurityAndBackup: undefined;
AccountVerifiedSuccess: undefined;
};

View File

@@ -17,6 +17,7 @@ import Keychain from 'react-native-keychain';
import { AuthEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import { captureException } from '@/config/sentry';
import type { GetSecureOptions } from '@/integrations/keychain';
import {
createKeychainOptions,
@@ -201,6 +202,14 @@ async function loadOrCreateMnemonic(
code: err?.code,
name: err?.name,
});
if (error instanceof Error) {
captureException(error, {
module: 'auth-provider',
source: 'loadOrCreateMnemonic',
errorCode: err?.code,
errorName: err?.name,
});
}
trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, {
reason: 'keychain_crypto_failed',
errorCode: err?.code,
@@ -214,6 +223,12 @@ async function loadOrCreateMnemonic(
}
console.error('Error loading mnemonic:', error);
if (error instanceof Error) {
captureException(error, {
module: 'auth-provider',
source: 'loadOrCreateMnemonic',
});
}
trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, {
reason: 'unknown_error',
error: error instanceof Error ? error.message : String(error),
@@ -466,6 +481,12 @@ export async function migrateToSecureKeychain(): Promise<boolean> {
return true;
} catch (error: unknown) {
console.error('Error during keychain migration:', error);
if (error instanceof Error) {
captureException(error, {
module: 'auth-provider',
source: 'keychain-migration',
});
}
const message = error instanceof Error ? error.message : String(error);
trackEvent(AuthEvents.MNEMONIC_RESTORE_FAILED, {
reason: 'migration_failed',

View File

@@ -66,6 +66,7 @@ import { isKycDocument, isMRZDocument } from '@selfxyz/common/utils/types';
import type { DocumentsAdapter, SelfClient } from '@selfxyz/mobile-sdk-alpha';
import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { captureException } from '@/config/sentry';
import { createKeychainOptions } from '@/integrations/keychain';
import { unsafe_getPrivateKey, useAuth } from '@/providers/authProvider';
import type { KeychainErrorType } from '@/utils/keychainErrors';
@@ -100,8 +101,12 @@ function handleKeychainReadError({
error: unknown;
throwOnUserCancel?: boolean;
}) {
const safeLabel = contextLabel.startsWith('document ')
? 'document'
: contextLabel;
if (isUserCancellation(error)) {
console.log(`User cancelled authentication for ${contextLabel}`);
console.log(`User cancelled authentication for ${safeLabel}`);
notifyKeychainFailure('user_cancelled');
if (throwOnUserCancel) {
@@ -111,15 +116,23 @@ function handleKeychainReadError({
if (isKeychainCryptoError(error)) {
const err = getKeychainErrorIdentity(error);
console.error(`Keychain crypto error loading ${contextLabel}:`, {
console.error(`Keychain crypto error loading ${safeLabel}:`, {
code: err?.code,
name: err?.name,
});
if (error instanceof Error) {
captureException(error, {
module: 'passport-data-provider',
contextLabel: safeLabel,
errorCode: err?.code,
errorName: err?.name,
});
}
notifyKeychainFailure('crypto_failed');
}
console.log(`Error loading ${contextLabel}:`, error);
console.log(`Error loading ${safeLabel}:`, error);
}
// Create safe wrapper functions to prevent undefined errors during early initialization

View File

@@ -5,6 +5,7 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { initRemoteConfig } from '@/config/remoteConfig';
import { captureException } from '@/config/sentry';
interface RemoteConfigContextValue {
isInitialized: boolean;
@@ -29,6 +30,9 @@ export const RemoteConfigProvider: React.FC<{ children: React.ReactNode }> = ({
setIsInitialized(true);
} catch (err) {
console.error('Failed to initialize remote config:', err);
if (err instanceof Error) {
captureException(err, { module: 'remote-config-provider' });
}
setError(err instanceof Error ? err.message : 'Unknown error');
// Still set as initialized to not block the app
setIsInitialized(true);

View File

@@ -19,6 +19,7 @@ import {
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import SupportUuidRow from '@/components/support/SupportUuidRow';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import { flush as flushAnalytics } from '@/services/analytics';
@@ -64,6 +65,7 @@ const DocumentDataNotFoundScreen: React.FC = () => {
height={150}
backgroundColor={white}
>
<SupportUuidRow />
<PrimaryButton onPress={onPress}>Continue</PrimaryButton>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>

View File

@@ -4,7 +4,7 @@
import { ethers } from 'ethers';
import React, { useCallback, useState } from 'react';
import { Keyboard, StyleSheet } from 'react-native';
import { Keyboard, Pressable, StyleSheet } from 'react-native';
import { Text, TextArea, View, XStack, YStack } from 'tamagui';
import Clipboard from '@react-native-clipboard/clipboard';
import { useNavigation } from '@react-navigation/native';
@@ -22,6 +22,7 @@ import {
import { BackupEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import {
black,
red500,
slate300,
slate400,
slate600,
@@ -38,6 +39,22 @@ import {
} from '@/providers/passportDataProvider';
import { recoveryCopy } from '@/screens/account/recovery/recoveryCopy';
type RecoveryError =
| 'invalid_mnemonic'
| 'restore_failed'
| 'not_registered'
| 'unexpected_error';
const ERROR_MESSAGES: Record<RecoveryError, string> = {
invalid_mnemonic:
'That doesnt look like a valid recovery phrase. Make sure all 24 words are correct and in the right order.',
restore_failed:
'We couldnt restore your account with this phrase. Please double-check and try again.',
not_registered:
'This recovery phrase doesnt match a registered ID. If you registered with a different phrase, try that one instead.',
unexpected_error: 'Something went wrong. Please try again.',
};
const RecoverWithPhraseScreen: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
@@ -47,20 +64,31 @@ const RecoverWithPhraseScreen: React.FC = () => {
const { trackEvent } = useSelfClient();
const [mnemonic, setMnemonic] = useState<string>();
const [restoring, setRestoring] = useState(false);
const [error, setError] = useState<RecoveryError | null>(null);
const onPaste = useCallback(async () => {
const clipboard = (await Clipboard.getString()).trim();
// bugfix: perform a simple clipboard check; ethers.Mnemonic.isValidMnemonic doesn't work
if (clipboard) {
setMnemonic(clipboard);
setError(null);
Keyboard.dismiss();
}
}, []);
const onChangeText = useCallback((text: string) => {
setMnemonic(text);
setError(null);
}, []);
const restoreAccount = useCallback(async () => {
Keyboard.dismiss();
setError(null);
try {
setRestoring(true);
const slimMnemonic = mnemonic?.trim();
if (!slimMnemonic || !ethers.Mnemonic.isValidMnemonic(slimMnemonic)) {
setError('invalid_mnemonic');
setRestoring(false);
return;
}
@@ -71,6 +99,7 @@ const RecoverWithPhraseScreen: React.FC = () => {
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_AUTH, {
mnemonicLength: slimMnemonic.split(' ').length,
});
setError('restore_failed');
setRestoring(false);
return;
}
@@ -121,6 +150,7 @@ const RecoverWithPhraseScreen: React.FC = () => {
reason: 'document_not_registered',
hasCSCA: !!csca,
});
setError('not_registered');
setRestoring(false);
return;
}
@@ -133,11 +163,12 @@ const RecoverWithPhraseScreen: React.FC = () => {
setRestoring(false);
trackEvent(BackupEvents.ACCOUNT_RECOVERY_COMPLETED);
navigation.navigate('AccountVerifiedSuccess');
} catch (error) {
} catch (restoreError) {
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN, {
reason: 'unexpected_error',
error: error instanceof Error ? error.message : 'unknown',
error: restoreError instanceof Error ? restoreError.name : 'unknown',
});
setError('unexpected_error');
setRestoring(false);
}
}, [
@@ -161,7 +192,7 @@ const RecoverWithPhraseScreen: React.FC = () => {
</Description>
<View width="100%" position="relative">
<TextArea
borderColor={slate600}
borderColor={error ? red500 : slate600}
backgroundColor={slate700}
color={slate400}
borderWidth="$1"
@@ -174,23 +205,22 @@ const RecoverWithPhraseScreen: React.FC = () => {
onKeyPress={key =>
key.nativeEvent.key === 'Enter' && mnemonic && Keyboard.dismiss()
}
onChangeText={setMnemonic}
onChangeText={onChangeText}
/>
<XStack
gap="$2"
position="absolute"
bottom={0}
width="100%"
alignItems="flex-end"
justifyContent="center"
paddingBottom="$4"
<Pressable
onPress={onPaste}
style={styles.pasteButton}
hitSlop={{ top: 10, bottom: 10, left: 20, right: 20 }}
>
<Paste color={white} height={20} width={20} />
<Text style={styles.pasteText}>{recoveryCopy.phrase.paste}</Text>
</XStack>
<XStack gap="$2" alignItems="center" justifyContent="center">
<Paste color={white} height={20} width={20} />
<Text style={styles.pasteText}>{recoveryCopy.phrase.paste}</Text>
</XStack>
</Pressable>
</View>
{error && <Text style={styles.errorText}>{ERROR_MESSAGES[error]}</Text>}
<SecondaryButton
disabled={!mnemonic || restoring}
onPress={restoreAccount}
@@ -211,9 +241,22 @@ const styles = StyleSheet.create({
backgroundColor: black,
height: '100%',
},
pasteButton: {
position: 'absolute',
bottom: 0,
width: '100%',
alignItems: 'center',
paddingBottom: 16,
},
pasteText: {
lineHeight: 20,
fontSize: 15,
color: white,
},
errorText: {
color: red500,
fontSize: 14,
textAlign: 'center',
paddingHorizontal: 20,
},
});

View File

@@ -0,0 +1,136 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { PropsWithChildren } from 'react';
import React, { useCallback } from 'react';
import { Platform, View as RNView } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { SvgProps } from 'react-native-svg';
import { Button, ScrollView, View, YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BodyText, pressedStyle } from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
neutral700,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import Cloud from '@/assets/icons/settings_cloud_backup.svg';
import Lock from '@/assets/icons/settings_lock.svg';
import useHasRealDocument from '@/hooks/useHasRealDocument';
import { impactLight } from '@/integrations/haptics';
import { extraYPadding } from '@/utils/styleUtils';
type MinimalRootStackParamList = Record<string, object | undefined>;
interface MenuButtonProps extends PropsWithChildren {
Icon: React.FC<SvgProps>;
onPress: () => void;
}
const MenuSkeletonRow: React.FC = () => (
<YStack
width="100%"
paddingVertical={20}
paddingHorizontal={10}
borderBottomColor={neutral700}
borderBottomWidth={1}
opacity={0.5}
>
<View flexDirection="row" gap={6} alignItems="center">
<View width={21} height={24} borderRadius={6} backgroundColor={white} />
<View width={170} height={18} borderRadius={6} backgroundColor={white} />
</View>
</YStack>
);
const MenuButton: React.FC<MenuButtonProps> = ({ children, Icon, onPress }) => (
<Button
unstyled
onPress={onPress}
pressStyle={pressedStyle}
width="100%"
flexDirection="row"
gap={6}
paddingVertical={20}
paddingHorizontal={10}
borderBottomColor={neutral700}
borderBottomWidth={1}
hitSlop={4}
>
<Icon height={24} width={21} color={white} />
<BodyText style={{ color: white, fontSize: 18, lineHeight: 23 }}>
{children}
</BodyText>
</Button>
);
const SecurityAndBackupScreen: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<MinimalRootStackParamList>>();
const { bottom } = useSafeAreaInsets();
const { hasRealDocument } = useHasRealDocument();
const go = useCallback(
(route: string) => () => {
impactLight();
navigation.navigate(route as never);
},
[navigation],
);
// Matches prior Settings gating: iOS always shows Cloud backup; Android
// shows it only when a real (non-mock) document is present. Recovery phrase
// is shown on both platforms only when a real document is present.
const isAndroidLoading =
Platform.OS === 'android' && hasRealDocument === null;
const showCloudBackup = Platform.OS !== 'android' || hasRealDocument === true;
const showRecoveryPhrase = hasRealDocument === true;
return (
<RNView collapsable={false}>
<View backgroundColor={white}>
<YStack
backgroundColor={black}
height="100%"
paddingHorizontal={20}
paddingBottom={bottom + extraYPadding}
borderTopLeftRadius={30}
borderTopRightRadius={30}
>
<ScrollView>
<YStack alignItems="flex-start" width="100%">
{isAndroidLoading ? (
<>
<MenuSkeletonRow />
<MenuSkeletonRow />
</>
) : (
<>
{showCloudBackup && (
<MenuButton
Icon={Cloud}
onPress={go('CloudBackupSettings')}
>
Cloud backup
</MenuButton>
)}
{showRecoveryPhrase && (
<MenuButton Icon={Lock} onPress={go('ShowRecoveryPhrase')}>
Reveal recovery phrase
</MenuButton>
)}
</>
)}
</YStack>
</ScrollView>
</YStack>
</View>
</RNView>
);
};
export default SecurityAndBackupScreen;

View File

@@ -3,13 +3,13 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { PropsWithChildren } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import { Linking, Platform, Share, View as RNView } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { SvgProps } from 'react-native-svg';
import { Button, ScrollView, View, XStack, YStack } from 'tamagui';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Bug, FileText, Settings2 } from '@tamagui/lucide-icons';
@@ -24,8 +24,6 @@ import {
import Discord from '@/assets/icons/discord.svg';
import Github from '@/assets/icons/github.svg';
import Cloud from '@/assets/icons/settings_cloud_backup.svg';
import Data from '@/assets/icons/settings_data.svg';
import Feedback from '@/assets/icons/settings_feedback.svg';
import Lock from '@/assets/icons/settings_lock.svg';
import ShareIcon from '@/assets/icons/share.svg';
@@ -42,9 +40,13 @@ import {
telegramUrl,
xUrl,
} from '@/consts/links';
import useOpenSupportForm from '@/hooks/useOpenSupportForm';
import useHasRealDocument from '@/hooks/useHasRealDocument';
import { impactLight } from '@/integrations/haptics';
import { usePassport } from '@/providers/passportDataProvider';
import type {
SettingsPlatform,
SettingsRouteKey,
} from '@/screens/account/settings/settingsMenu';
import { buildSettingsMenu } from '@/screens/account/settings/settingsMenu';
import { useSettingStore } from '@/stores/settingStore';
import { extraYPadding } from '@/utils/styleUtils';
@@ -61,9 +63,6 @@ interface SocialButtonProps {
onPress?: () => void;
}
// Avoid importing RootStackParamList; we only need string route names plus a few literals
type RouteOption = string | 'share' | 'support_form' | 'ManageDocuments';
const storeURL = Platform.OS === 'ios' ? appStoreUrl : playStoreUrl;
const goToStore = () => {
@@ -71,45 +70,17 @@ const goToStore = () => {
Linking.openURL(storeURL);
};
const routes =
Platform.OS !== 'web'
? ([
[Data, 'View document info', 'DocumentDataInfo'],
[Lock, 'Reveal recovery phrase', 'ShowRecoveryPhrase'],
[Cloud, 'Cloud backup', 'CloudBackupSettings'],
[Settings2 as React.FC<SvgProps>, 'Proof settings', 'ProofSettings'],
[Feedback, 'Get support', 'support_form'],
[ShareIcon, 'Share Self app', 'share'],
[
FileText as React.FC<SvgProps>,
'Manage ID documents',
'ManageDocuments',
],
] satisfies [React.FC<SvgProps>, string, RouteOption][])
: ([
[Data, 'View document info', 'DocumentDataInfo'],
[Settings2 as React.FC<SvgProps>, 'Proof settings', 'ProofSettings'],
[Feedback, 'Get support', 'support_form'],
[
FileText as React.FC<SvgProps>,
'Manage ID documents',
'ManageDocuments',
],
] satisfies [React.FC<SvgProps>, string, RouteOption][]);
const CURRENT_PLATFORM: SettingsPlatform =
Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'web';
// get the actual type of the routes so we can use in the onMenuPress function so it
// doesnt worry about us linking to screens with required props which we dont want to go to anyway
type RouteLinks = (typeof routes)[number][2] | (typeof DEBUG_MENU)[number][2];
const DEBUG_MENU: [React.FC<SvgProps>, string, RouteOption][] = [
[Bug as React.FC<SvgProps>, 'Debug menu', 'DevSettings'],
];
const DOCUMENT_DEPENDENT_ROUTES: RouteOption[] = [
'DocumentDataInfo',
'ShowRecoveryPhrase',
];
const CLOUD_BACKUP_ROUTE: RouteOption = 'CloudBackupSettings';
const ROUTE_ICONS: Record<SettingsRouteKey, React.FC<SvgProps>> = {
ManageDocuments: FileText as React.FC<SvgProps>,
SecurityAndBackup: Lock,
ProofSettings: Settings2 as React.FC<SvgProps>,
Support: Feedback,
share: ShareIcon,
DevSettings: Bug as React.FC<SvgProps>,
};
const social = [
[X, xUrl],
@@ -162,55 +133,24 @@ const SocialButton: React.FC<SocialButtonProps> = ({
const SettingsScreen: React.FC = () => {
const { isDevMode, setDevModeOn } = useSettingStore();
const openSupportForm = useOpenSupportForm();
const navigation =
useNavigation<NativeStackNavigationProp<MinimalRootStackParamList>>();
const { loadDocumentCatalog } = usePassport();
const { hasRealDocument } = useHasRealDocument('SettingsScreen');
const openSelfWebsite = useCallback(() => {
impactLight();
navigation.navigate('WebView', { url: selfUrl, title: 'Self' });
}, [navigation]);
const [hasRealDocument, setHasRealDocument] = useState<boolean | null>(null);
const refreshDocumentAvailability = useCallback(async () => {
try {
const catalog = await loadDocumentCatalog();
if (!catalog?.documents || !Array.isArray(catalog.documents)) {
console.warn('SettingsScreen: invalid catalog structure');
setHasRealDocument(false);
return;
}
setHasRealDocument(catalog.documents.some(doc => !doc.mock));
} catch {
console.warn('SettingsScreen: failed to load document catalog');
setHasRealDocument(false);
}
}, [loadDocumentCatalog]);
useFocusEffect(
useCallback(() => {
refreshDocumentAvailability();
}, [refreshDocumentAvailability]),
const screenRoutes = useMemo(
() =>
buildSettingsMenu({
platform: CURRENT_PLATFORM,
hasRealDocument: hasRealDocument === true,
isDevMode,
}),
[hasRealDocument, isDevMode],
);
const screenRoutes = useMemo(() => {
const baseRoutes = isDevMode ? [...routes, ...DEBUG_MENU] : routes;
const shouldHideCloudBackup = Platform.OS === 'android';
const hasConfirmedRealDocument = hasRealDocument === true;
return baseRoutes.filter(([, , route]) => {
if (DOCUMENT_DEPENDENT_ROUTES.includes(route)) {
return hasConfirmedRealDocument;
}
if (shouldHideCloudBackup && route === CLOUD_BACKUP_ROUTE) {
return hasConfirmedRealDocument;
}
return true;
});
}, [hasRealDocument, isDevMode]);
const devModeTap = Gesture.Tap()
.numberOfTaps(5)
.onStart(() => {
@@ -218,7 +158,7 @@ const SettingsScreen: React.FC = () => {
});
const onMenuPress = useCallback(
(menuRoute: RouteLinks) => {
(menuRoute: SettingsRouteKey) => {
return async () => {
impactLight();
switch (menuRoute) {
@@ -230,21 +170,13 @@ const SettingsScreen: React.FC = () => {
);
break;
case 'support_form':
openSupportForm();
break;
case 'ManageDocuments':
navigation.navigate('ManageDocuments');
break;
default:
navigation.navigate(menuRoute as never);
break;
}
};
},
[navigation, openSupportForm],
[navigation],
);
const { bottom } = useSafeAreaInsets();
return (
@@ -267,15 +199,13 @@ const SettingsScreen: React.FC = () => {
justifyContent="flex-start"
width="100%"
>
{screenRoutes.map(([Icon, menuText, menuRoute], idx) => (
{screenRoutes.map(({ label, route }) => (
<MenuButton
key={
typeof menuRoute === 'string' ? menuRoute : String(idx)
}
Icon={Icon}
onPress={onMenuPress(menuRoute)}
key={route}
Icon={ROUTE_ICONS[route]}
onPress={onMenuPress(route)}
>
{menuText}
{label}
</MenuButton>
))}
</YStack>

View File

@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback } from 'react';
import { Alert } from 'react-native';
import { Button, ScrollView, YStack } from 'tamagui';
import { BodyText } from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
slate100,
slate200,
slate500,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import useOpenSupportForm from '@/hooks/useOpenSupportForm';
import { useSupportUuid } from '@/hooks/useSupportUuid';
const SupportScreen: React.FC = () => {
const { supportUuid, copy, regenerate } = useSupportUuid();
const openSupportForm = useOpenSupportForm();
const diagnosticIdText = supportUuid ?? 'Loading diagnostic ID...';
const handleCopy = useCallback(() => {
copy();
Alert.alert('Copied', 'Diagnostic ID copied to clipboard.');
}, [copy]);
const handleRegenerate = useCallback(() => {
Alert.alert(
'Regenerate diagnostic ID?',
'This will immediately replace the current ID for future support diagnostics.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Regenerate',
style: 'destructive',
onPress: () => {
regenerate();
Alert.alert('Updated', 'Diagnostic ID regenerated successfully.');
},
},
],
);
}, [regenerate]);
return (
<ScrollView flex={1} backgroundColor={slate100}>
<YStack padding={20} gap={20}>
<Button
backgroundColor={black}
borderRadius={12}
onPress={openSupportForm}
>
<BodyText style={{ color: white }}>Send feedback</BodyText>
</Button>
<YStack gap={8}>
<BodyText style={{ color: slate500, fontSize: 13 }}>
Share the diagnostic ID below when contacting support so we can
locate your logs.
</BodyText>
<YStack
borderWidth={1}
borderColor={slate200}
borderRadius={12}
backgroundColor={white}
padding={16}
gap={8}
>
<BodyText style={{ color: black, fontSize: 16 }}>
Diagnostic ID
</BodyText>
<BodyText style={{ color: slate500, fontSize: 14 }}>
{diagnosticIdText}
</BodyText>
</YStack>
<Button
backgroundColor={white}
borderColor={slate200}
borderWidth={1}
borderRadius={12}
onPress={handleCopy}
>
<BodyText style={{ color: black }}>Copy diagnostic ID</BodyText>
</Button>
<Button
backgroundColor={white}
borderColor={slate200}
borderWidth={1}
borderRadius={12}
onPress={handleRegenerate}
>
<BodyText style={{ color: black }}>Regenerate</BodyText>
</Button>
</YStack>
</YStack>
</ScrollView>
);
};
export default SupportScreen;

View File

@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export type SettingsEntry = {
label: string;
route: SettingsRouteKey;
};
export type SettingsGatingContext = {
platform: SettingsPlatform;
hasRealDocument: boolean;
isDevMode: boolean;
};
export type SettingsPlatform = 'ios' | 'android' | 'web';
export type SettingsRouteKey =
| 'ManageDocuments'
| 'SecurityAndBackup'
| 'ProofSettings'
| 'Support'
| 'share'
| 'DevSettings';
export const DEBUG_SETTINGS_ENTRY: SettingsEntry = {
label: 'Debug menu',
route: 'DevSettings',
};
export const SETTINGS_ENTRIES_NATIVE: readonly SettingsEntry[] = [
{ label: 'Manage ID documents', route: 'ManageDocuments' },
{ label: 'Security & backup', route: 'SecurityAndBackup' },
{ label: 'Proof settings', route: 'ProofSettings' },
{ label: 'Get support', route: 'Support' },
{ label: 'Share Self app', route: 'share' },
];
export const SETTINGS_ENTRIES_WEB: readonly SettingsEntry[] = [
{ label: 'Manage ID documents', route: 'ManageDocuments' },
{ label: 'Proof settings', route: 'ProofSettings' },
{ label: 'Get support', route: 'Support' },
];
export const baseEntriesForPlatform = (
platform: SettingsPlatform,
): readonly SettingsEntry[] =>
platform === 'web' ? SETTINGS_ENTRIES_WEB : SETTINGS_ENTRIES_NATIVE;
// Security & backup houses Cloud backup (iOS: always available; Android:
// requires a real doc) and Reveal recovery phrase (requires a real doc). The
// parent entry is therefore hidden only when no child would be reachable —
// i.e. on Android without a real document. iOS keeps it visible so users can
// still reach Cloud backup.
export const buildSettingsMenu = (
context: SettingsGatingContext,
): SettingsEntry[] => {
const { platform, isDevMode } = context;
const base = baseEntriesForPlatform(platform);
const withDebug = isDevMode ? [...base, DEBUG_SETTINGS_ENTRY] : base;
return withDebug.filter(entry => shouldShowSettingsEntry(entry, context));
};
export const shouldShowSettingsEntry = (
entry: SettingsEntry,
{ platform, hasRealDocument }: Omit<SettingsGatingContext, 'isDevMode'>,
): boolean => {
if (entry.route === 'SecurityAndBackup') {
return platform !== 'android' || hasRealDocument;
}
return true;
};

View File

@@ -50,7 +50,7 @@ const GratificationScreen: React.FC = () => {
};
const handleBackPress = () => {
navigation.navigate('Points');
navigation.goBack();
};
const handleAnimationFinish = useCallback(() => {

View File

@@ -38,6 +38,7 @@ import {
getStartupNavigationTarget,
hasStartupRecoverySignal,
} from '@/screens/app/startupRouting';
import { initializeSupportUuidContext } from '@/services/supportUuid';
import {
useSettingStore,
waitForSettingStoreHydration,
@@ -90,6 +91,14 @@ const SplashScreen: React.FC = ({}) => {
`SplashScreen: migrateFromLegacyStorage complete (${elapsed()})`,
);
await waitForSettingStoreHydration();
try {
initializeSupportUuidContext();
} catch (error) {
console.warn(
'SplashScreen: failed to initialize support UUID context',
error,
);
}
const needsMigration = await checkIfAnyDocumentsNeedMigration();
console.log(

View File

@@ -27,6 +27,7 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import WarningIcon from '@/assets/images/warning.svg';
import { NavBar } from '@/components/navbar/BaseNavBar';
import SupportUuidRow from '@/components/support/SupportUuidRow';
import { useKycLauncher } from '@/hooks/useKycLauncher';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
@@ -243,6 +244,7 @@ const AadhaarUploadErrorScreen: React.FC = () => {
Registering with alternative methods may take longer to verify your
document.
</BodyText>
<SupportUuidRow />
</YStack>
</YStack>
);

View File

@@ -28,6 +28,7 @@ import {
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import useHasRealDocument from '@/hooks/useHasRealDocument';
import { impactLight } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider';
@@ -400,6 +401,7 @@ const ManageDocumentsScreen: React.FC = () => {
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { bottom } = useSafeAreaInsets();
const { trackEvent } = useSelfClient();
const { hasRealDocument } = useHasRealDocument();
useEffect(() => {
trackEvent(DocumentEvents.MANAGE_SCREEN_OPENED);
@@ -417,6 +419,11 @@ const ManageDocumentsScreen: React.FC = () => {
navigation.navigate('CreateMock');
};
const handleViewInfo = () => {
impactLight();
navigation.navigate('DocumentDataInfo');
};
return (
<YStack
flex={1}
@@ -434,6 +441,11 @@ const ManageDocumentsScreen: React.FC = () => {
<PrimaryButton onPress={handleAddDocument}>
Add New Document
</PrimaryButton>
{hasRealDocument === true && (
<SecondaryButton onPress={handleViewInfo}>
View Document Info
</SecondaryButton>
)}
<SecondaryButton onPress={handleGenerateMock}>
Generate Mock Document
</SecondaryButton>

View File

@@ -14,6 +14,7 @@ import PassportCameraBulb from '@/assets/icons/passport_camera_bulb.svg';
import PassportCameraScan from '@/assets/icons/passport_camera_scan.svg';
import QrScan from '@/assets/icons/qr_scan.svg';
import Star from '@/assets/icons/star.svg';
import SupportUuidRow from '@/components/support/SupportUuidRow';
import type { TipProps } from '@/components/Tips';
import Tips from '@/components/Tips';
import useHapticNavigation from '@/hooks/useHapticNavigation';
@@ -87,6 +88,8 @@ const DocumentCameraTroubleScreen: React.FC = () => {
Or try an alternative verification method:
</Caption>
<SupportUuidRow />
<SecondaryButton
onPress={launchKycVerification}
disabled={isLoading}

View File

@@ -12,6 +12,7 @@ import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { Caption, SecondaryButton } from '@selfxyz/mobile-sdk-alpha/components';
import { slate500, slate700 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import SupportUuidRow from '@/components/support/SupportUuidRow';
import type { TipProps } from '@/components/Tips';
import Tips from '@/components/Tips';
import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide';
@@ -89,6 +90,8 @@ const DocumentNFCTroubleScreen: React.FC = () => {
onSecondaryButtonPress={goToNFCMethodSelection}
footer={
<YStack gap="$3">
<SupportUuidRow title="Support diagnostic ID" />
<SecondaryButton
onPress={openSupportForm}
textColor={slate700}

View File

@@ -25,6 +25,7 @@ import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import WarningIcon from '@/assets/images/warning.svg';
import SupportUuidRow from '@/components/support/SupportUuidRow';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import { extraYPadding } from '@/utils/styleUtils';
@@ -167,6 +168,7 @@ const KycConnectionErrorScreen: React.FC = () => {
Registering with alternative methods may take longer to verify your
document.
</BodyText>
<SupportUuidRow />
</YStack>
</YStack>
);

View File

@@ -22,6 +22,7 @@ import {
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import ShieldErrorIcon from '@/assets/icons/shield_error.svg';
import SupportUuidRow from '@/components/support/SupportUuidRow';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
@@ -75,6 +76,7 @@ const KycFailureScreen: React.FC = () => {
</Description>
</YStack>
<YStack gap={12} paddingHorizontal={24} paddingBottom={32}>
<SupportUuidRow />
<AbstractButton
bgColor="transparent"
color={white}

View File

@@ -21,6 +21,7 @@ import {
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import SupportUuidRow from '@/components/support/SupportUuidRow';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import useOpenSupportForm from '@/hooks/useOpenSupportForm';
import { notificationError } from '@/integrations/haptics';
@@ -157,6 +158,7 @@ const ComingSoonScreen: React.FC<ComingSoonScreenProps> = ({ route }) => {
paddingTop={20}
paddingBottom={20}
>
<SupportUuidRow title="Support diagnostic ID" />
<PrimaryButton
onPress={onNotifyMe}
trackEvent={PassportEvents.NOTIFY_COMING_SOON}

View File

@@ -8,6 +8,7 @@ import { View } from 'tamagui';
import { Caption, SecondaryButton } from '@selfxyz/mobile-sdk-alpha/components';
import { slate500 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import SupportUuidRow from '@/components/support/SupportUuidRow';
import type { TipProps } from '@/components/Tips';
import Tips from '@/components/Tips';
import useHapticNavigation from '@/hooks/useHapticNavigation';
@@ -63,9 +64,12 @@ const QRCodeTrouble: React.FC = () => {
title="Having trouble scanning the QR code?"
onDismiss={go}
footer={
<SecondaryButton onPress={openSupportForm}>
{SUPPORT_FORM_BUTTON_TEXT}
</SecondaryButton>
<View gap={12}>
<SupportUuidRow title="Support diagnostic ID" />
<SecondaryButton onPress={openSupportForm}>
{SUPPORT_FORM_BUTTON_TEXT}
</SecondaryButton>
</View>
}
>
<Caption size="large" style={{ color: slate500, marginBottom: 16 }}>

View File

@@ -34,6 +34,7 @@ const eventQueue: Array<{
name: string;
properties?: Record<string, unknown>;
}> = [];
let supportUuid: string | null = null;
// ============================================================================
// Internal Helpers - JSON Coercion
@@ -111,6 +112,19 @@ function validateParams(
// Internal Helpers - Event Tracking
// ============================================================================
function withSupportUuid(
properties?: Record<string, unknown>,
): Record<string, unknown> | undefined {
if (!supportUuid) {
return properties;
}
return {
...properties,
support_uuid: supportUuid,
};
}
/**
* Internal tracking function used by trackEvent and trackScreenView
* Records analytics events and screen views
@@ -127,7 +141,7 @@ function _track(
const finalEventName = type === 'screen' ? `Viewed ${eventName}` : eventName;
// Validate and clean properties
const validatedProps = validateParams(properties);
const validatedProps = validateParams(withSupportUuid(properties));
if (__DEV__) {
console.log(`[DEV: Analytics ${type.toUpperCase()}]`, {
@@ -210,6 +224,66 @@ export const flushAllAnalytics = () => {
}
};
const identifyInSegment = (nextSupportUuid: string) => {
if (!segmentClient) return Promise.resolve();
return segmentClient.identify(nextSupportUuid).catch(err => {
if (__DEV__) console.warn('Failed to identify Segment user:', err);
});
};
export const resetAnalyticsIdentityForSupportUuid = (
nextSupportUuid: string,
) => {
supportUuid = nextSupportUuid;
if (segmentClient) {
segmentClient
.reset()
.catch(err => {
if (__DEV__) console.warn('Failed to reset Segment identity:', err);
})
.then(() => identifyInSegment(nextSupportUuid));
} else {
identifyInSegment(nextSupportUuid);
}
if (PassportReader && typeof PassportReader.resetIdentity === 'function') {
try {
PassportReader.resetIdentity();
} catch (error) {
if (__DEV__) {
console.warn('Failed to reset Mixpanel identity:', error);
}
}
}
if (PassportReader && typeof PassportReader.setDistinctId === 'function') {
try {
PassportReader.setDistinctId(nextSupportUuid);
} catch (error) {
if (__DEV__) {
console.warn('Failed to set Mixpanel distinct_id:', error);
}
}
}
};
export const setAnalyticsSupportUuid = (nextSupportUuid: string) => {
supportUuid = nextSupportUuid;
identifyInSegment(nextSupportUuid);
if (PassportReader && typeof PassportReader.setDistinctId === 'function') {
try {
PassportReader.setDistinctId(nextSupportUuid);
} catch (error) {
if (__DEV__) {
console.warn('Failed to set Mixpanel distinct_id:', error);
}
}
}
};
/**
* Set NFC scanning state to prevent analytics flush interference
*/
@@ -245,19 +319,23 @@ export const trackNfcEvent = async (
if (!MIXPANEL_NFC_PROJECT_TOKEN) return;
if (!mixpanelConfigured) await configureNfcAnalytics();
const propertiesWithSupportUuid = withSupportUuid(properties);
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 });
eventQueue.push({ name, properties: propertiesWithSupportUuid });
return;
}
try {
if (PassportReader && PassportReader.trackEvent) {
await Promise.resolve(PassportReader.trackEvent(name, properties));
await Promise.resolve(
PassportReader.trackEvent(name, propertiesWithSupportUuid),
);
}
eventCount++;
// Prevent automatic flush during NFC scanning
@@ -270,7 +348,7 @@ export const trackNfcEvent = async (
console.warn('[Mixpanel] Event queue full, dropping oldest event');
eventQueue.shift();
}
eventQueue.push({ name, properties });
eventQueue.push({ name, properties: propertiesWithSupportUuid });
}
};

View File

@@ -7,6 +7,7 @@ import { AppState } from 'react-native';
import type { transportFunctionType } from 'react-native-logs';
import { registerDocumentChangeCallback } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
import {
GRAFANA_LOKI_PASSWORD,
@@ -29,6 +30,9 @@ interface LokiPayload {
streams: LokiStream[];
}
// Per-session ID for grouping logs in Grafana (not persistent, not user-identifiable)
const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
// Batch management state
let batch: LokiLogEntry[] = [];
let batchTimer: NodeJS.Timeout | null = null;
@@ -88,12 +92,11 @@ const sendBatch = async (
});
if (!response.ok) {
console.warn(
`Loki transport failed: ${response.status} ${response.statusText}`,
);
// Silently fail — console.warn here would trigger consoleInterceptor
// feedback loop when logger is enabled in dev
}
} catch (error) {
console.warn('Loki transport error:', error);
} catch {
// Silently fail — same feedback loop risk
}
};
@@ -195,11 +198,15 @@ const lokiTransport: transportFunctionType<LokiTransportOptions> = props => {
level: string;
message: string;
timestamp: string;
session_id: string;
support_uuid: string;
data?: unknown;
} = {
level: level.text,
message: actualMessage,
timestamp,
session_id: sessionId,
support_uuid: useSettingStore.getState().supportUuid ?? 'unset',
};
if (actualData) {

View File

@@ -16,6 +16,64 @@ import { useSettingStore } from '@/stores/settingStore';
export const SELF_UUID_NAMESPACE = '00000000-0000-8000-8000-531f00000000';
const REGISTER_TOKEN_TIMEOUT_MS = 10000;
const REGISTER_TOKEN_MAX_ATTEMPTS = 3;
const REGISTER_TOKEN_BACKOFF_MS = 500;
// Retry only on TypeError (pure transport failure before the request body
// reached the server). 5xx and AbortError are not retried because the server
// may already have processed the request, and /register-token is not known to
// be idempotent — retrying could create duplicate registrations.
async function fetchRegisterToken(
url: string,
body: string,
): Promise<Response> {
let attempt = 0;
let lastError: unknown;
while (attempt < REGISTER_TOKEN_MAX_ATTEMPTS) {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
REGISTER_TOKEN_TIMEOUT_MS,
);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (err) {
clearTimeout(timeoutId);
lastError = err;
if (!(err instanceof TypeError)) {
break;
}
}
attempt += 1;
if (attempt >= REGISTER_TOKEN_MAX_ATTEMPTS) {
break;
}
const backoff =
REGISTER_TOKEN_BACKOFF_MS + Math.random() * REGISTER_TOKEN_BACKOFF_MS;
await new Promise(resolve => setTimeout(resolve, backoff));
}
throw lastError instanceof Error
? lastError
: new Error(String(lastError ?? 'register-token request failed'));
}
export async function getFCMToken(): Promise<string | null> {
try {
const token = await messaging().getToken();
@@ -137,14 +195,10 @@ export async function registerDeviceToken(
);
}
const response = await fetch(`${baseUrl}/register-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(deviceTokenRegistration),
});
const response = await fetchRegisterToken(
`${baseUrl}/register-token`,
JSON.stringify(deviceTokenRegistration),
);
if (!response.ok) {
const errorText = await response.text();

View File

@@ -6,6 +6,7 @@ import { Linking } from 'react-native';
import { supportFormUrl } from '@/consts/links';
import { navigationRef } from '@/navigation';
import { appendSupportUuidToUrl } from '@/services/supportUuid';
export const SUPPORT_FORM_BUTTON_TEXT = 'Send feedback';
@@ -24,13 +25,15 @@ export const SUPPORT_FORM_TIP_MESSAGE = 'Have feedback? Let us know.';
* Falls back to opening the URL in the system browser if navigation is not ready.
*/
export const openSupportForm = (): void => {
const supportUrl = appendSupportUuidToUrl(supportFormUrl);
if (navigationRef.isReady()) {
navigationRef.navigate('WebView', {
url: supportFormUrl,
url: supportUrl,
title: 'Get Support',
});
} else {
Linking.openURL(supportFormUrl).catch(err =>
Linking.openURL(supportUrl).catch(err =>
console.warn('Failed to open support form URL:', err),
);
}

View File

@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { v4 as uuidv4 } from 'uuid';
import Clipboard from '@react-native-clipboard/clipboard';
import { setSupportUuidInSentry } from '@/config/sentry';
import {
resetAnalyticsIdentityForSupportUuid,
setAnalyticsSupportUuid,
} from '@/services/analytics';
import { useSettingStore } from '@/stores/settingStore';
const ensureSupportUuid = (): string => {
const state = useSettingStore.getState();
if (state.supportUuid) {
return state.supportUuid;
}
const nextUuid = uuidv4();
state.setSupportUuid(nextUuid);
return nextUuid;
};
export const appendSupportUuidToUrl = (url: string): string => {
const supportUuid = ensureSupportUuid();
try {
const parsed = new URL(url);
parsed.searchParams.set('support_uuid', supportUuid);
return parsed.toString();
} catch {
// Fallback for malformed URLs / unsupported URL parsing edge-cases.
// Preserve any fragment by appending the query before `#`.
const hashIndex = url.indexOf('#');
const baseUrl = hashIndex === -1 ? url : url.slice(0, hashIndex);
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
const separator = baseUrl.includes('?') ? '&' : '?';
return `${baseUrl}${separator}support_uuid=${encodeURIComponent(supportUuid)}${hash}`;
}
};
export const copySupportUuid = (): string => {
const supportUuid = ensureSupportUuid();
Clipboard.setString(supportUuid);
return supportUuid;
};
export const getSupportUuid = (): string => {
return ensureSupportUuid();
};
export const initializeSupportUuidContext = (): string => {
const supportUuid = ensureSupportUuid();
setSupportUuidInSentry(supportUuid);
setAnalyticsSupportUuid(supportUuid);
return supportUuid;
};
export const regenerateSupportUuid = (): string => {
const nextUuid = uuidv4();
const state = useSettingStore.getState();
state.setSupportUuid(nextUuid);
setSupportUuidInSentry(nextUuid);
resetAnalyticsIdentityForSupportUuid(nextUuid);
return nextUuid;
};

View File

@@ -35,6 +35,19 @@ const SYNC_THROTTLE_MS = 30 * 1000; // 30 seconds throttle for sync calls
export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
let lastSyncTime = 0; // Track last sync time for throttling
const parseStatusMessage = (message: unknown) => {
if (typeof message !== 'string') {
return message as Record<string, unknown>;
}
try {
return JSON.parse(message) as Record<string, unknown>;
} catch (error) {
console.error('Invalid websocket status payload', error);
return null;
}
};
const syncProofHistoryStatus = async () => {
try {
// Throttling mechanism - prevent sync if called too frequently
@@ -54,34 +67,73 @@ export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
return;
}
const pendingSessionIds = new Set(
pendingProofs.rows.map(proof => proof.sessionId),
);
const websocket = io(WS_DB_RELAYER, {
path: '/',
transports: ['websocket'],
});
setTimeout(() => {
if (websocket.connected) {
websocket.disconnect();
}
const disconnectTimer = setTimeout(() => {
websocket.disconnect();
// disconnect after 2 minutes
}, SYNC_THROTTLE_MS * 4);
websocket.on('disconnect', () => {
if (!websocket.active) {
clearTimeout(disconnectTimer);
}
});
websocket.on('connect_error', error => {
console.error('Proof history websocket connection error', error);
});
websocket.on('error', error => {
console.error('Proof history websocket error', error);
});
for (let i = 0; i < pendingProofs.rows.length; i++) {
const proof = pendingProofs.rows[i];
websocket.emit('subscribe', proof.sessionId);
}
websocket.timeout(SYNC_THROTTLE_MS * 3).on('status', message => {
const data =
typeof message === 'string' ? JSON.parse(message) : message;
const data = parseStatusMessage(message);
if (data.status === 3) {
get().updateProofStatus(data.request_id, ProofStatus.FAILURE);
} else if (data.status === 4) {
get().updateProofStatus(data.request_id, ProofStatus.SUCCESS);
} else if (data.status === 5) {
get().updateProofStatus(data.request_id, ProofStatus.FAILURE);
if (!data) {
return;
}
websocket.emit('unsubscribe', data.request_id);
const status = Number(data.status);
const requestId =
typeof data.request_id === 'string' ? data.request_id : undefined;
if (!requestId) {
console.error('Proof history status message missing request_id');
return;
}
if (!pendingSessionIds.has(requestId)) {
console.error('Proof history status message for unknown request_id');
return;
}
if (status !== 3 && status !== 4 && status !== 5) {
return;
}
pendingSessionIds.delete(requestId);
if (status === 4) {
get().updateProofStatus(requestId, ProofStatus.SUCCESS);
} else {
get().updateProofStatus(requestId, ProofStatus.FAILURE);
}
websocket.emit('unsubscribe', requestId);
});
} catch (error) {
console.error('Error syncing proof status', error);

View File

@@ -23,6 +23,7 @@ interface PersistedSettingsState {
isDevMode: boolean;
loggingSeverity: LoggingSeverity;
pointsAddress: string | null;
supportUuid: string | null;
removeSubscribedTopic: (topic: string) => void;
resetBackupForPoints: () => void;
setBackupForPointsCompleted: () => void;
@@ -34,6 +35,7 @@ interface PersistedSettingsState {
setKeychainMigrationCompleted: () => void;
setLoggingSeverity: (severity: LoggingSeverity) => void;
setPointsAddress: (address: string | null) => void;
setSupportUuid: (supportUuid: string | null) => void;
setSkipDocumentSelector: (value: boolean) => void;
setSubscribedTopics: (topics: string[]) => void;
setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void;
@@ -139,6 +141,9 @@ export const useSettingStore = create<SettingsState>()(
setPointsAddress: (address: string | null) =>
set({ pointsAddress: address }),
supportUuid: null,
setSupportUuid: (supportUuid: string | null) => set({ supportUuid }),
// Document selector skip settings
skipDocumentSelector: false,
setSkipDocumentSelector: (value: boolean) =>

View File

@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
module.exports = {
CameraView: 'CameraView',
useCameraPermissions: () => [{ granted: true, canAskAgain: true }, jest.fn()],
};

View File

@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { act, renderHook, waitFor } from '@testing-library/react-native';
import useHasRealDocument from '@/hooks/useHasRealDocument';
import { usePassport } from '@/providers/passportDataProvider';
const mockUseFocusEffect = jest.fn();
let focusEffectCallback: (() => void | (() => void)) | undefined;
jest.mock('@react-navigation/native', () => ({
useFocusEffect: (callback: () => void | (() => void)) =>
mockUseFocusEffect(callback),
}));
jest.mock('@/providers/passportDataProvider', () => ({
usePassport: jest.fn(),
}));
const mockUsePassport = usePassport as jest.MockedFunction<typeof usePassport>;
describe('useHasRealDocument', () => {
const loadDocumentCatalog = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockUseFocusEffect.mockImplementation(
(callback: () => void | (() => void)) => {
focusEffectCallback = callback;
},
);
mockUsePassport.mockReturnValue({
loadDocumentCatalog,
} as ReturnType<typeof usePassport>);
});
it('starts as null and resolves true when a real document exists', async () => {
loadDocumentCatalog.mockResolvedValue({
documents: [{ id: 'real-doc', mock: false }],
});
const { result } = renderHook(() => useHasRealDocument('SettingsScreen'));
expect(result.current.hasRealDocument).toBeNull();
act(() => {
focusEffectCallback?.();
});
await waitFor(() => {
expect(result.current.hasRealDocument).toBe(true);
});
expect(loadDocumentCatalog).toHaveBeenCalledTimes(1);
});
it('resolves false and logs when the catalog structure is invalid', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
loadDocumentCatalog.mockResolvedValue({
documents: null,
});
const { result } = renderHook(() =>
useHasRealDocument('ManageDocumentsScreen'),
);
act(() => {
focusEffectCallback?.();
});
await waitFor(() => {
expect(result.current.hasRealDocument).toBe(false);
});
expect(warnSpy).toHaveBeenCalledWith(
'ManageDocumentsScreen: invalid catalog structure',
);
warnSpy.mockRestore();
});
it('refresh updates state when the catalog changes after mount', async () => {
loadDocumentCatalog
.mockResolvedValueOnce({
documents: [{ id: 'mock-doc', mock: true }],
})
.mockResolvedValueOnce({
documents: [{ id: 'real-doc', mock: false }],
});
const { result } = renderHook(() => useHasRealDocument());
act(() => {
focusEffectCallback?.();
});
await waitFor(() => {
expect(result.current.hasRealDocument).toBe(false);
});
await act(async () => {
await result.current.refresh();
});
await waitFor(() => {
expect(result.current.hasRealDocument).toBe(true);
});
expect(loadDocumentCatalog).toHaveBeenCalledTimes(2);
});
});

View File

@@ -34,9 +34,15 @@ describe('useOpenSupportForm', () => {
});
expect(impactLight).toHaveBeenCalledTimes(1);
expect(navigationRef.navigate).toHaveBeenCalledWith('WebView', {
url: supportFormUrl,
title: 'Get Support',
});
expect(navigationRef.navigate).toHaveBeenCalledWith(
'WebView',
expect.objectContaining({
title: 'Get Support',
}),
);
const [, params] = (navigationRef.navigate as jest.Mock).mock.calls[0];
expect(params.url).toContain(supportFormUrl);
expect(params.url).toContain('support_uuid=');
});
});

View File

@@ -104,11 +104,13 @@ describe('navigation', () => {
'RegistrationFallbackMRZ',
'RegistrationFallbackNFC',
'SaveRecoveryPhrase',
'SecurityAndBackup',
'Settings',
'ShowRecoveryPhrase',
'SocialLoginDemo',
'Splash',
'StarfallPushCode',
'Support',
'WebView',
]);
});

View File

@@ -8,10 +8,12 @@ import { render, waitFor } from '@testing-library/react-native';
import { SelfClientProvider } from '@selfxyz/mobile-sdk-alpha';
import { captureException } from '@/config/sentry';
// Import after mocking
import {
__resetPassportProviderTestState,
initializeNativeModules,
loadDocumentByIdDirectlyFromKeychain,
loadDocumentCatalogDirectlyFromKeychain,
migrateFromLegacyStorage,
PassportProvider,
@@ -47,6 +49,14 @@ jest.mock('@/providers/authProvider', () => ({
useAuth: () => mockAuthProvider,
}));
jest.mock('@/config/sentry', () => ({
captureException: jest.fn(),
}));
const mockCaptureException = captureException as jest.MockedFunction<
typeof captureException
>;
const MockText = ({
children,
testID,
@@ -149,6 +159,7 @@ describe('PassportDataProvider', () => {
jest.clearAllMocks();
console.log = jest.fn();
console.warn = jest.fn();
console.error = jest.fn();
__resetPassportProviderTestState();
});
@@ -579,7 +590,7 @@ describe('PassportDataProvider', () => {
expect(result).toEqual({ documents: [] });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Error loading document catalog:',
'Error loading document:',
expect.any(SyntaxError),
);
@@ -622,7 +633,7 @@ describe('PassportDataProvider', () => {
expect(result).toEqual({ documents: [] });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Error loading document catalog:',
'Error loading document:',
expect.any(SyntaxError),
);
@@ -649,7 +660,7 @@ describe('PassportDataProvider', () => {
// and the function returns empty catalog
expect(result).toEqual({ documents: [] });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Error loading document catalog:',
'Error loading document:',
expect.any(TypeError),
);
@@ -674,11 +685,42 @@ describe('PassportDataProvider', () => {
expect(result).toEqual({ documents: [] });
expect(consoleLogSpy).toHaveBeenCalledWith(
'Error loading document catalog:',
'Error loading document:',
expect.any(SyntaxError),
);
consoleLogSpy.mockRestore();
});
});
describe('loadDocumentByIdDirectlyFromKeychain', () => {
beforeEach(() => {
jest.clearAllMocks();
__resetPassportProviderTestState();
});
it('sanitizes document ids before sending keychain failures to Sentry', async () => {
const cryptoError = Object.assign(new Error('Decryption failed'), {
code: 'E_CRYPTO_FAILED',
name: 'com.oblador.keychain.exceptions.CryptoFailedException',
});
mockKeychain.getGenericPassword = jest
.fn()
.mockResolvedValueOnce({ password: 'test' })
.mockRejectedValueOnce(cryptoError);
await initializeNativeModules();
const result = await loadDocumentByIdDirectlyFromKeychain('doc-123');
expect(result).toBeNull();
expect(mockCaptureException).toHaveBeenCalledWith(cryptoError, {
module: 'passport-data-provider',
contextLabel: 'document',
errorCode: 'E_CRYPTO_FAILED',
errorName: 'com.oblador.keychain.exceptions.CryptoFailedException',
});
});
});
});

View File

@@ -6,6 +6,7 @@ import type { ReactNode } from 'react';
import { render, waitFor } from '@testing-library/react-native';
import { initRemoteConfig } from '@/config/remoteConfig';
import { captureException } from '@/config/sentry';
import {
RemoteConfigProvider,
useRemoteConfig,
@@ -16,9 +17,16 @@ jest.mock('@/config/remoteConfig', () => ({
initRemoteConfig: jest.fn(),
}));
jest.mock('@/config/sentry', () => ({
captureException: jest.fn(),
}));
const mockInitRemoteConfig = initRemoteConfig as jest.MockedFunction<
typeof initRemoteConfig
>;
const mockCaptureException = captureException as jest.MockedFunction<
typeof captureException
>;
// Test component that uses the hook
const MockText = ({
@@ -92,6 +100,9 @@ describe('RemoteConfigProvider', () => {
'Failed to initialize remote config:',
expect.any(Error),
);
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error), {
module: 'remote-config-provider',
});
});
it('should handle non-Error rejection gracefully', async () => {

View File

@@ -0,0 +1,216 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react-native';
import RecoverWithPhraseScreen from '@/screens/account/recovery/RecoverWithPhraseScreen';
declare global {
namespace JSX {
interface IntrinsicElements {
'mock-view': any;
'mock-text': any;
'mock-textarea': any;
'mock-stack': any;
'mock-pressable': any;
'mock-paste-icon': any;
'mock-button': any;
}
}
}
jest.mock('react-native', () => ({
__esModule: true,
Keyboard: {
dismiss: jest.fn(),
},
Pressable: ({ children, ...props }: any) => (
<mock-pressable {...props}>{children}</mock-pressable>
),
StyleSheet: {
create: (styles: unknown) => styles,
flatten: (style: unknown) => style,
},
}));
jest.mock('tamagui', () => ({
__esModule: true,
Text: ({ children, ...props }: any) => (
<mock-text {...props}>{children}</mock-text>
),
TextArea: ({ children, ...props }: any) => (
<mock-textarea {...props}>{children}</mock-textarea>
),
View: ({ children, ...props }: any) => (
<mock-view {...props}>{children}</mock-view>
),
XStack: ({ children, ...props }: any) => (
<mock-stack {...props}>{children}</mock-stack>
),
YStack: ({ children, ...props }: any) => (
<mock-stack {...props}>{children}</mock-stack>
),
}));
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
}));
jest.mock('@react-native-clipboard/clipboard', () => ({
__esModule: true,
default: {
getString: jest.fn(),
},
}));
jest.mock('ethers', () => ({
ethers: {
Mnemonic: {
isValidMnemonic: jest.fn(() => true),
},
},
}));
jest.mock('@selfxyz/common/utils/passports/validate', () => ({
isUserRegisteredWithAlternativeCSCA: jest.fn(),
}));
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
markCurrentDocumentAsRegistered: jest.fn(),
useSelfClient: jest.fn(),
}));
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
Description: ({ children, ...props }: any) => (
<mock-text {...props}>{children}</mock-text>
),
SecondaryButton: ({ children, onPress, disabled, ...props }: any) => (
<mock-button onPress={onPress} disabled={disabled} {...props}>
{children}
</mock-button>
),
}));
jest.mock('@selfxyz/mobile-sdk-alpha/constants/analytics', () => ({
BackupEvents: {
CLOUD_RESTORE_FAILED_UNKNOWN: 'CLOUD_RESTORE_FAILED_UNKNOWN',
CLOUD_RESTORE_FAILED_AUTH: 'CLOUD_RESTORE_FAILED_AUTH',
CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED:
'CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED',
ACCOUNT_RECOVERY_COMPLETED: 'ACCOUNT_RECOVERY_COMPLETED',
},
}));
jest.mock('@selfxyz/mobile-sdk-alpha/constants/colors', () => ({
black: '#000',
red500: '#f00',
slate300: '#333',
slate400: '#444',
slate600: '#666',
slate700: '#777',
white: '#fff',
}));
jest.mock('@/assets/icons/paste.svg', () => ({
__esModule: true,
default: (props: any) => <mock-paste-icon {...props} />,
}));
jest.mock('@/providers/authProvider', () => ({
getPrivateKeyFromMnemonic: jest.fn(),
useAuth: jest.fn(),
}));
jest.mock('@/providers/passportDataProvider', () => ({
loadPassportData: jest.fn(),
reStorePassportDataWithRightCSCA: jest.fn(),
}));
jest.mock('@/screens/account/recovery/recoveryCopy', () => ({
recoveryCopy: {
phrase: {
instructions: 'Recovery instructions',
placeholder: 'Enter or paste your recovery phrase',
paste: 'PASTE',
submit: 'Continue',
},
},
}));
const { useNavigation } = jest.requireMock('@react-navigation/native') as {
useNavigation: jest.Mock;
};
const { useSelfClient } = jest.requireMock('@selfxyz/mobile-sdk-alpha') as {
useSelfClient: jest.Mock;
};
const { useAuth } = jest.requireMock('@/providers/authProvider') as {
useAuth: jest.Mock;
};
describe('RecoverWithPhraseScreen', () => {
const mockNavigate = jest.fn();
const mockTrackEvent = jest.fn();
const mockRestoreAccountFromMnemonic = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
useNavigation.mockReturnValue({
navigate: mockNavigate,
});
useSelfClient.mockReturnValue({
trackEvent: mockTrackEvent,
useProtocolStore: {
getState: jest.fn(() => ({
passport: {
commitment_tree: {},
alternative_csca: {},
},
aadhaar: {
public_keys: [],
},
})),
},
});
useAuth.mockReturnValue({
restoreAccountFromMnemonic: mockRestoreAccountFromMnemonic,
});
});
it('tracks only the error name for unexpected recovery failures', async () => {
const restoreError = new Error(
'mnemonic payload leaked into raw error message',
);
restoreError.name = 'SecureRestoreError';
mockRestoreAccountFromMnemonic.mockRejectedValue(restoreError);
const { UNSAFE_getByType } = render(<RecoverWithPhraseScreen />);
fireEvent.changeText(
UNSAFE_getByType('mock-textarea'),
'valid seed phrase',
);
fireEvent.press(UNSAFE_getByType('mock-button'));
await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith(
'CLOUD_RESTORE_FAILED_UNKNOWN',
{
reason: 'unexpected_error',
error: 'SecureRestoreError',
},
);
});
expect(mockTrackEvent).not.toHaveBeenCalledWith(
'CLOUD_RESTORE_FAILED_UNKNOWN',
expect.objectContaining({
error: 'mnemonic payload leaked into raw error message',
}),
);
});
});

View File

@@ -0,0 +1,120 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import {
buildSettingsMenu,
shouldShowSettingsEntry,
} from '@/screens/account/settings/settingsMenu';
describe('settingsMenu', () => {
describe('buildSettingsMenu', () => {
it('iOS with real doc: full native order, no debug', () => {
const menu = buildSettingsMenu({
platform: 'ios',
hasRealDocument: true,
isDevMode: false,
});
expect(menu.map(e => e.route)).toEqual([
'ManageDocuments',
'SecurityAndBackup',
'ProofSettings',
'Support',
'share',
]);
});
it('iOS with no real doc: keeps Security & backup so Cloud backup stays reachable', () => {
const menu = buildSettingsMenu({
platform: 'ios',
hasRealDocument: false,
isDevMode: false,
});
expect(menu.map(e => e.route)).toContain('SecurityAndBackup');
});
it('Android with no real doc: hides Security & backup (both children unreachable)', () => {
const menu = buildSettingsMenu({
platform: 'android',
hasRealDocument: false,
isDevMode: false,
});
expect(menu.map(e => e.route)).not.toContain('SecurityAndBackup');
expect(menu.map(e => e.route)).toEqual([
'ManageDocuments',
'ProofSettings',
'Support',
'share',
]);
});
it('Android with real doc: Security & backup shown', () => {
const menu = buildSettingsMenu({
platform: 'android',
hasRealDocument: true,
isDevMode: false,
});
expect(menu.map(e => e.route)).toContain('SecurityAndBackup');
});
it('web: omits Security & backup and Share regardless of doc state', () => {
const menu = buildSettingsMenu({
platform: 'web',
hasRealDocument: true,
isDevMode: false,
});
expect(menu.map(e => e.route)).toEqual([
'ManageDocuments',
'ProofSettings',
'Support',
]);
});
it('appends Debug menu when isDevMode is true', () => {
const menu = buildSettingsMenu({
platform: 'ios',
hasRealDocument: true,
isDevMode: true,
});
expect(menu[menu.length - 1].route).toBe('DevSettings');
});
it('does not include Debug menu when isDevMode is false', () => {
const menu = buildSettingsMenu({
platform: 'ios',
hasRealDocument: true,
isDevMode: false,
});
expect(menu.map(e => e.route)).not.toContain('DevSettings');
});
});
describe('shouldShowSettingsEntry', () => {
it('always shows ManageDocuments', () => {
expect(
shouldShowSettingsEntry(
{ label: 'Manage ID documents', route: 'ManageDocuments' },
{ platform: 'android', hasRealDocument: false },
),
).toBe(true);
});
it('hides SecurityAndBackup on Android without a real doc', () => {
expect(
shouldShowSettingsEntry(
{ label: 'Security & backup', route: 'SecurityAndBackup' },
{ platform: 'android', hasRealDocument: false },
),
).toBe(false);
});
it('shows SecurityAndBackup on iOS without a real doc', () => {
expect(
shouldShowSettingsEntry(
{ label: 'Security & backup', route: 'SecurityAndBackup' },
{ platform: 'ios', hasRealDocument: false },
),
).toBe(true);
});
});
});

View File

@@ -0,0 +1,195 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react-native';
import useHasRealDocument from '@/hooks/useHasRealDocument';
import { usePassport } from '@/providers/passportDataProvider';
import ManageDocumentsScreen from '@/screens/documents/management/ManageDocumentsScreen';
declare global {
namespace JSX {
interface IntrinsicElements {
'mock-button': any;
'mock-stack': any;
'mock-text': any;
'mock-spinner': any;
}
}
}
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: jest.fn(() => ({ bottom: 0 })),
}));
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
}));
jest.mock('tamagui', () => ({
Button: ({ children, onPress, ...props }: any) => (
<mock-button onPress={onPress} {...props}>
{children}
</mock-button>
),
ScrollView: ({ children, ...props }: any) => (
<mock-stack {...props}>{children}</mock-stack>
),
Spinner: (props: any) => <mock-spinner {...props} />,
Text: ({ children, ...props }: any) => (
<mock-text {...props}>{children}</mock-text>
),
XStack: ({ children, ...props }: any) => (
<mock-stack {...props}>{children}</mock-stack>
),
YStack: ({ children, ...props }: any) => (
<mock-stack {...props}>{children}</mock-stack>
),
}));
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
useSelfClient: jest.fn(),
}));
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
ButtonsContainer: ({ children, ...props }: any) => (
<mock-stack {...props}>{children}</mock-stack>
),
PrimaryButton: ({ children, onPress, ...props }: any) => (
<mock-button onPress={onPress} {...props}>
{children}
</mock-button>
),
SecondaryButton: ({ children, onPress, ...props }: any) => (
<mock-button onPress={onPress} {...props}>
{children}
</mock-button>
),
}));
jest.mock('@selfxyz/mobile-sdk-alpha/constants/colors', () => ({
borderColor: '#ddd',
textBlack: '#111',
white: '#fff',
}));
jest.mock('@selfxyz/mobile-sdk-alpha/constants/analytics', () => ({
DocumentEvents: {
MANAGE_SCREEN_OPENED: 'MANAGE_SCREEN_OPENED',
ADD_NEW_SCAN_SELECTED: 'ADD_NEW_SCAN_SELECTED',
ADD_NEW_MOCK_SELECTED: 'ADD_NEW_MOCK_SELECTED',
DOCUMENTS_FETCHED: 'DOCUMENTS_FETCHED',
NO_DOCUMENTS_FOUND: 'NO_DOCUMENTS_FOUND',
DOCUMENT_DELETED: 'DOCUMENT_DELETED',
DOCUMENT_SELECTED: 'DOCUMENT_SELECTED',
},
}));
jest.mock('@tamagui/lucide-icons', () => ({
Check: () => null,
Eraser: () => null,
HousePlus: () => null,
}));
jest.mock('@/hooks/useHasRealDocument', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('@/integrations/haptics', () => ({
impactLight: jest.fn(),
}));
jest.mock('@/providers/passportDataProvider', () => ({
usePassport: jest.fn(),
}));
const { useNavigation } = jest.requireMock('@react-navigation/native') as {
useNavigation: jest.Mock;
};
const { useSelfClient } = jest.requireMock('@selfxyz/mobile-sdk-alpha') as {
useSelfClient: jest.Mock;
};
const { impactLight } = jest.requireMock('@/integrations/haptics') as {
impactLight: jest.Mock;
};
const mockUseHasRealDocument = useHasRealDocument as jest.MockedFunction<
typeof useHasRealDocument
>;
const mockUsePassport = usePassport as jest.MockedFunction<typeof usePassport>;
describe('ManageDocumentsScreen', () => {
const navigate = jest.fn();
const trackEvent = jest.fn();
const loadDocumentCatalog = jest.fn();
const getAllDocuments = jest.fn();
const deleteDocument = jest.fn();
const setSelectedDocument = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
useNavigation.mockReturnValue({ navigate });
useSelfClient.mockReturnValue({ trackEvent });
mockUseHasRealDocument.mockReturnValue({
hasRealDocument: false,
refresh: jest.fn(),
});
mockUsePassport.mockReturnValue({
loadDocumentCatalog,
getAllDocuments,
deleteDocument,
setSelectedDocument,
} as ReturnType<typeof usePassport>);
loadDocumentCatalog.mockResolvedValue({
documents: [],
selectedDocumentId: undefined,
});
getAllDocuments.mockResolvedValue({});
});
const getButtonLabels = (root: any): string[] =>
root.findAllByType('mock-button').map((button: any) => {
const { children } = button.props;
return Array.isArray(children) ? children.join('') : String(children);
});
it('hides the View Document Info action when no real document exists', async () => {
const { UNSAFE_root } = render(<ManageDocumentsScreen />);
await waitFor(() => {
expect(getButtonLabels(UNSAFE_root)).toEqual([
'Add New Document',
'Generate Mock Document',
]);
});
expect(getButtonLabels(UNSAFE_root)).not.toContain('View Document Info');
});
it('shows the View Document Info action and navigates to DocumentDataInfo', async () => {
mockUseHasRealDocument.mockReturnValue({
hasRealDocument: true,
refresh: jest.fn(),
});
const { UNSAFE_root } = render(<ManageDocumentsScreen />);
await waitFor(() => {
expect(getButtonLabels(UNSAFE_root)).toEqual([
'Add New Document',
'View Document Info',
'Generate Mock Document',
]);
});
fireEvent.press(UNSAFE_root.findAllByType('mock-button')[1]);
expect(impactLight).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith('DocumentDataInfo');
});
});

View File

@@ -2,15 +2,37 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { trackEvent, trackScreenView } from '@/services/analytics';
import * as segmentConfig from '@/config/segment';
import {
resetAnalyticsIdentityForSupportUuid,
setAnalyticsSupportUuid,
trackEvent,
trackScreenView,
} from '@/services/analytics';
// Mock the Segment client
jest.mock('@/config/segment', () => ({
createSegmentClient: jest.fn(() => ({
jest.mock('@/config/segment', () => {
const client = {
track: jest.fn().mockResolvedValue(undefined),
flush: jest.fn().mockResolvedValue(undefined),
})),
}));
identify: jest.fn().mockResolvedValue(undefined),
reset: jest.fn().mockResolvedValue(undefined),
};
return {
createSegmentClient: jest.fn(() => client),
__client: client,
};
});
const mockSegmentClient = (
segmentConfig as unknown as {
__client: {
track: jest.Mock;
flush: jest.Mock;
identify: jest.Mock;
reset: jest.Mock;
};
}
).__client;
describe('analytics', () => {
beforeEach(() => {
@@ -337,4 +359,19 @@ describe('analytics', () => {
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
});
describe('support UUID identity wiring', () => {
it('identifies the user in Segment on setAnalyticsSupportUuid', () => {
setAnalyticsSupportUuid('uuid-1');
expect(mockSegmentClient.identify).toHaveBeenCalledWith('uuid-1');
});
it('resets and re-identifies on resetAnalyticsIdentityForSupportUuid', async () => {
resetAnalyticsIdentityForSupportUuid('uuid-2');
expect(mockSegmentClient.reset).toHaveBeenCalled();
await Promise.resolve();
await Promise.resolve();
expect(mockSegmentClient.identify).toHaveBeenCalledWith('uuid-2');
});
});
});

View File

@@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/**
* @jest-environment node
*/
import {
flushLokiTransport,
lokiTransport,
} from '@/services/logging/logger/lokiTransport';
import { useSettingStore } from '@/stores/settingStore';
jest.mock('react-native', () => ({
AppState: {
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
},
}));
jest.mock('@/providers/passportDataProvider', () => ({
registerDocumentChangeCallback: jest.fn(),
}));
jest.mock('../../../../env', () => ({
GRAFANA_LOKI_URL: 'https://loki.example.com',
GRAFANA_LOKI_USERNAME: '',
GRAFANA_LOKI_PASSWORD: '',
}));
jest.mock('@/stores/settingStore', () => {
const state = { supportUuid: null as string | null };
return {
useSettingStore: {
getState: () => ({
get supportUuid() {
return state.supportUuid;
},
}),
__state: state,
},
};
});
const storeState = (
useSettingStore as unknown as {
__state: { supportUuid: string | null };
}
).__state;
const callTransport = (overrides: Record<string, unknown> = {}) => {
(lokiTransport as unknown as (props: Record<string, unknown>) => void)({
msg: 'hello',
rawMsg: ['hello'],
level: { text: 'info', severity: 2 },
extension: 'default',
...overrides,
});
};
describe('lokiTransport', () => {
let fetchMock: jest.Mock;
beforeEach(() => {
fetchMock = jest.fn().mockResolvedValue({ ok: true });
(global as unknown as { fetch: jest.Mock }).fetch = fetchMock;
storeState.supportUuid = null;
});
afterEach(() => {
flushLokiTransport();
jest.useRealTimers();
});
it('includes support_uuid in the log body when set', async () => {
storeState.supportUuid = 'abc-123';
callTransport();
flushLokiTransport();
await Promise.resolve();
await Promise.resolve();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
const payload = JSON.parse(init.body as string);
const logLine = JSON.parse(payload.streams[0].values[0][1]);
expect(logLine.support_uuid).toBe('abc-123');
});
it('falls back to "unset" when no support UUID is stored', async () => {
callTransport();
flushLokiTransport();
await Promise.resolve();
await Promise.resolve();
const [, init] = fetchMock.mock.calls[0];
const payload = JSON.parse(init.body as string);
const logLine = JSON.parse(payload.streams[0].values[0][1]);
expect(logLine.support_uuid).toBe('unset');
});
it('does not put support_uuid or session_id in Loki stream labels', async () => {
storeState.supportUuid = 'should-not-leak-into-labels';
callTransport();
flushLokiTransport();
await Promise.resolve();
await Promise.resolve();
const [, init] = fetchMock.mock.calls[0];
const payload = JSON.parse(init.body as string);
const stream = payload.streams[0].stream;
expect(stream).not.toHaveProperty('support_uuid');
expect(stream).not.toHaveProperty('session_id');
expect(stream).toMatchObject({
app: 'self-mobile',
platform: 'react-native',
level: 'info',
});
});
});

View File

@@ -66,6 +66,11 @@ describe('notificationService', () => {
mockPermissionsAndroid.request.mockClear();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
describe('requestNotificationPermission', () => {
it('grants permission on Android', async () => {
mockPlatform.OS = 'android';
@@ -128,5 +133,77 @@ describe('notificationService', () => {
expect.objectContaining({ method: 'POST' }),
);
});
it('retries transport failures and eventually succeeds', async () => {
jest.useFakeTimers();
jest.spyOn(Math, 'random').mockReturnValue(0);
(fetch as jest.Mock)
.mockRejectedValueOnce(new TypeError('network down'))
.mockRejectedValueOnce(new TypeError('still down'))
.mockResolvedValueOnce({ ok: true, text: jest.fn() });
const promise = service.registerDeviceToken('123', 'tok', true);
await jest.runAllTimersAsync();
await promise;
expect(fetch).toHaveBeenCalledTimes(3);
});
it('does not retry abort errors', async () => {
const abortError = new Error('request aborted');
abortError.name = 'AbortError';
(fetch as jest.Mock).mockRejectedValue(abortError);
await service.registerDeviceToken('123', 'tok', true);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('does not retry server errors', async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 500,
text: jest.fn().mockResolvedValue('server exploded'),
});
await service.registerDeviceToken('123', 'tok', true);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('aborts a stalled request after the timeout window', async () => {
jest.useFakeTimers();
let capturedSignal: AbortSignal | undefined;
(fetch as jest.Mock).mockImplementation(
(_url: string, init?: RequestInit) => {
capturedSignal = init?.signal as AbortSignal | undefined;
return new Promise((_resolve, reject) => {
capturedSignal?.addEventListener(
'abort',
() => {
const abortError = new Error('request aborted');
abortError.name = 'AbortError';
reject(abortError);
},
{ once: true },
);
});
},
);
const promise = service.registerDeviceToken('123', 'tok', true);
expect(capturedSignal?.aborted).toBe(false);
await jest.advanceTimersByTimeAsync(10000);
await promise;
expect(capturedSignal?.aborted).toBe(true);
expect(fetch).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import Clipboard from '@react-native-clipboard/clipboard';
import { setSupportUuidInSentry } from '@/config/sentry';
import {
resetAnalyticsIdentityForSupportUuid,
setAnalyticsSupportUuid,
} from '@/services/analytics';
import {
appendSupportUuidToUrl,
copySupportUuid,
getSupportUuid,
initializeSupportUuidContext,
regenerateSupportUuid,
} from '@/services/supportUuid';
import { useSettingStore } from '@/stores/settingStore';
jest.mock('@/stores/settingStore', () => {
const state = { supportUuid: null as string | null };
const setSupportUuid = jest.fn((next: string | null) => {
state.supportUuid = next;
});
return {
useSettingStore: {
getState: () => ({
get supportUuid() {
return state.supportUuid;
},
setSupportUuid,
}),
__state: state,
__setSupportUuid: setSupportUuid,
},
};
});
jest.mock('@/config/sentry', () => ({
setSupportUuidInSentry: jest.fn(),
}));
jest.mock('@/services/analytics', () => ({
setAnalyticsSupportUuid: jest.fn(),
resetAnalyticsIdentityForSupportUuid: jest.fn(),
}));
jest.mock('@react-native-clipboard/clipboard', () => ({
__esModule: true,
default: { setString: jest.fn() },
}));
const storeState = (
useSettingStore as unknown as { __state: { supportUuid: string | null } }
).__state;
const mockSetSupportUuid = (
useSettingStore as unknown as { __setSupportUuid: jest.Mock }
).__setSupportUuid;
describe('supportUuid service', () => {
beforeEach(() => {
storeState.supportUuid = null;
jest.clearAllMocks();
});
describe('getSupportUuid', () => {
it('generates and persists a UUID on first call', () => {
const uuid = getSupportUuid();
expect(uuid).toMatch(/^[0-9a-f-]{36}$/);
expect(mockSetSupportUuid).toHaveBeenCalledWith(uuid);
});
it('returns the persisted UUID on subsequent calls', () => {
storeState.supportUuid = '11111111-1111-1111-1111-111111111111';
expect(getSupportUuid()).toBe(storeState.supportUuid);
expect(mockSetSupportUuid).not.toHaveBeenCalled();
});
});
describe('appendSupportUuidToUrl', () => {
beforeEach(() => {
storeState.supportUuid = '22222222-2222-2222-2222-222222222222';
});
it('adds support_uuid to a normal URL', () => {
const result = appendSupportUuidToUrl('https://example.com/help');
expect(result).toBe(
`https://example.com/help?support_uuid=${storeState.supportUuid}`,
);
});
it('merges with existing query params', () => {
const result = appendSupportUuidToUrl('https://example.com/help?x=1');
expect(result).toContain('x=1');
expect(result).toContain(`support_uuid=${storeState.supportUuid}`);
});
it('overwrites an existing support_uuid query param', () => {
const result = appendSupportUuidToUrl(
'https://example.com/help?support_uuid=stale',
);
expect(result).toContain(`support_uuid=${storeState.supportUuid}`);
expect(result).not.toContain('support_uuid=stale');
});
it('preserves fragments when falling back on malformed URLs', () => {
const urlSpy = jest.spyOn(global, 'URL').mockImplementationOnce(() => {
throw new TypeError('invalid');
});
const result = appendSupportUuidToUrl('support?x=1#section');
expect(result).toBe(
`support?x=1&support_uuid=${storeState.supportUuid}#section`,
);
urlSpy.mockRestore();
});
});
describe('initializeSupportUuidContext', () => {
it('wires the UUID into Sentry and analytics', () => {
const uuid = initializeSupportUuidContext();
expect(setSupportUuidInSentry).toHaveBeenCalledWith(uuid);
expect(setAnalyticsSupportUuid).toHaveBeenCalledWith(uuid);
});
});
describe('regenerateSupportUuid', () => {
it('rotates the UUID and propagates to Sentry + analytics', () => {
storeState.supportUuid = 'old-uuid';
const next = regenerateSupportUuid();
expect(next).not.toBe('old-uuid');
expect(mockSetSupportUuid).toHaveBeenCalledWith(next);
expect(setSupportUuidInSentry).toHaveBeenCalledTimes(1);
expect(setSupportUuidInSentry).toHaveBeenCalledWith(next);
expect(resetAnalyticsIdentityForSupportUuid).toHaveBeenCalledWith(next);
});
});
describe('copySupportUuid', () => {
it('copies the current UUID to the clipboard', () => {
storeState.supportUuid = '33333333-3333-3333-3333-333333333333';
const uuid = copySupportUuid();
expect(uuid).toBe(storeState.supportUuid);
expect(Clipboard.setString).toHaveBeenCalledWith(storeState.supportUuid);
});
});
});

View File

@@ -55,7 +55,15 @@ describe('proofHistoryStore', () => {
mockSocket = {
emit: jest.fn(),
on: jest.fn(),
on: jest.fn().mockImplementation(function (this: any) {
return this;
}),
timeout: jest.fn().mockImplementation(function (this: any) {
return this;
}),
disconnect: jest.fn(),
active: false,
connected: false,
};
mockIo.mockReturnValue(mockSocket);
});
@@ -317,6 +325,148 @@ describe('proofHistoryStore', () => {
});
});
describe('syncProofHistoryStatus (via initDatabase)', () => {
const pendingProof = {
id: '1',
sessionId: 'session-abc',
appName: 'TestApp',
endpointType: 'celo',
status: ProofStatus.PENDING,
errorCode: null,
errorReason: null,
timestamp: Date.now(),
disclosures: '{}',
logoBase64: null,
userId: 'u',
userIdType: 'uuid',
};
const getHandler = (event: string) => {
const call = (mockSocket.on as jest.Mock).mock.calls.find(
([name]) => name === event,
);
return call?.[1] as ((...args: any[]) => void) | undefined;
};
let nowSpy: jest.SpyInstance;
let testClock = Date.now();
beforeEach(() => {
testClock += 10 * 60 * 1000;
nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => testClock);
mockDatabase.init.mockResolvedValue(undefined);
mockDatabase.updateStaleProofs.mockResolvedValue(undefined);
mockDatabase.getHistory.mockResolvedValue({ rows: [], total_count: 0 });
mockDatabase.getPendingProofs.mockResolvedValue({ rows: [pendingProof] });
});
afterEach(() => {
nowSpy.mockRestore();
});
it('does not unsubscribe on non-terminal status', async () => {
await act(async () => {
await useProofHistoryStore.getState().initDatabase();
});
const statusHandler = getHandler('status');
expect(statusHandler).toBeDefined();
(mockSocket.emit as jest.Mock).mockClear();
statusHandler!({ status: 1, request_id: 'session-abc' });
expect(mockSocket.emit).not.toHaveBeenCalledWith(
'unsubscribe',
expect.anything(),
);
expect(mockDatabase.updateProofStatus).not.toHaveBeenCalled();
});
it('unsubscribes on terminal status', async () => {
await act(async () => {
await useProofHistoryStore.getState().initDatabase();
});
const statusHandler = getHandler('status');
(mockSocket.emit as jest.Mock).mockClear();
statusHandler!({ status: 4, request_id: 'session-abc' });
expect(mockSocket.emit).toHaveBeenCalledWith(
'unsubscribe',
'session-abc',
);
});
it('ignores terminal status updates for unknown request ids', async () => {
await act(async () => {
await useProofHistoryStore.getState().initDatabase();
});
const statusHandler = getHandler('status');
(mockSocket.emit as jest.Mock).mockClear();
mockDatabase.updateProofStatus.mockClear();
statusHandler!({ status: 4, request_id: 'session-other' });
expect(mockSocket.emit).not.toHaveBeenCalledWith(
'unsubscribe',
expect.anything(),
);
expect(mockDatabase.updateProofStatus).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
'Proof history status message for unknown request_id',
);
});
it('keeps the timeout active across reconnectable disconnects', async () => {
nowSpy.mockRestore();
jest.useFakeTimers();
jest.setSystemTime(testClock);
try {
await act(async () => {
await useProofHistoryStore.getState().initDatabase();
});
const disconnectHandler = getHandler('disconnect');
expect(disconnectHandler).toBeDefined();
mockSocket.active = true;
disconnectHandler!();
await jest.advanceTimersByTimeAsync(30 * 1000 * 4);
expect(mockSocket.disconnect).toHaveBeenCalled();
} finally {
jest.useRealTimers();
nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => testClock);
}
});
it('disconnects after timeout even when never connected', async () => {
nowSpy.mockRestore();
jest.useFakeTimers();
jest.setSystemTime(testClock);
try {
await act(async () => {
await useProofHistoryStore.getState().initDatabase();
});
mockSocket.connected = false;
expect(mockSocket.disconnect).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(30 * 1000 * 4);
expect(mockSocket.disconnect).toHaveBeenCalled();
} finally {
jest.useRealTimers();
nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => testClock);
}
});
});
describe('resetHistory', () => {
it('resets history state to initial values', async () => {
const mockProof = {

View File

@@ -1,10 +1,10 @@
{
"ios": {
"build": 217,
"lastDeployed": "2026-04-14T04:50:15.630Z"
"build": 218,
"lastDeployed": "2026-04-14T21:02:24.000Z"
},
"android": {
"build": 147,
"lastDeployed": "2026-04-14T02:03:03.238Z"
"build": 148,
"lastDeployed": "2026-04-14T21:02:24.000Z"
}
}

View File

@@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.minipay.screens
import android.annotation.SuppressLint
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.serialization.json.Json
import org.json.JSONObject
import xyz.self.minipay.webview.BRIDGE_DEMO_HTML
import xyz.self.minipay.webview.BridgeMethodException
import xyz.self.minipay.webview.ETHEREUM_BRIDGE_CHANNEL
import xyz.self.minipay.webview.ETHEREUM_BRIDGE_STUB
import xyz.self.minipay.webview.MethodRegistry
import xyz.self.minipay.webview.ProviderError
import xyz.self.minipay.webview.ProviderErrorCodes
import xyz.self.minipay.webview.ProviderRequest
import xyz.self.minipay.webview.ProviderResponse
private val json = Json { ignoreUnknownKeys = true }
@Composable
actual fun PlatformWebViewBridge(registry: MethodRegistry) {
AndroidView(
modifier = Modifier,
factory = { context -> createWebView(context = context, registry = registry) },
)
}
@SuppressLint("SetJavaScriptEnabled")
private fun createWebView(
context: Context,
registry: MethodRegistry,
): WebView {
val bridge = AndroidEthereumBridge(registry)
return WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient =
object : WebViewClient() {
override fun onPageFinished(
view: WebView,
url: String?,
) {
view.evaluateJavascript(ETHEREUM_BRIDGE_STUB, null)
}
}
addJavascriptInterface(bridge, ETHEREUM_BRIDGE_CHANNEL)
bridge.attach(this)
loadDataWithBaseURL("https://localhost/", BRIDGE_DEMO_HTML, "text/html", "utf-8", null)
}
}
private class AndroidEthereumBridge(
private val registry: MethodRegistry,
) {
private val pendingRequests = mutableMapOf<String, (ProviderResponse) -> Unit>()
private var webView: WebView? = null
fun attach(webView: WebView) {
this.webView = webView
}
@JavascriptInterface
fun postMessage(requestJson: String) {
val request =
try {
json.decodeFromString<ProviderRequest>(requestJson)
} catch (_: Exception) {
val fallbackId = requestJson.substringAfter("\"id\":\"", "").substringBefore("\"", "")
if (fallbackId.isNotEmpty()) {
respond(
ProviderResponse(
id = fallbackId,
error =
ProviderError(
code = ProviderErrorCodes.INVALID_PARAMS,
message = "Invalid request payload",
),
),
)
}
return
}
pendingRequests[request.id] = { response -> sendResponseToJs(response) }
val response =
try {
registry.dispatch(request)
} catch (exception: BridgeMethodException) {
ProviderResponse(id = request.id, error = exception.providerError)
} catch (exception: Exception) {
ProviderResponse(
id = request.id,
error =
ProviderError(
code = ProviderErrorCodes.INTERNAL_ERROR,
message = exception.message ?: "Internal error",
),
)
}
respond(response)
}
private fun respond(response: ProviderResponse) {
val callback = pendingRequests.remove(response.id) ?: return
callback(response)
}
private fun sendResponseToJs(response: ProviderResponse) {
val payload = json.encodeToString(ProviderResponse.serializer(), response)
val escaped = JSONObject.quote(payload)
webView?.post {
webView?.evaluateJavascript("window.__selfEthereumResolve($escaped);", null)
}
}
}

View File

@@ -11,6 +11,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import xyz.self.minipay.screens.HomeScreen
import xyz.self.minipay.screens.ResultScreen
import xyz.self.minipay.screens.WebViewBridgeScreen
import xyz.self.minipay.theme.MiniPayTheme
import xyz.self.sdk.api.SelfSdk
@@ -31,6 +32,7 @@ fun App(sdk: SelfSdk? = null, platformContext: Any? = null) {
viewModel = viewModel,
onVerify = { viewModel.launchVerification(platformContext) },
onNavigateToResult = { navController.navigate("result") },
onOpenWebViewBridge = { navController.navigate("webview-bridge") },
)
}
@@ -47,6 +49,10 @@ fun App(sdk: SelfSdk? = null, platformContext: Any? = null) {
},
)
}
composable("webview-bridge") {
WebViewBridgeScreen()
}
}
}
}

View File

@@ -31,6 +31,7 @@ fun HomeScreen(
viewModel: MainViewModel,
onVerify: () -> Unit,
onNavigateToResult: () -> Unit,
onOpenWebViewBridge: () -> Unit,
) {
// Navigate to result when screen changes
LaunchedEffect(viewModel.currentScreen) {
@@ -125,6 +126,20 @@ fun HomeScreen(
)
}
Button(
onClick = onOpenWebViewBridge,
modifier =
Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp),
) {
Text(
text = "Open WebView Bridge PoC",
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.minipay.screens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import xyz.self.minipay.webview.BridgeMethodException
import xyz.self.minipay.webview.MethodRegistry
import xyz.self.minipay.webview.ProviderError
import xyz.self.minipay.webview.ProviderErrorCodes
@Composable
fun WebViewBridgeScreen() {
val registry =
remember {
MethodRegistry().apply {
registerMethod("demo_echo") { params ->
Result.success(
buildJsonObject {
put("ok", JsonPrimitive(true))
put("echo", params ?: JsonNull)
},
)
}
registerMethod("demo_reject") {
Result.failure(
BridgeMethodException(
ProviderError(
code = ProviderErrorCodes.INVALID_PARAMS,
message = "demo_reject always fails",
),
),
)
}
}
}
PlatformWebViewBridge(registry = registry)
}
@Composable
expect fun PlatformWebViewBridge(registry: MethodRegistry)

View File

@@ -0,0 +1,239 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.minipay.webview
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
object ProviderErrorCodes {
const val UNKNOWN_METHOD = 4200
const val INVALID_PARAMS = -32602
const val INTERNAL_ERROR = -32603
}
@Serializable
data class ProviderError(
val code: Int,
val message: String,
val data: JsonElement? = null,
)
@Serializable
data class ProviderRequest(
val id: String,
val method: String,
val params: JsonElement? = null,
)
@Serializable
data class ProviderResponse(
val id: String,
val result: JsonElement? = null,
val error: ProviderError? = null,
)
typealias MethodHandler = (params: JsonElement?) -> Result<JsonElement?>
class BridgeMethodException(
val providerError: ProviderError,
) : Exception(providerError.message)
class MethodRegistry {
private val handlers = mutableMapOf<String, MethodHandler>()
fun registerMethod(
name: String,
handler: MethodHandler,
) {
handlers[name] = handler
}
fun dispatch(request: ProviderRequest): ProviderResponse {
val handler =
handlers[request.method]
?: return request.errorResponse(
ProviderError(
code = ProviderErrorCodes.UNKNOWN_METHOD,
message = "Unsupported method: ${request.method}",
),
)
return handler(request.params)
.fold(
onSuccess = { result -> ProviderResponse(id = request.id, result = result) },
onFailure = { throwable ->
val providerError =
if (throwable is BridgeMethodException) {
throwable.providerError
} else {
ProviderError(
code = ProviderErrorCodes.INTERNAL_ERROR,
message = throwable.message ?: "Internal error",
)
}
request.errorResponse(providerError)
},
)
}
private fun ProviderRequest.errorResponse(error: ProviderError): ProviderResponse =
ProviderResponse(id = id, error = error)
}
const val ETHEREUM_BRIDGE_CHANNEL = "SelfEthereumBridge"
const val ETHEREUM_BRIDGE_STUB =
"""
(() => {
if (window.ethereum && window.ethereum.__selfBridgeReady) {
return;
}
const pending = new Map();
let nextRequestId = 1;
const rejectWithProviderError = (reject, error) => {
reject({
code: error?.code ?? -32603,
message: error?.message ?? 'Internal error',
data: error?.data ?? null,
});
};
window.__selfEthereumResolve = (responseJson) => {
let response;
try {
response = JSON.parse(responseJson);
} catch (_error) {
return;
}
const entry = pending.get(response.id);
if (!entry) {
return;
}
pending.delete(response.id);
if (response.error) {
rejectWithProviderError(entry.reject, response.error);
return;
}
entry.resolve(response.result ?? null);
};
const sendToNative = (payload) => {
if (window.webkit?.messageHandlers?.SelfEthereumBridge) {
window.webkit.messageHandlers.SelfEthereumBridge.postMessage(payload);
return;
}
if (window.SelfEthereumBridge?.postMessage) {
window.SelfEthereumBridge.postMessage(payload);
return;
}
throw new Error('Native bridge is unavailable');
};
window.ethereum = {
__selfBridgeReady: true,
request(args) {
return new Promise((resolve, reject) => {
const { method, params } = args || {};
if (!method || typeof method !== 'string') {
rejectWithProviderError(reject, {
code: -32602,
message: 'Invalid params: method must be a string',
});
return;
}
const id = String(nextRequestId++);
pending.set(id, { resolve, reject });
try {
sendToNative(JSON.stringify({ id, method, params: params ?? null }));
} catch (error) {
pending.delete(id);
rejectWithProviderError(reject, {
code: -32603,
message: error?.message || 'Failed to call native bridge',
});
}
});
},
};
})();
"""
const val BRIDGE_DEMO_HTML =
"""
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MiniPay Bridge PoC</title>
</head>
<body style="font-family: sans-serif; padding: 16px;">
<h2>MiniPay Bridge PoC</h2>
<p>Uses <code>window.ethereum.request</code> through native bridge.</p>
<button onclick="runEcho()">demo_echo</button>
<button onclick="runReject()">demo_reject</button>
<button onclick="runUnknown()">foo (unknown)</button>
<button onclick="runConcurrent()">concurrent demo</button>
<pre id="output" style="margin-top: 16px; white-space: pre-wrap;"></pre>
<script>
const output = document.getElementById('output');
const log = (label, value) => {
output.textContent += `${'$'}{label}: ${'$'}{JSON.stringify(value)}\\n`;
};
async function runEcho() {
try {
const result = await window.ethereum.request({
method: 'demo_echo',
params: { from: 'html', time: Date.now() },
});
log('demo_echo resolved', result);
} catch (error) {
log('demo_echo rejected', error);
}
}
async function runReject() {
try {
const result = await window.ethereum.request({ method: 'demo_reject' });
log('demo_reject resolved', result);
} catch (error) {
log('demo_reject rejected', error);
}
}
async function runUnknown() {
try {
const result = await window.ethereum.request({ method: 'foo' });
log('foo resolved', result);
} catch (error) {
log('foo rejected', error);
}
}
async function runConcurrent() {
const [echo, unknown] = await Promise.allSettled([
window.ethereum.request({ method: 'demo_echo', params: { mode: 'parallel' } }),
window.ethereum.request({ method: 'foo' }),
]);
log('concurrent demo_echo', echo);
log('concurrent foo', unknown);
}
</script>
</body>
</html>
"""

View File

@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.minipay.screens
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.interop.UIKitView
import kotlin.native.ref.WeakReference
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.readValue
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import platform.CoreGraphics.CGRectZero
import platform.darwin.NSObject
import platform.WebKit.WKScriptMessage
import platform.WebKit.WKScriptMessageHandlerProtocol
import platform.WebKit.WKUserContentController
import platform.WebKit.WKUserScript
import platform.WebKit.WKUserScriptInjectionTime.WKUserScriptInjectionTimeAtDocumentStart
import platform.WebKit.WKWebView
import platform.WebKit.WKWebViewConfiguration
import xyz.self.minipay.webview.BRIDGE_DEMO_HTML
import xyz.self.minipay.webview.BridgeMethodException
import xyz.self.minipay.webview.ETHEREUM_BRIDGE_CHANNEL
import xyz.self.minipay.webview.ETHEREUM_BRIDGE_STUB
import xyz.self.minipay.webview.MethodRegistry
import xyz.self.minipay.webview.ProviderError
import xyz.self.minipay.webview.ProviderErrorCodes
import xyz.self.minipay.webview.ProviderRequest
import xyz.self.minipay.webview.ProviderResponse
private val json = Json { ignoreUnknownKeys = true }
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun PlatformWebViewBridge(registry: MethodRegistry) {
UIKitView(
modifier = Modifier,
factory = {
val bridge = IosEthereumBridge(registry)
val userContentController = WKUserContentController()
userContentController.addScriptMessageHandler(
WeakMessageHandlerProxy(bridge),
ETHEREUM_BRIDGE_CHANNEL,
)
userContentController.addUserScript(
WKUserScript(
source = ETHEREUM_BRIDGE_STUB,
injectionTime = WKUserScriptInjectionTimeAtDocumentStart,
forMainFrameOnly = true,
),
)
val config = WKWebViewConfiguration()
config.userContentController = userContentController
WKWebView(frame = CGRectZero.readValue(), configuration = config).apply {
bridge.attach(this)
loadHTMLString(BRIDGE_DEMO_HTML, baseURL = null)
}
},
update = {},
)
}
@OptIn(ExperimentalForeignApi::class)
private class IosEthereumBridge(
private val registry: MethodRegistry,
) : NSObject(), WKScriptMessageHandlerProtocol {
private val pendingRequests = mutableMapOf<String, (ProviderResponse) -> Unit>()
private var webView: WKWebView? = null
fun attach(webView: WKWebView) {
this.webView = webView
}
override fun userContentController(
userContentController: WKUserContentController,
didReceiveScriptMessage: WKScriptMessage,
) {
val requestJson = didReceiveScriptMessage.body.toString()
val request =
try {
json.decodeFromString<ProviderRequest>(requestJson)
} catch (_: Exception) {
return
}
pendingRequests[request.id] = { response -> sendResponseToJs(response) }
val response =
try {
registry.dispatch(request)
} catch (exception: BridgeMethodException) {
ProviderResponse(id = request.id, error = exception.providerError)
} catch (exception: Exception) {
ProviderResponse(
id = request.id,
error =
ProviderError(
code = ProviderErrorCodes.INTERNAL_ERROR,
message = exception.message ?: "Internal error",
),
)
}
respond(response)
}
private fun respond(response: ProviderResponse) {
val callback = pendingRequests.remove(response.id) ?: return
callback(response)
}
private fun sendResponseToJs(response: ProviderResponse) {
val responseJson = json.encodeToString(ProviderResponse.serializer(), response)
val jsStringLiteral = json.encodeToString(String.serializer(), responseJson)
val script = "window.__selfEthereumResolve($jsStringLiteral);"
webView?.evaluateJavaScript(script, completionHandler = null)
}
}
@OptIn(ExperimentalForeignApi::class, kotlin.experimental.ExperimentalNativeApi::class)
private class WeakMessageHandlerProxy(
handler: WKScriptMessageHandlerProtocol,
) : NSObject(), WKScriptMessageHandlerProtocol {
private val weakHandler = WeakReference(handler)
override fun userContentController(
userContentController: WKUserContentController,
didReceiveScriptMessage: WKScriptMessage,
) {
weakHandler.get()?.userContentController(userContentController, didReceiveScriptMessage)
}
}

View File

@@ -59,7 +59,7 @@ export function useReadMRZ(scanStartTimeRef: RefObject<number>) {
duration_seconds: parseFloat(scanDurationSeconds),
});
// TODO: Add error handling here
selfClient.emit(SdkEvents.DOCUMENT_MRZ_READ_FAILURE);
return;
}

View File

@@ -712,6 +712,10 @@ export const useProvingStore = create<ProvingState>((set, get) => {
const context = createProofContext(selfClient, '_startSocketIOStatusListener');
selfClient.logProofEvent('info', 'Socket.IO listener started', context, { url });
// Guards against racing the intentional post-terminal-status disconnect
// into a spurious PROVE_ERROR in the disconnect handler.
let terminalStatusHandled = false;
socket.on('connect', () => {
socket?.emit('subscribe', receivedUuid);
selfClient.trackEvent(ProofEvents.SOCKETIO_SUBSCRIBED);
@@ -733,12 +737,18 @@ export const useProvingStore = create<ProvingState>((set, get) => {
socket.on('disconnect', (_reason: string) => {
const currentActor = actor;
const currentState = get().currentState;
selfClient.logProofEvent('warn', 'Socket.IO disconnected', context);
if (get().currentState === 'ready_to_prove' && currentActor) {
console.error('SocketIO disconnected unexpectedly during proof listening.');
if (terminalStatusHandled) {
set({ socketConnection: null });
return;
}
if (currentActor && (currentState === 'ready_to_prove' || currentState === 'listening_for_status')) {
console.error(`SocketIO disconnected unexpectedly during ${currentState}.`);
selfClient.trackEvent(ProofEvents.SOCKETIO_DISCONNECT_UNEXPECTED);
selfClient.logProofEvent('error', 'Socket.IO disconnected unexpectedly', context, {
failure: 'PROOF_FAILED_CONNECTION',
state: currentState,
});
currentActor.send({ type: 'PROVE_ERROR' });
}
@@ -788,6 +798,7 @@ export const useProvingStore = create<ProvingState>((set, get) => {
// Handle disconnection
if (result.shouldDisconnect) {
terminalStatusHandled = true;
socket?.disconnect();
}
} catch (error) {

View File

@@ -215,6 +215,23 @@ describe('useReadMRZ', () => {
});
});
it('routes native scan errors into the MRZ fallback flow', () => {
const { result } = renderHook(() => useReadMRZ(scanStartTimeRef));
const { onPassportRead } = result.current;
onPassportRead(new Error('Camera crashed'));
expect(mockSelfClient.trackEvent).toHaveBeenCalledWith(
PassportEvents.CAMERA_SCAN_FAILED,
expect.objectContaining({
reason: 'unknown_error',
error: 'Camera crashed',
duration_seconds: expect.any(Number),
}),
);
expect(mockSelfClient.emit).toHaveBeenCalledWith(SdkEvents.DOCUMENT_MRZ_READ_FAILURE);
});
it('calculates scan duration correctly', () => {
const { result } = renderHook(() => useReadMRZ(scanStartTimeRef));
const { onPassportRead } = result.current;

View File

@@ -169,4 +169,34 @@ describe('Socket.IO status handler wiring', () => {
expect(finalState.reason).toBe(null);
expect(actorMock.send).toHaveBeenCalledWith({ type: 'PROVE_ERROR' });
});
it('emits PROVE_ERROR when the status listener disconnects while awaiting status', async () => {
useProvingStore.setState({ currentState: 'listening_for_status' } as any);
const store = useProvingStore.getState();
store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient);
await new Promise(resolve => setImmediate(resolve));
(mockSocket as any).emit('disconnect', 'transport close');
expect(actorMock.send).toHaveBeenCalledWith({ type: 'PROVE_ERROR' });
expect(useProvingStore.getState().socketConnection).toBe(null);
});
it('does not emit PROVE_ERROR when disconnect follows a terminal status', async () => {
useProvingStore.setState({ currentState: 'listening_for_status' } as any);
const store = useProvingStore.getState();
store._startSocketIOStatusListener('test-uuid', 'https', mockSelfClient);
await new Promise(resolve => setImmediate(resolve));
(mockSocket as any).emit('status', { status: 4 });
(mockSocket as any).emit('disconnect', 'io client disconnect');
expect(actorMock.send).toHaveBeenCalledWith({ type: 'PROVE_SUCCESS' });
expect(actorMock.send).not.toHaveBeenCalledWith({ type: 'PROVE_ERROR' });
expect(useProvingStore.getState().socketConnection).toBe(null);
});
});

View File

@@ -55,6 +55,7 @@ import { TunnelCountryPickerScreen } from './screens/tunnel/TunnelCountryPickerS
import { TunnelDiscloseScreen } from './screens/tunnel/TunnelDiscloseScreen';
import { TunnelIDTypeScreen } from './screens/tunnel/TunnelIDTypeScreen';
import { TunnelKycFailureScreen } from './screens/tunnel/TunnelKycFailureScreen';
import { TunnelKycPendingScreen } from './screens/tunnel/TunnelKycPendingScreen';
import { TunnelKycSuccessScreen } from './screens/tunnel/TunnelKycSuccessScreen';
import { TunnelKycWrapper } from './screens/tunnel/TunnelKycWrapper';
import { TunnelProofReceiptScreen } from './screens/tunnel/TunnelProofReceiptScreen';
@@ -112,6 +113,7 @@ export const App: React.FC = () => (
<Route path="/coming-soon" element={<ComingSoonScreen />} />
<Route path="/tunnel/tour/:step" element={<TunnelTourScreen />} />
<Route path="/tunnel/kyc" element={<TunnelKycWrapper />} />
{import.meta.env.DEV && <Route path="/tunnel/kyc-pending" element={<TunnelKycPendingScreen />} />}
<Route path="/tunnel/kyc-failure" element={<TunnelKycFailureScreen />} />
<Route path="/tunnel/kyc-success" element={<TunnelKycSuccessScreen />} />
<Route path="/tunnel/registration/country" element={<TunnelCountryPickerScreen />} />

View File

@@ -127,7 +127,7 @@ export const ProviderLaunchScreen: React.FC = () => {
let cancelled = false;
const controller = new AbortController();
if (environment !== 'prod') {
if (environment !== 'prod' && !defaultNextPath.includes('mock=demo')) {
(async () => {
try {
setPhase('waiting');
@@ -306,12 +306,12 @@ export const ProviderLaunchScreen: React.FC = () => {
{phase === 'waiting' && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
overflow: 'hidden',
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
>
<KycPendingScreen

View File

@@ -22,9 +22,15 @@ export const ProviderResultScreen: React.FC = () => {
const state =
(location.state as ({ providerResult?: KycProviderResult } & MockOnboardingNavigationState) | null) ?? null;
const providerResult = state?.providerResult ?? createMockProviderResult({ outcome: mockOutcome });
const providerResult =
state?.providerResult ?? (import.meta.env.DEV ? createMockProviderResult({ outcome: mockOutcome }) : undefined);
useEffect(() => {
if (!providerResult) {
navigate('/', { replace: true });
return;
}
haptic.trigger('selection');
analytics.trackEvent('provider_result_received', {
status: providerResult.status,
@@ -79,8 +85,8 @@ export const ProviderResultScreen: React.FC = () => {
lifecycle,
mockOutcome,
navigate,
providerResult.error?.retryable,
providerResult.status,
providerResult?.error?.retryable,
providerResult?.status,
state?.countryCode,
state?.documentType,
]);

View File

@@ -4,7 +4,7 @@
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import type { ProofGenerationStep } from '@selfxyz/euclid';
import { ProofProgressScreen, SelfLogo } from '@selfxyz/euclid';
@@ -14,8 +14,54 @@ import { useProvingStore } from '@selfxyz/mobile-sdk-alpha/browser';
import { useSelfClient } from '../../providers/SelfClientProvider';
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
import { isDemoMode } from '../../utils/mockOnboardingFlow';
import { initSelfAppFromRequest } from '../../utils/selfAppContext';
const DEMO_DISCLOSE_STEPS: ProofGenerationStep[] = [
'readingRegistry',
'generatingProof',
'awaitingVerification',
'finishingUp',
];
const DemoTunnelDiscloseScreen: React.FC<{ search: string }> = ({ search }) => {
const navigate = useNavigate();
const { appName, appEndpoint, timestamp } = useVerificationRequest();
const [stepIndex, setStepIndex] = useState(0);
const advance = useCallback(() => {
if (stepIndex < DEMO_DISCLOSE_STEPS.length - 1) {
setStepIndex(i => i + 1);
} else {
navigate(`/tunnel/proof/result${search}`, { replace: true, state: { success: true } });
}
}, [stepIndex, navigate, search]);
return (
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
>
<ProofProgressScreen
{...WEB_SAFE_AREA}
appIcon={<SelfLogo size={40} />}
appName={appName}
appEndpoint={appEndpoint}
documentType="passport"
timestamp={timestamp}
step={DEMO_DISCLOSE_STEPS[stepIndex]}
/>
<div onClick={advance} style={{ position: 'absolute', inset: 0, zIndex: 1, cursor: 'pointer' }} />
</div>
);
};
const MAX_DISCLOSE_RETRIES = 3;
const DISCLOSE_RETRY_DELAY_MS = 3000;
const ERROR_STATES: ProvingStateType[] = ['error', 'failure', 'passport_not_supported', 'passport_data_not_found'];
@@ -42,7 +88,7 @@ function mapDiscloseStateToStep(state: ProvingStateType | null): ProofGeneration
}
}
export const TunnelDiscloseScreen: React.FC = () => {
const StandardTunnelDiscloseScreen: React.FC = () => {
const navigate = useNavigate();
const { client, analytics, haptic } = useSelfClient();
const verificationCtx = useVerificationRequest();
@@ -151,3 +197,13 @@ export const TunnelDiscloseScreen: React.FC = () => {
/>
);
};
export const TunnelDiscloseScreen: React.FC = () => {
const location = useLocation();
if (isDemoMode(location.search)) {
return <DemoTunnelDiscloseScreen search={location.search} />;
}
return <StandardTunnelDiscloseScreen />;
};

View File

@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type React from 'react';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { KycPendingScreen } from '@selfxyz/euclid';
import { WEB_SAFE_AREA } from '../../utils/insets';
import { createMockProviderResult, isDemoMode } from '../../utils/mockOnboardingFlow';
export const TunnelKycPendingScreen: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const handleTap = useCallback(() => {
if (!isDemoMode(location.search)) return;
navigate(`/tunnel/kyc-success${location.search}`, {
state: { providerResult: createMockProviderResult({ outcome: 'demo' }) },
});
}, [navigate, location.search]);
return (
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
>
<KycPendingScreen insets={WEB_SAFE_AREA.insets} onCheckBackLater={() => {}} onReceiveLiveUpdates={() => {}} />
<div onClick={handleTap} style={{ position: 'absolute', inset: 0, zIndex: 1, cursor: 'pointer' }} />
</div>
);
};

View File

@@ -11,6 +11,7 @@ import { KycVerificationSuccessScreen } from '@selfxyz/euclid';
import { useSelfClient } from '../../providers/SelfClientProvider';
import type { KycProviderResult } from '../../types/kycProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
import { isDemoMode } from '../../utils/mockOnboardingFlow';
export const TunnelKycSuccessScreen: React.FC = () => {
const navigate = useNavigate();
@@ -20,8 +21,10 @@ export const TunnelKycSuccessScreen: React.FC = () => {
const state = location.state as { providerResult?: KycProviderResult } | null;
const providerResult = state?.providerResult;
const demo = isDemoMode(location.search);
useEffect(() => {
if (!providerResult) return;
if (demo || !providerResult) return;
if (providerResult.status === 'cancel') {
navigate('/tunnel/tour/4', { replace: true });
@@ -36,13 +39,13 @@ export const TunnelKycSuccessScreen: React.FC = () => {
}
return;
}
}, [providerResult, navigate]);
}, [demo, providerResult, navigate]);
const onGenerateProof = useCallback(() => {
haptic.trigger('success');
analytics.trackEvent('tunnel_kyc_success_generate_proof');
navigate('/tunnel/proof/generating');
}, [navigate, haptic, analytics]);
navigate(`/tunnel/proof/generating${location.search}`);
}, [navigate, location.search, haptic, analytics]);
return <KycVerificationSuccessScreen insets={WEB_SAFE_AREA.insets} onGenerateProof={onGenerateProof} />;
};

View File

@@ -5,7 +5,7 @@
import type React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { createMockProviderResult, getMockOutcomeFromSearch } from '../../utils/mockOnboardingFlow';
import { createMockProviderResult, getMockOutcomeFromSearch, isDemoMode } from '../../utils/mockOnboardingFlow';
/**
* Redirects `/tunnel/kyc` to `ProviderLaunchScreen` at `/onboarding/provider`
@@ -19,6 +19,21 @@ export const TunnelKycWrapper: React.FC = () => {
const incomingState = (location.state as Record<string, unknown>) ?? {};
const mockOutcome = getMockOutcomeFromSearch(location.search);
if (isDemoMode(location.search)) {
const pendingPath = `/tunnel/kyc-pending${location.search}`;
return (
<Navigate
to="/onboarding/provider"
replace
state={{
...incomingState,
backPath: pendingPath,
nextPath: pendingPath,
}}
/>
);
}
if (import.meta.env.DEV && location.search.includes('mock=')) {
return (
<Navigate

View File

@@ -4,7 +4,7 @@
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import type { ProofGenerationStep } from '@selfxyz/euclid';
import { ProofGenerationScreen } from '@selfxyz/euclid';
@@ -14,9 +14,41 @@ import { loadSelectedDocument, useProvingStore } from '@selfxyz/mobile-sdk-alpha
import { useSelfClient } from '../../providers/SelfClientProvider';
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
import { isDemoMode } from '../../utils/mockOnboardingFlow';
import { getIdCardProps } from '../../utils/provingUtils';
import { initSelfAppFromRequest } from '../../utils/selfAppContext';
const DEMO_PROVING_STEPS: ProofGenerationStep[] = ['readingRegistry', 'generatingProof'];
const DemoTunnelProvingScreen: React.FC<{ search: string }> = ({ search }) => {
const navigate = useNavigate();
const [stepIndex, setStepIndex] = useState(0);
const advance = useCallback(() => {
if (stepIndex < DEMO_PROVING_STEPS.length - 1) {
setStepIndex(i => i + 1);
} else {
navigate(`/tunnel/proof/disclose${search}`, { replace: true });
}
}, [stepIndex, navigate, search]);
return (
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}
>
<ProofGenerationScreen {...WEB_SAFE_AREA} step={DEMO_PROVING_STEPS[stepIndex]} />
<div onClick={advance} style={{ position: 'absolute', inset: 0, zIndex: 1, cursor: 'pointer' }} />
</div>
);
};
type Phase = 'dsc' | 'register';
const ERROR_STATES: ProvingStateType[] = ['error', 'failure', 'passport_not_supported', 'passport_data_not_found'];
@@ -42,7 +74,7 @@ function mapProvingStateToStep(state: ProvingStateType | null, phase: Phase): Pr
}
}
export const TunnelProvingScreen: React.FC = () => {
const StandardTunnelProvingScreen: React.FC = () => {
const navigate = useNavigate();
const { client, analytics, haptic } = useSelfClient();
const verificationCtx = useVerificationRequest();
@@ -120,3 +152,13 @@ export const TunnelProvingScreen: React.FC = () => {
/>
);
};
export const TunnelProvingScreen: React.FC = () => {
const location = useLocation();
if (isDemoMode(location.search)) {
return <DemoTunnelProvingScreen search={location.search} />;
}
return <StandardTunnelProvingScreen />;
};

View File

@@ -12,6 +12,7 @@ import type { VerificationResult } from '@selfxyz/webview-bridge';
import { useSelfClient } from '../../providers/SelfClientProvider';
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
import { isDemoMode } from '../../utils/mockOnboardingFlow';
interface TunnelResultState {
success?: boolean;
@@ -44,7 +45,22 @@ export const TunnelResultScreen: React.FC = () => {
analytics.trackEvent('tunnel_result_failure', { error });
}, [success, error, analytics]);
const demo = isDemoMode(location.search);
const onContinue = useCallback(async () => {
if (demo) {
const demoResult: VerificationResult = {
success: true,
userId: request.userId,
verificationId,
claims: { resultType: 'proofRequested' },
};
await lifecycle.setResult(demoResult);
analytics.trackEvent('tunnel_result_success');
lifecycle.dismiss();
return;
}
try {
const result: VerificationResult = {
success: true,
@@ -60,7 +76,7 @@ export const TunnelResultScreen: React.FC = () => {
error: err instanceof Error ? err.message : 'Failed to send result',
});
}
}, [request.userId, verificationId, lifecycle, analytics]);
}, [demo, request.userId, verificationId, lifecycle, analytics]);
const onRetry = useCallback(() => {
navigate(getTunnelBackPath(source), { replace: true });

View File

@@ -11,7 +11,7 @@ export interface MockOnboardingNavigationState {
nextPath?: string;
}
export type MockRegistrationOutcome = 'success' | 'kyc-failure' | 'registration-failure' | 'cancel';
export type MockRegistrationOutcome = 'success' | 'kyc-failure' | 'registration-failure' | 'cancel' | 'demo';
export type PromptMockState = 'default' | 'existing-account';
const DEFAULT_OUTCOME: MockRegistrationOutcome = 'success';
@@ -25,6 +25,10 @@ export const createMockProviderResult = ({
outcome: MockRegistrationOutcome;
verificationId?: string;
}): KycProviderResult => {
if (!MOCKS_ENABLED) {
throw new Error('createMockProviderResult must not be called outside dev mode');
}
const resolvedVerificationId = verificationId ?? 'mock-verification';
switch (outcome) {
@@ -71,6 +75,13 @@ export const createMockProviderResult = ({
retryable: true,
},
};
case 'demo':
return {
status: 'success',
verificationId: resolvedVerificationId,
provider: 'mock-provider',
completedAt: new Date().toISOString(),
};
}
};
@@ -86,6 +97,7 @@ export const getMockOutcomeFromSearch = (search: string): MockRegistrationOutcom
case 'kyc-failure':
case 'registration-failure':
case 'cancel':
case 'demo':
return value;
default:
return DEFAULT_OUTCOME;
@@ -116,3 +128,6 @@ export const getPromptMockSearch = (mock: PromptMockState = DEFAULT_PROMPT_MOCK)
export const getProviderPath = (outcome: MockRegistrationOutcome): string =>
`/onboarding/provider${getMockOutcomeSearch(outcome)}`;
export const isDemoMode = (search: string): boolean =>
MOCKS_ENABLED && new URLSearchParams(search).get('mock') === 'demo';

View File

@@ -11255,6 +11255,7 @@ __metadata:
ethers: "npm:^6.16.0"
expo: "npm:~52.0.40"
expo-application: "npm:~6.0.2"
expo-camera: "npm:~16.0.18"
hash.js: "npm:^1.1.7"
hermes-eslint: "npm:^0.33.3"
jest: "npm:^30.3.0"
@@ -24698,6 +24699,23 @@ __metadata:
languageName: node
linkType: hard
"expo-camera@npm:~16.0.18":
version: 16.0.18
resolution: "expo-camera@npm:16.0.18"
dependencies:
invariant: "npm:^2.2.4"
peerDependencies:
expo: "*"
react: "*"
react-native: "*"
react-native-web: "*"
peerDependenciesMeta:
react-native-web:
optional: true
checksum: 10c0/19d6f27af22d6e148b0bd7dc1dc37778b155ce0454515045a07e7dcc2b0474aa3d0d0986684fcbfe16837cffa41b33128d23376454d975b09b592c3dd92fe803
languageName: node
linkType: hard
"expo-constants@npm:~17.0.8":
version: 17.0.8
resolution: "expo-constants@npm:17.0.8"