mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
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:
181
.github/actions/find-ios-simulator/action.yml
vendored
181
.github/actions/find-ios-simulator/action.yml
vendored
@@ -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"
|
||||
|
||||
12
.github/pull_request_template.md
vendored
12
.github/pull_request_template.md
vendored
@@ -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)
|
||||
|
||||
4
.github/workflows/mobile-ci.yml
vendored
4
.github/workflows/mobile-ci.yml
vendored
@@ -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:
|
||||
|
||||
125
.github/workflows/mobile-deploy.yml
vendored
125
.github/workflows/mobile-deploy.yml
vendored
@@ -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')
|
||||
|
||||
210
.github/workflows/mobile-e2e.yml
vendored
210
.github/workflows/mobile-e2e.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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$':
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
79
app/src/components/support/SupportUuidRow.tsx
Normal file
79
app/src/components/support/SupportUuidRow.tsx
Normal 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;
|
||||
@@ -30,6 +30,7 @@ export {
|
||||
logEvent,
|
||||
logNFCEvent,
|
||||
logProofEvent,
|
||||
setSupportUuidInSentry,
|
||||
wrapWithSentry,
|
||||
} from '@/config/sentry';
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
50
app/src/hooks/useHasRealDocument.ts
Normal file
50
app/src/hooks/useHasRealDocument.ts
Normal 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;
|
||||
34
app/src/hooks/useSupportUuid.ts
Normal file
34
app/src/hooks/useSupportUuid.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -44,6 +44,8 @@ export type AccountRoutesParamList = {
|
||||
}
|
||||
| undefined;
|
||||
ProofSettings: undefined;
|
||||
Support: undefined;
|
||||
SecurityAndBackup: undefined;
|
||||
AccountVerifiedSuccess: undefined;
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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<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,
|
||||
},
|
||||
});
|
||||
|
||||
136
app/src/screens/account/settings/SecurityAndBackupScreen.tsx
Normal file
136
app/src/screens/account/settings/SecurityAndBackupScreen.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
107
app/src/screens/account/settings/SupportScreen.tsx
Normal file
107
app/src/screens/account/settings/SupportScreen.tsx
Normal 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;
|
||||
72
app/src/screens/account/settings/settingsMenu.ts
Normal file
72
app/src/screens/account/settings/settingsMenu.ts
Normal 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;
|
||||
};
|
||||
@@ -50,7 +50,7 @@ const GratificationScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleBackPress = () => {
|
||||
navigation.navigate('Points');
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const handleAnimationFinish = useCallback(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
70
app/src/services/supportUuid.ts
Normal file
70
app/src/services/supportUuid.ts
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
8
app/tests/__setup__/expoCameraMock.js
Normal file
8
app/tests/__setup__/expoCameraMock.js
Normal 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()],
|
||||
};
|
||||
113
app/tests/src/hooks/useHasRealDocument.test.ts
Normal file
113
app/tests/src/hooks/useHasRealDocument.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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=');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,11 +104,13 @@ describe('navigation', () => {
|
||||
'RegistrationFallbackMRZ',
|
||||
'RegistrationFallbackNFC',
|
||||
'SaveRecoveryPhrase',
|
||||
'SecurityAndBackup',
|
||||
'Settings',
|
||||
'ShowRecoveryPhrase',
|
||||
'SocialLoginDemo',
|
||||
'Splash',
|
||||
'StarfallPushCode',
|
||||
'Support',
|
||||
'WebView',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
120
app/tests/src/screens/account/settings/settingsMenu.test.ts
Normal file
120
app/tests/src/screens/account/settings/settingsMenu.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
119
app/tests/src/services/logging/lokiTransport.test.ts
Normal file
119
app/tests/src/services/logging/lokiTransport.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
148
app/tests/src/services/supportUuid.test.ts
Normal file
148
app/tests/src/services/supportUuid.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
"""
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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';
|
||||
|
||||
18
yarn.lock
18
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user