diff --git a/.github/actions/find-ios-simulator/action.yml b/.github/actions/find-ios-simulator/action.yml index 8c75f7543..a8a03ef40 100644 --- a/.github/actions/find-ios-simulator/action.yml +++ b/.github/actions/find-ios-simulator/action.yml @@ -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" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1acc07d98..8d503fe22 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,15 +5,3 @@ ## Test plan - ---- - -### Native Consolidation Checklist - - - -- [ ] 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) diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index 473daf031..e7f3f1256 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -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: diff --git a/.github/workflows/mobile-deploy.yml b/.github/workflows/mobile-deploy.yml index 6b17ae02b..db88d8a08 100644 --- a/.github/workflows/mobile-deploy.yml +++ b/.github/workflows/mobile-deploy.yml @@ -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') diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml index 05b17a4a4..aee38e37e 100644 --- a/.github/workflows/mobile-e2e.yml +++ b/.github/workflows/mobile-e2e.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index fbf228a7f..4d14b4a05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index e154940f9..c51292a48 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -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 { diff --git a/app/android/build.gradle b/app/android/build.gradle index 6d7eafc12..a5e284ed1 100644 --- a/app/android/build.gradle +++ b/app/android/build.gradle @@ -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 { diff --git a/app/ios/Podfile b/app/ios/Podfile index fe16e519a..731870335 100755 --- a/app/ios/Podfile +++ b/app/ios/Podfile @@ -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 diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 2a16a6ec6..03f139936 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -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 diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj index 503271669..d88ab8f35 100644 --- a/app/ios/Self.xcodeproj/project.pbxproj +++ b/app/ios/Self.xcodeproj/project.pbxproj @@ -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; diff --git a/app/jest.config.cjs b/app/jest.config.cjs index 637f58d9e..3ebd75154 100644 --- a/app/jest.config.cjs +++ b/app/jest.config.cjs @@ -36,6 +36,7 @@ module.exports = { '^@$': '/src', '^@tests/(.*)$': '/tests/src/$1', '^@tests$': '/tests/src', + '^expo-camera$': '/tests/__setup__/expoCameraMock.js', // Map react-native-svg to app's node_modules for all packages '^react-native-svg$': '/node_modules/react-native-svg', '^@selfxyz/mobile-sdk-alpha$': diff --git a/app/package.json b/app/package.json index 3cbb117af..30d341b90 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/scripts/upload_to_play_store.py b/app/scripts/upload_to_play_store.py index e22185b73..d4ba2a7d4 100644 --- a/app/scripts/upload_to_play_store.py +++ b/app/scripts/upload_to_play_store.py @@ -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!") diff --git a/app/src/components/homescreen/KycIdCard.tsx b/app/src/components/homescreen/KycIdCard.tsx index e27105091..ffa6656d5 100644 --- a/app/src/components/homescreen/KycIdCard.tsx +++ b/app/src/components/homescreen/KycIdCard.tsx @@ -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 = ({ - 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 = ({ 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 = ({ 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 = ({ // 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 ( + + + {/* Header */} + + + + + {docTitle} + + + Verified {countryName} {docTitle} + + + + + + + {/* Attributes Grid */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Footer */} + + + + + + + + ); + } + return ( ) => void; - onError: ( - event: NativeSyntheticEvent<{ - error: string; - errorMessage: string; - stackTrace: string; - }>, - ) => void; - style?: StyleProp; -} - -const QRCodeNativeComponent = Platform.select({ - ios: requireNativeComponent( - 'QRCodeScannerView', - ), - android: requireNativeComponent( - '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 = ({ 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 ( - - ); - } else { - // For Android, wrap the native component inside your RCTFragment to preserve existing functionality. - const Fragment = RCTFragment as React.FC< - React.ComponentProps & NativeQRCodeScannerViewProps - >; - return ( - - } - fragmentComponentName="QRCodeScannerViewManager" - isMounted={isMounted} - style={{ - height: PixelRatio.getPixelSizeForLayoutSize(800), - width: PixelRatio.getPixelSizeForLayoutSize(400), - }} - onError={_onError} - onQRData={_onQRData} - /> - ); + if (!permission?.granted) { + return null; } + + return ( + + + + ); }; + +const styles = StyleSheet.create({ + container: { + width: '110%', + height: '110%', + }, + camera: { + flex: 1, + }, +}); diff --git a/app/src/components/navbar/Points.tsx b/app/src/components/navbar/Points.tsx index d4611a485..e1fb704f7 100644 --- a/app/src/components/navbar/Points.tsx +++ b/app/src/components/navbar/Points.tsx @@ -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); diff --git a/app/src/components/navbar/PointsNavBar.tsx b/app/src/components/navbar/PointsNavBar.tsx index 1e2f3a621..ec3c518a3 100644 --- a/app/src/components/navbar/PointsNavBar.tsx +++ b/app/src/components/navbar/PointsNavBar.tsx @@ -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, diff --git a/app/src/components/support/SupportUuidRow.tsx b/app/src/components/support/SupportUuidRow.tsx new file mode 100644 index 000000000..79f3dbcd2 --- /dev/null +++ b/app/src/components/support/SupportUuidRow.tsx @@ -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 = ({ + 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 ( + + ); + } + + return ( + + + + {diagnosticIdText} + + + + + + ); +}; + +export default SupportUuidRow; diff --git a/app/src/config/index.ts b/app/src/config/index.ts index a23ffb505..9898187eb 100644 --- a/app/src/config/index.ts +++ b/app/src/config/index.ts @@ -30,6 +30,7 @@ export { logEvent, logNFCEvent, logProofEvent, + setSupportUuidInSentry, wrapWithSentry, } from '@/config/sentry'; diff --git a/app/src/config/sentry.ts b/app/src/config/sentry.ts index 15c28d94c..799146919 100644 --- a/app/src/config/sentry.ts +++ b/app/src/config/sentry.ts @@ -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, ) => 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); }; diff --git a/app/src/config/sentry.web.ts b/app/src/config/sentry.web.ts index bf5cb833d..5156a0ef7 100644 --- a/app/src/config/sentry.web.ts +++ b/app/src/config/sentry.web.ts @@ -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, ) => logEvent(level, 'proof', message, context, extra); +export const setSupportUuidInSentry = (_supportUuid: string | null) => {}; + export const wrapWithSentry = (App: React.ComponentType) => { return isSentryDisabled ? App : withProfiler(App); }; diff --git a/app/src/hooks/useHasRealDocument.ts b/app/src/hooks/useHasRealDocument.ts new file mode 100644 index 000000000..1bca3cd2e --- /dev/null +++ b/app/src/hooks/useHasRealDocument.ts @@ -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 } => { + const { loadDocumentCatalog } = usePassport(); + const [hasRealDocument, setHasRealDocument] = useState(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; diff --git a/app/src/hooks/useSupportUuid.ts b/app/src/hooks/useSupportUuid.ts new file mode 100644 index 000000000..9112817e3 --- /dev/null +++ b/app/src/hooks/useSupportUuid.ts @@ -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, + }; +} diff --git a/app/src/integrations/nfc/passportReader.ts b/app/src/integrations/nfc/passportReader.ts index 6d5514232..423953cc0 100644 --- a/app/src/integrations/nfc/passportReader.ts +++ b/app/src/integrations/nfc/passportReader.ts @@ -41,6 +41,8 @@ type AndroidPassportReaderModule = { trackEvent?: (name: string, properties?: Record) => void; flush?: () => void | Promise; reset?: () => void; + resetIdentity?: () => void; + setDistinctId?: (distinctId: string) => void; scan?: (options: ScanOptions) => Promise; }; @@ -48,6 +50,8 @@ type IOSPassportReaderModule = { configure?: (token: string, enableDebug?: boolean) => void; trackEvent?: (name: string, properties?: Record) => void; flush?: () => void | Promise; + resetIdentity?: () => void; + setDistinctId?: (distinctId: string) => void; scanPassport?: ( passportNumber: string, dateOfBirth: string, diff --git a/app/src/navigation/account.ts b/app/src/navigation/account.ts index 151e168ef..58cd04c85 100644 --- a/app/src/navigation/account.ts +++ b/app/src/navigation/account.ts @@ -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: { diff --git a/app/src/navigation/account.web.ts b/app/src/navigation/account.web.ts index 316c6811e..ab6e59def 100644 --- a/app/src/navigation/account.web.ts +++ b/app/src/navigation/account.web.ts @@ -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: { diff --git a/app/src/navigation/app.tsx b/app/src/navigation/app.tsx index 766bed085..95c438c8e 100644 --- a/app/src/navigation/app.tsx +++ b/app/src/navigation/app.tsx @@ -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; diff --git a/app/src/navigation/types.ts b/app/src/navigation/types.ts index ee0f317f8..115be7cb4 100644 --- a/app/src/navigation/types.ts +++ b/app/src/navigation/types.ts @@ -44,6 +44,8 @@ export type AccountRoutesParamList = { } | undefined; ProofSettings: undefined; + Support: undefined; + SecurityAndBackup: undefined; AccountVerifiedSuccess: undefined; }; diff --git a/app/src/providers/authProvider.tsx b/app/src/providers/authProvider.tsx index 7f0faa769..c986e2c85 100644 --- a/app/src/providers/authProvider.tsx +++ b/app/src/providers/authProvider.tsx @@ -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 { 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', diff --git a/app/src/providers/passportDataProvider.tsx b/app/src/providers/passportDataProvider.tsx index 1f58cf521..fa74dffc8 100644 --- a/app/src/providers/passportDataProvider.tsx +++ b/app/src/providers/passportDataProvider.tsx @@ -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 diff --git a/app/src/providers/remoteConfigProvider.tsx b/app/src/providers/remoteConfigProvider.tsx index 5d2f5f8db..e2a622861 100644 --- a/app/src/providers/remoteConfigProvider.tsx +++ b/app/src/providers/remoteConfigProvider.tsx @@ -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); diff --git a/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx b/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx index 3291f3a66..45e293761 100644 --- a/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx +++ b/app/src/screens/account/recovery/DocumentDataNotFoundScreen.tsx @@ -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} > + Continue diff --git a/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx b/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx index 45760c2e1..f2fdd8acd 100644 --- a/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx +++ b/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx @@ -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 = { + invalid_mnemonic: + 'That doesnโ€™t look like a valid recovery phrase. Make sure all 24 words are correct and in the right order.', + restore_failed: + 'We couldnโ€™t restore your account with this phrase. Please double-check and try again.', + not_registered: + 'This recovery phrase doesnโ€™t 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>(); @@ -47,20 +64,31 @@ const RecoverWithPhraseScreen: React.FC = () => { const { trackEvent } = useSelfClient(); const [mnemonic, setMnemonic] = useState(); const [restoring, setRestoring] = useState(false); + const [error, setError] = useState(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 = () => {