chore: update dev with staging 09/06/25 (#1007)

* update CI

* bump iOS version

* update readme

* update mobile-deploy ci

* bump version iOS

* update workflow to use workload identity federation (#933)

* update workflow to use workload identity federation

* add token permissions

* correct provider name

* chore: incrementing android build version for version 2.6.4 [github action]

---------

Co-authored-by: Self GitHub Actions <action@github.com>

* update ci

* update ci

* update ci

* update ci

* update ci

* fix ci

* fix ci

* fix ci

* remove fastlane use for android

* bump iOS build version

* update CI python script

* iterate on CI

* iterate on CI

* iterate on CI

* Dev (#941)

* SDK Go version (#920)

* feat: helper functions and constant for go-sdk

* feat: formatRevealedDataPacked in go

* chore: refactor

* feat: define struct for selfBackendVerifier

* feat: verify function for selfBackendVerifier

* feat(wip): custom hasher

* feat: SelfVerifierBacked in go

* test(wip): scope and userContextHash is failing

* test: zk proof verified

* fix: MockConfigStore getactionId function

* chore: refactor

* chore: remove abi duplicate files

* chore: move configStore to utils

* chore: modified VcAndDiscloseProof struct

* chore: more review changes

* feat: impl DefaultConfig and InMemoryConfigStore

* chore: refactor and export functions

* fix: module import and README

* chore: remove example folder

* chore: remove pointers from VerificationConfig

* chore: coderabbit review fixes

* chore: more coderabbit review fix

* chore: add license

* fix: convert attestationIdd to int

* chore: remove duplicate code

---------

Co-authored-by: ayman <aymanshaik1015@gmail.com>

* Moving proving Utils to common (#935)

* remove react dom

* moves proving utils to the common

* need to use rn components

* fix imports

* add proving-utils and dedeuplicate entry configs for esm and cjs.

* must wrap in text component

* fix metro bundling

* fix mock import

* fix builds and tests

* please save me

* solution?

* fix test

* Move proving inputs to the common package (#937)

* create ofactTree type to share

* move proving inputs from app to register inputs in common

* missed reexport

* ok

* add some validations as suggested by our ai overlords

* Fix mock passport flow (#942)

* fix dev screens

* add hint

* rename

* fix path

* fix mobile-ci path

* fix: extractMRZ (#938)

* fix: extractMRZ

* yarn nice && yarn types

* fix test: remove unused

* fix mobile ci

* add script

---------

Co-authored-by: Justin Hernandez <transphorm@gmail.com>

* Move Proving attest and cose (#950)

* moved attest and cose utils to common

with cursor converted tests in common to use vitest and converted coseVerify.test to vitest after moving from app to common

what does cryptoLoader do?

* moved away

* get buff

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* SELF-253 feat: add user email feedback (#889)

* feat: add sentry feedback

* add sentry feedback to web

* feat: add custom feedback modal & fix freeze on IOS

* yarn nice

* update lock

* feat: show feedback widget on NFC scan issues (#948)

* feat: show feedback widget on NFC scan issues

* fix ref

* clean up

* fix report issue screen

* abstract send user feedback email logic

* fixes

* change text to Report Issue

* sanitize email and track event messge

* remove unnecessary sanitization

* add sanitize error message tests

* fix tests

* save wip. almost done

* fix screen test

* fix screen test

* remove non working test

---------

Co-authored-by: Justin Hernandez <transphorm@gmail.com>
Co-authored-by: Justin Hernandez <justin.hernandez@self.xyz>

* chore: centralize license header checks (#952)

* chore: centralize license header scripts

* chore: run license header checks from root

* add header to other files

* add header to bundle

* add migration script and update check license headers

* convert license to mobile sdk

* migrate license headers

* remove headers from common; convert remaining

* fix headers

* add license header checks

* update unsupported passport screen (#953)

* update unsupported passport screen

* yarn nice

---------

Co-authored-by: Vishalkulkarni45 <109329073+Vishalkulkarni45@users.noreply.github.com>
Co-authored-by: ayman <aymanshaik1015@gmail.com>
Co-authored-by: Aaron DeRuvo <aaron.deruvo@clabs.co>
Co-authored-by: Justin Hernandez <justin.hernandez@self.xyz>
Co-authored-by: Seshanth.S🐺 <35675963+seshanthS@users.noreply.github.com>
Co-authored-by: Justin Hernandez <transphorm@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* bump version

* bump yarn.lock

* update ci (#966)

* chore: Manually bump and release v2.6.4 (#961)

* update lock files

* bump and build android

* update build artifacts

* show generate mock document button

* update lock

* fix formatting and update failing e2e test

* revert podfile

* fixes

* fix cold start of the app with deeplink

* update ci

* update ci

* Sync MARKETING_VERSION to iOS project files after version bump

* chore: incrementing android build version for version 2.6.4 [github action] (#976)

Co-authored-by: remicolin <98749896+remicolin@users.noreply.github.com>

* chore: add build dependencies step for iOS and Android in mobile deploy workflow

* chore: enhance mobile deploy workflow by adding CMake installation step

* bump android build version

* chore: incrementing android build version for version 2.6.4 [github action] (#985)

Co-authored-by: remicolin <98749896+remicolin@users.noreply.github.com>

* chore: configure Metro bundler for production compatibility in mobile deploy workflow

* chore: incrementing android build version for version 2.6.4 [github action] (#987)

Co-authored-by: remicolin <98749896+remicolin@users.noreply.github.com>

* Revert "chore: configure Metro bundler for production compatibility in mobile deploy workflow"

This reverts commit 60fc1f2580.

* reduce max old space size in mobile-deploy ci

* fix android french id card (#957)

* fix android french id card

* fix common ci cache

* feat: log apdu (#988)

---------

Co-authored-by: Justin Hernandez <transphorm@gmail.com>
Co-authored-by: Seshanth.S🐺 <35675963+seshanthS@users.noreply.github.com>

* unblock ci

* fix merge

* merge fixes

* fix tests

* make ci happy

---------

Co-authored-by: turnoffthiscomputer <colin.remi07@gmail.com>
Co-authored-by: pputman-clabs <99900942+pputman-clabs@users.noreply.github.com>
Co-authored-by: Self GitHub Actions <action@github.com>
Co-authored-by: turnoffthiscomputer <98749896+remicolin@users.noreply.github.com>
Co-authored-by: Vishalkulkarni45 <109329073+Vishalkulkarni45@users.noreply.github.com>
Co-authored-by: ayman <aymanshaik1015@gmail.com>
Co-authored-by: Aaron DeRuvo <aaron.deruvo@clabs.co>
Co-authored-by: Seshanth.S🐺 <35675963+seshanthS@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Justin Hernandez
2025-09-07 11:19:59 -07:00
committed by GitHub
parent ec93ad564a
commit b6d526e5f8
31 changed files with 1040 additions and 232 deletions

View File

@@ -15,7 +15,7 @@ reviews:
auto_review:
enabled: true
drafts: false
base_branches: ["main", "dev"]
base_branches: ["main", "dev", "staging"]
tools:
github-checks:
timeout_ms: 300000

View File

@@ -1,13 +1,9 @@
name: Circuits Build
on:
push:
branches:
- main
paths:
- "circuits/circuits/**"
- ".github/workflows/artifacts.yml"
pull_request:
branches:
- dev
- staging
- main
paths:
- "circuits/circuits/**"

View File

@@ -1,21 +1,12 @@
name: Circuits CI
on:
push:
branches:
- dev
- main
- openpassportv2
paths:
- "circuits/**"
- "common/**"
pull_request:
branches:
- dev
- staging
- main
- openpassportv2
paths:
- "circuits/**"
- "common/**"
jobs:
run_circuit_tests:
if: github.event.pull_request.draft == false

View File

@@ -1,15 +1,9 @@
name: Contracts CI
on:
push:
branches:
- dev
- main
paths:
- "contracts/**"
- "common/**"
pull_request:
branches:
- dev
- staging
- main
paths:
- "contracts/**"

View File

@@ -16,7 +16,11 @@ env:
GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=4 -Dorg.gradle.parallel=true -Dorg.gradle.configureondemand=true -Dorg.gradle.caching=true
CI: true
on:
push:
pull_request:
branches:
- dev
- staging
- main
paths:
- "common/**"
- "app/**"
@@ -98,7 +102,7 @@ jobs:
working-directory: ./
test:
runs-on: macos-latest
runs-on: macos-latest-large
needs: build-deps
steps:
- uses: actions/checkout@v4

View File

@@ -30,6 +30,7 @@ env:
permissions:
contents: write
pull-requests: write
id-token: write
on:
workflow_dispatch:
@@ -106,6 +107,9 @@ jobs:
echo "📦 Version bump: ${{ inputs.version_bump }}"
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: staging
- name: Read and sanitize Node.js version
shell: bash
run: |
@@ -120,6 +124,24 @@ jobs:
echo "NODE_VERSION=$VERSION" >> "$GITHUB_ENV"
echo "NODE_VERSION_SANITIZED=${VERSION//\//-}" >> "$GITHUB_ENV"
- name: Verify branch and commit (iOS)
if: inputs.platform != 'android'
run: |
echo "🔍 Verifying we're building from the correct branch and commit..."
echo "Current branch: $(git branch --show-current || git symbolic-ref --short HEAD 2>/dev/null || echo 'detached')"
echo "Current commit: $(git rev-parse HEAD)"
echo "Current commit message: $(git log -1 --pretty=format:'%s')"
echo "Staging HEAD commit: $(git rev-parse origin/staging)"
echo "Staging HEAD message: $(git log -1 --pretty=format:'%s' origin/staging)"
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/staging)" ]; then
echo "⚠️ WARNING: Current commit differs from latest staging commit"
echo "This might indicate we're not building from the latest staging branch"
git log --oneline HEAD..origin/staging || true
else
echo "✅ Building from latest staging commit"
fi
- name: Set up Xcode
if: inputs.platform != 'android'
uses: maxim-lobanov/setup-xcode@v1
@@ -148,8 +170,9 @@ jobs:
.yarn/cache
node_modules
${{ env.APP_PATH }}/node_modules
key: ${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}-${{ hashFiles('yarn.lock') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}-${{ hashFiles('yarn.lock') }}-
${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}-
${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ env.GH_CACHE_VERSION }}-
@@ -160,6 +183,7 @@ jobs:
path: ${{ env.APP_PATH }}/ios/vendor/bundle
key: ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-${{ hashFiles('app/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-${{ hashFiles('app/Gemfile.lock') }}-
${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-
${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-
@@ -171,7 +195,7 @@ jobs:
${{ env.APP_PATH }}/ios/Pods
~/Library/Caches/CocoaPods
lock-file: app/ios/Podfile.lock
cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.GH_PODS_CACHE_VERSION }}
cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.GH_PODS_CACHE_VERSION }}-${{ github.sha }}
- name: Log cache status
run: |
@@ -415,6 +439,14 @@ jobs:
echo "✅ Provisioning profile installation steps completed."
- name: Build Dependencies (iOS)
if: inputs.platform != 'android'
run: |
echo "🏗️ Building SDK dependencies..."
cd ${{ env.APP_PATH }}
yarn workspace @selfxyz/mobile-app run build:deps --silent || { echo "❌ Dependency build failed"; exit 1; }
echo "✅ Dependencies built successfully"
# act won't work with macos, but you can test with `bundle exec fastlane ios ...`
- name: Build and upload to App Store Connect/TestFlight
if: inputs.platform != 'android' && !env.ACT
@@ -524,12 +556,17 @@ jobs:
with:
app_path: ${{ env.APP_PATH }}
- name: Commit updated build number
- name: Open PR for iOS build number bump
if: ${{ !env.ACT && success() }}
uses: ./.github/actions/push-changes
uses: peter-evans/create-pull-request@v6
with:
commit_message: "incrementing ios build number for version ${{ env.VERSION }}"
commit_paths: "./app/version.json"
title: "chore: bump iOS build for ${{ env.VERSION }}"
body: "Automated bump of iOS build number by CI"
commit-message: "chore: incrementing ios build number for version ${{ env.VERSION }} [github action]"
branch: ci/bump-ios-build-${{ github.run_id }}
base: staging
add-paths: |
app/version.json
- name: Monitor cache usage
if: always()
@@ -568,6 +605,71 @@ jobs:
- uses: actions/checkout@v4
if: inputs.platform != 'ios'
with:
fetch-depth: 0
ref: staging
- uses: 'google-github-actions/auth@v2'
with:
project_id: "plucky-tempo-454713-r0"
workload_identity_provider: "projects/852920390127/locations/global/workloadIdentityPools/gh-self/providers/github-by-repos"
service_account: "self-xyz@plucky-tempo-454713-r0.iam.gserviceaccount.com"
# Fail fast: set up JDK for keytool and verify Android secrets early
- name: Setup Java environment
if: inputs.platform != 'ios'
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Decode Android Secrets
if: inputs.platform != 'ios'
run: |
echo "${{ secrets.ANDROID_KEYSTORE }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}
- name: Verify Android Secrets
if: inputs.platform != 'ios'
run: |
# Verify Google Cloud auth via Workload Identity Federation (ADC)
if [ -z "$GOOGLE_APPLICATION_CREDENTIALS" ] || [ ! -f "$GOOGLE_APPLICATION_CREDENTIALS" ]; then
echo "❌ Error: GOOGLE_APPLICATION_CREDENTIALS not set or file missing. Ensure google-github-actions/auth ran."
exit 1
fi
# Verify keystore file exists and is valid
if [ ! -f "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" ]; then
echo "❌ Error: Keystore file was not created successfully"
exit 1
fi
# Try to verify the keystore with the provided password
if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >/dev/null 2>&1; then
echo "❌ Error: Invalid keystore password"
exit 1
fi
# Verify the key alias exists
if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" -alias "${{ secrets.ANDROID_KEY_ALIAS }}" >/dev/null 2>&1; then
echo "❌ Error: Key alias '${{ secrets.ANDROID_KEY_ALIAS }}' not found in keystore"
exit 1
fi
# Verify the key password
if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" -alias "${{ secrets.ANDROID_KEY_ALIAS }}" -keypass "${{ secrets.ANDROID_KEY_PASSWORD }}" >/dev/null 2>&1; then
echo "❌ Error: Invalid key password"
exit 1
fi
# Detect keystore type and export for later steps
KEYSTORE_TYPE=$(keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" 2>/dev/null | awk -F': ' '/Keystore type:/ {print $2; exit}')
if [ -z "$KEYSTORE_TYPE" ]; then
echo "❌ Error: Unable to determine keystore type"
exit 1
fi
echo "ANDROID_KEYSTORE_TYPE=$KEYSTORE_TYPE" >> "$GITHUB_ENV"
echo "Detected keystore type: $KEYSTORE_TYPE"
# Ensure the alias holds a PrivateKeyEntry (required for signing)
if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" -alias "${{ secrets.ANDROID_KEY_ALIAS }}" -keypass "${{ secrets.ANDROID_KEY_PASSWORD }}" | grep -q "Entry type: PrivateKeyEntry"; then
echo "❌ Error: Alias '${{ secrets.ANDROID_KEY_ALIAS }}' is not a PrivateKeyEntry"
exit 1
fi
echo "✅ All Android secrets verified successfully!"
- name: Read and sanitize Node.js version
shell: bash
run: |
@@ -582,6 +684,24 @@ jobs:
echo "NODE_VERSION=$VERSION" >> "$GITHUB_ENV"
echo "NODE_VERSION_SANITIZED=${VERSION//\//-}" >> "$GITHUB_ENV"
- name: Verify branch and commit (Android)
if: inputs.platform != 'ios'
run: |
echo "🔍 Verifying we're building from the correct branch and commit..."
echo "Current branch: $(git branch --show-current || git symbolic-ref --short HEAD 2>/dev/null || echo 'detached')"
echo "Current commit: $(git rev-parse HEAD)"
echo "Current commit message: $(git log -1 --pretty=format:'%s')"
echo "Staging HEAD commit: $(git rev-parse origin/staging)"
echo "Staging HEAD message: $(git log -1 --pretty=format:'%s' origin/staging)"
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/staging)" ]; then
echo "⚠️ WARNING: Current commit differs from latest staging commit"
echo "This might indicate we're not building from the latest staging branch"
git log --oneline HEAD..origin/staging || true
else
echo "✅ Building from latest staging commit"
fi
- name: Cache Yarn dependencies
id: yarn-cache
uses: ./.github/actions/cache-yarn
@@ -590,8 +710,9 @@ jobs:
.yarn/cache
node_modules
${{ env.APP_PATH }}/node_modules
key: ${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}-${{ hashFiles('yarn.lock') }}
key: ${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}-${{ hashFiles('yarn.lock') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}-${{ hashFiles('yarn.lock') }}-
${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ env.GH_CACHE_VERSION }}-${{ env.GH_YARN_CACHE_VERSION }}-
${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ env.GH_CACHE_VERSION }}-
@@ -602,6 +723,7 @@ jobs:
path: ${{ env.APP_PATH }}/ios/vendor/bundle
key: ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-${{ hashFiles('app/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-${{ hashFiles('app/Gemfile.lock') }}-
${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-${{ env.GH_GEMS_CACHE_VERSION }}-
${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ env.GH_CACHE_VERSION }}-
@@ -609,14 +731,14 @@ jobs:
id: gradle-cache
uses: ./.github/actions/cache-gradle
with:
cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.GH_GRADLE_CACHE_VERSION }}
cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.GH_GRADLE_CACHE_VERSION }}-${{ github.sha }}
- name: Cache Android NDK
id: ndk-cache
uses: actions/cache@v4
with:
path: ${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.ANDROID_NDK_VERSION }}
key: ${{ runner.os }}-ndk-${{ env.ANDROID_NDK_VERSION }}
key: ${{ runner.os }}-ndk-${{ env.ANDROID_NDK_VERSION }}-${{ github.sha }}
- name: Log cache status
run: |
@@ -656,12 +778,6 @@ jobs:
workspace: ${{ env.WORKSPACE }}
# android specific steps
- name: Setup Java environment
if: inputs.platform != 'ios'
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Setup Android SDK
if: inputs.platform != 'ios'
@@ -669,16 +785,18 @@ jobs:
with:
accept-android-sdk-licenses: true
- name: Install NDK
- name: Install NDK and CMake
if: inputs.platform != 'ios' && steps.ndk-cache.outputs.cache-hit != 'true'
run: |
max_attempts=5
attempt=1
# Install NDK
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt of $max_attempts to install NDK..."
if sdkmanager "ndk;${{ env.ANDROID_NDK_VERSION }}"; then
echo "Successfully installed NDK"
exit 0
break
fi
echo "Failed to install NDK on attempt $attempt"
if [ $attempt -eq $max_attempts ]; then
@@ -692,54 +810,47 @@ jobs:
attempt=$((attempt + 1))
done
# Install CMake (required for native module builds)
echo "Installing CMake..."
attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt of $max_attempts to install CMake..."
if sdkmanager "cmake;3.22.1"; then
echo "Successfully installed CMake"
break
fi
echo "Failed to install CMake on attempt $attempt"
if [ $attempt -eq $max_attempts ]; then
echo "All attempts to install CMake failed"
exit 1
fi
# Exponential backoff: 2^attempt seconds
wait_time=$((2 ** attempt))
echo "Waiting $wait_time seconds before retrying..."
sleep $wait_time
attempt=$((attempt + 1))
done
- name: Set Gradle JVM options
if: inputs.platform != 'ios' && env.ACT
if: inputs.platform != 'ios' # Apply to CI builds (not just ACT)
run: |
echo "org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8" >> ${{ env.APP_PATH }}/android/gradle.properties
echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8" >> ${{ env.APP_PATH }}/android/gradle.properties
- name: Decode Android Secrets
- name: Install Python dependencies for Play Store upload
if: inputs.platform != 'ios'
run: |
echo "${{ secrets.ANDROID_KEYSTORE }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}
echo "${{ secrets.ANDROID_PLAY_STORE_JSON_KEY_BASE64 }}" | base64 --decode > ${{ env.APP_PATH }}${{ env.ANDROID_PLAY_STORE_JSON_KEY_PATH }}
python -m pip install --upgrade pip
pip install google-auth google-auth-oauthlib google-auth-httplib2 google-api-python-client
# run secrets check after keytool has been setup
- name: Verify Android Secrets
- name: Build Dependencies (Android)
if: inputs.platform != 'ios'
run: |
# Verify Play Store JSON key base64 secret exists and is valid
if [ -z "${{ secrets.ANDROID_PLAY_STORE_JSON_KEY_BASE64 }}" ]; then
echo "❌ Error: Play Store JSON key base64 secret cannot be empty"
exit 1
fi
# Verify the base64 can be decoded
if ! echo "${{ secrets.ANDROID_PLAY_STORE_JSON_KEY_BASE64 }}" | base64 --decode >/dev/null 2>&1; then
echo "❌ Error: Invalid Play Store JSON key base64 format"
exit 1
fi
# Verify keystore file exists and is valid
if [ ! -f "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" ]; then
echo "❌ Error: Keystore file was not created successfully"
exit 1
fi
# Try to verify the keystore with the provided password
if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >/dev/null 2>&1; then
echo "❌ Error: Invalid keystore password"
exit 1
fi
# Verify the key alias exists
if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" -alias "${{ secrets.ANDROID_KEY_ALIAS }}" >/dev/null 2>&1; then
echo "❌ Error: Key alias '${{ secrets.ANDROID_KEY_ALIAS }}' not found in keystore"
exit 1
fi
# Verify the key password
if ! keytool -list -v -keystore "${{ env.APP_PATH }}${{ env.ANDROID_KEYSTORE_PATH }}" -storepass "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" -alias "${{ secrets.ANDROID_KEY_ALIAS }}" -keypass "${{ secrets.ANDROID_KEY_PASSWORD }}" >/dev/null 2>&1; then
echo "❌ Error: Invalid key password"
exit 1
fi
echo "✅ All Android secrets verified successfully!"
echo "🏗️ Building SDK dependencies..."
cd ${{ env.APP_PATH }}
yarn workspace @selfxyz/mobile-app run build:deps --silent || { echo "❌ Dependency build failed"; exit 1; }
echo "✅ Dependencies built successfully"
- name: Build and upload to Google Play Internal Testing
- name: Build AAB with Fastlane
if: inputs.platform != 'ios'
env:
ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
@@ -748,11 +859,7 @@ jobs:
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_PACKAGE_NAME: ${{ secrets.ANDROID_PACKAGE_NAME }}
ANDROID_PLAY_STORE_JSON_KEY_PATH: ${{ env.APP_PATH }}${{ env.ANDROID_PLAY_STORE_JSON_KEY_PATH }}
NODE_OPTIONS: "--max-old-space-size=8192"
SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }}
SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
SLACK_ANNOUNCE_CHANNEL_NAME: ${{ secrets.SLACK_ANNOUNCE_CHANNEL_NAME }}
NODE_OPTIONS: "--max-old-space-size=6144"
run: |
cd ${{ env.APP_PATH }}
@@ -761,25 +868,34 @@ jobs:
VERSION_BUMP="${{ inputs.version_bump || 'build' }}"
TEST_MODE="${{ inputs.test_mode || false }}"
echo "🤖 Deployment Configuration:"
echo "🤖 Build Configuration:"
echo " - Track: $DEPLOYMENT_TRACK"
echo " - Version Bump: $VERSION_BUMP"
echo " - Test Mode: $TEST_MODE"
if [ "$TEST_MODE" = "true" ]; then
echo "🧪 Running in TEST MODE - will skip upload to Play Store"
bundle exec fastlane android deploy_auto \
deployment_track:$DEPLOYMENT_TRACK \
version_bump:$VERSION_BUMP \
test_mode:true \
--verbose
else
echo "🚀 Deploying to Google Play Store..."
bundle exec fastlane android deploy_auto \
deployment_track:$DEPLOYMENT_TRACK \
version_bump:$VERSION_BUMP \
--verbose
fi
echo "🔨 Building AAB with Fastlane..."
bundle exec fastlane android build_only \
deployment_track:$DEPLOYMENT_TRACK \
version_bump:$VERSION_BUMP \
--verbose
- name: Upload to Google Play Store using WIF
if: inputs.platform != 'ios' && inputs.test_mode != true
env:
SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }}
SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
SLACK_ANNOUNCE_CHANNEL_NAME: ${{ secrets.SLACK_ANNOUNCE_CHANNEL_NAME }}
run: |
cd ${{ env.APP_PATH }}
# Determine deployment track
DEPLOYMENT_TRACK="${{ inputs.deployment_track || 'internal' }}"
echo "🚀 Uploading to Google Play Store using Workload Identity Federation..."
python scripts/upload_to_play_store.py \
--aab "android/app/build/outputs/bundle/release/app-release.aab" \
--package-name "${{ secrets.ANDROID_PACKAGE_NAME }}" \
--track "$DEPLOYMENT_TRACK"
# Version updates moved to separate job to avoid race conditions
@@ -789,12 +905,17 @@ jobs:
with:
app_path: ${{ env.APP_PATH }}
- name: Commit updated build version
- name: Open PR for Android build number bump
if: ${{ !env.ACT && success() }}
uses: ./.github/actions/push-changes
uses: peter-evans/create-pull-request@v6
with:
commit_message: "incrementing android build version for version ${{ env.VERSION }}"
commit_paths: "./app/version.json"
title: "chore: bump Android build for ${{ env.VERSION }}"
body: "Automated bump of Android build number by CI"
commit-message: "chore: incrementing android build version for version ${{ env.VERSION }} [github action]"
branch: ci/bump-android-build-${{ github.run_id }}
base: staging
add-paths: |
app/version.json
- name: Monitor cache usage
if: always()
@@ -841,6 +962,7 @@ jobs:
with:
token: ${{ github.token }}
fetch-depth: 0
ref: staging
- name: Read and sanitize Node.js version
shell: bash
run: |
@@ -886,37 +1008,20 @@ jobs:
echo " Version already up to date or no version field in version.json"
fi
- name: Commit and push version files
run: |
cd ${{ github.workspace }}
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Check if there are any changes to commit
if git diff --quiet app/version.json app/package.json yarn.lock 2>/dev/null; then
echo "No changes to version files, skipping commit"
else
# Stage the changes
git add app/version.json app/package.json yarn.lock 2>/dev/null || true
# Create commit message based on which platforms were deployed
COMMIT_MSG="chore: update version files after"
if [ "${{ needs.build-ios.result }}" = "success" ] && [ "${{ needs.build-android.result }}" = "success" ]; then
COMMIT_MSG="$COMMIT_MSG iOS and Android deployment"
elif [ "${{ needs.build-ios.result }}" = "success" ]; then
COMMIT_MSG="$COMMIT_MSG iOS deployment"
else
COMMIT_MSG="$COMMIT_MSG Android deployment"
fi
COMMIT_MSG="$COMMIT_MSG [skip ci]"
# Commit and push
git commit -m "$COMMIT_MSG"
git push
echo "✅ Committed version file changes"
fi
- name: Open PR to update version files
uses: peter-evans/create-pull-request@v6
with:
title: "chore: update version files after deployment"
body: |
Automated update of version files after successful deployment.
Includes updates to `app/version.json`, `app/package.json`, and `yarn.lock`.
commit-message: "chore: update version files after deployment [skip ci]"
branch: ci/update-version-${{ github.run_id }}
base: staging
add-paths: |
app/version.json
app/package.json
yarn.lock
# Create git tags after successful deployment
create-release-tags:
@@ -931,6 +1036,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: staging
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git

View File

@@ -17,12 +17,11 @@ env:
MAESTRO_VERSION: 1.41.0
on:
push:
branches: [main, release/**]
paths:
- "app/**"
- ".github/workflows/mobile-e2e.yml"
pull_request:
branches:
- dev
- staging
- main
paths:
- "app/**"
- ".github/workflows/mobile-e2e.yml"

View File

@@ -10,16 +10,15 @@ env:
on:
pull_request:
branches:
- dev
- staging
- main
paths:
- "sdk/qrcode/**"
- "common/**"
- ".github/workflows/qrcode-sdk-ci.yml"
- ".github/actions/**"
push:
branches: [main, develop]
paths:
- "sdk/qrcode/**"
- "common/**"
jobs:
# Build dependencies once and cache for other jobs

View File

@@ -1,7 +1,11 @@
name: Web CI
on:
push:
pull_request:
branches:
- dev
- staging
- main
paths:
- "app/**"
- ".github/workflows/web.yml"

View File

@@ -84,7 +84,9 @@ These guides provide comprehensive context for AI-assisted development with Chat
## Contributing
We are actively looking for contributors. Please check the [open issues](https://github.com/selfxyz/self/issues) if you don't know were to start! We offer bounties for significant contributions.
We are actively looking for contributors. Please check the [open issues](https://github.com/selfxyz/self/issues) if you don't know where to start! We offer bounties for significant contributions.
> **Important:** Please open your pull request from the `staging` branch. Pull requests from other branches will be automatically closed.
## Contact us

View File

@@ -25,7 +25,7 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1154.0)
aws-partitions (1.1155.0)
aws-sdk-core (3.232.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@@ -315,4 +315,4 @@ RUBY VERSION
ruby 3.2.7p253
BUNDLED WITH
2.4.19
2.6.9

View File

@@ -121,7 +121,7 @@ android {
applicationId "com.proofofpassportapp"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 85
versionCode 90
versionName "2.6.4"
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
externalNativeBuild {

View File

@@ -0,0 +1,114 @@
package io.tradle.nfc
import net.sf.scuba.smartcards.APDUEvent
import net.sf.scuba.smartcards.APDUListener
import net.sf.scuba.smartcards.CommandAPDU
import net.sf.scuba.smartcards.ResponseAPDU
import org.jmrtd.WrappedAPDUEvent
import android.util.Log
class APDULogger : APDUListener {
private var moduleReference: RNPassportReaderModule? = null
private val sessionContext = mutableMapOf<String, Any>()
fun setModuleReference(module: RNPassportReaderModule) {
moduleReference = module
}
fun setContext(key: String, value: Any) {
sessionContext[key] = value
}
fun clearContext() {
sessionContext.clear()
}
override fun exchangedAPDU(event: APDUEvent) {
try {
val entry = createLogEntry(event)
logToAnalytics(entry)
} catch (e: Exception) {
Log.e("APDULogger", "Error exchanging APDU", e)
}
}
private fun createLogEntry(event: APDUEvent): APDULogEntry {
val command = event.commandAPDU
val response = event.responseAPDU
val timestamp = System.currentTimeMillis()
val entry = APDULogEntry(
timestamp = timestamp,
commandHex = command.bytes.toHexString(),
responseHex = response.bytes.toHexString(),
statusWord = response.sw,
statusWordHex = "0x${response.sw.toString(16).uppercase().padStart(4, '0')}",
commandLength = command.bytes.size,
responseLength = response.bytes.size,
dataLength = response.data.size,
isWrapped = event is WrappedAPDUEvent,
plainCommandHex = if (event is WrappedAPDUEvent) event.plainTextCommandAPDU.bytes.toHexString() else null,
plainResponseHex = if (event is WrappedAPDUEvent) event.plainTextResponseAPDU.bytes.toHexString() else null,
plainCommandLength = if (event is WrappedAPDUEvent) event.plainTextCommandAPDU.bytes.size else null,
plainResponseLength = if (event is WrappedAPDUEvent) event.plainTextResponseAPDU.bytes.size else null,
plainDataLength = if (event is WrappedAPDUEvent) event.plainTextResponseAPDU.data.size else null,
context = sessionContext.toMap()
)
return entry
}
private fun ByteArray.toHexString(): String {
return joinToString("") { "%02X".format(it) }
}
private fun logToAnalytics(entry: APDULogEntry) {
try {
val params = mutableMapOf<String, Any>().apply {
put("timestamp", entry.timestamp)
put("command_hex", entry.commandHex)
put("response_hex", entry.responseHex)
put("status_word", entry.statusWord)
put("status_word_hex", entry.statusWordHex)
put("command_length", entry.commandLength)
put("response_length", entry.responseLength)
put("data_length", entry.dataLength)
put("is_wrapped", entry.isWrapped)
put("context", entry.context)
entry.plainCommandHex?.let { put("plain_command_hex", it) }
entry.plainResponseHex?.let { put("plain_response_hex", it) }
entry.plainCommandLength?.let { put("plain_command_length", it) }
entry.plainResponseLength?.let { put("plain_response_length", it) }
entry.plainDataLength?.let { put("plain_data_length", it) }
}
moduleReference?.logAnalyticsEvent("nfc_apdu_exchange", params)
} catch (e: Exception) {
Log.e("APDULogger", "Error logging to analytics", e)
}
}
}
data class APDULogEntry(
val timestamp: Long,
val commandHex: String,
val responseHex: String,
val statusWord: Int,
val statusWordHex: String,
val commandLength: Int,
val responseLength: Int,
val dataLength: Int,
val isWrapped: Boolean,
val plainCommandHex: String?,
val plainResponseHex: String?,
val plainCommandLength: Int?,
val plainResponseLength: Int?,
val plainDataLength: Int?,
val context: Map<String, Any>
)

View File

@@ -157,7 +157,9 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
// private var encodePhotoToBase64 = false
private var scanPromise: Promise? = null
private var opts: ReadableMap? = null
private val apduLogger = APDULogger()
private var currentSessionId: String? = null
data class Data(val id: String, val digest: String, val signature: String, val publicKey: String)
data class PassportData(
@@ -173,6 +175,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
init {
instance = this
reactContext.addLifecycleEventListener(this)
apduLogger.setModuleReference(this)
}
override fun onCatalystInstanceDestroy() {
@@ -197,6 +200,10 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
@ReactMethod
fun scan(opts: ReadableMap, promise: Promise) {
currentSessionId = generateSessionId()
apduLogger.setContext("session_id", currentSessionId!!)
// Log scan start
logAnalyticsEvent("nfc_scan_started", mapOf(
"use_can" to (opts.getBoolean(PARAM_USE_CAN) ?: false),
@@ -228,7 +235,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
this.opts = opts
this.scanPromise = promise
Log.d("RNPassportReaderModule", "opts set to: " + opts.toString())
// Log.d("RNPassportReaderModule", "opts set to: " + opts.toString())
}
private fun resetState() {
@@ -293,7 +300,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
@SuppressLint("StaticFieldLeak")
private inner class ReadTask(
private val isoDep: IsoDep,
private val isoDep: IsoDep,
private val authKey: AccessKeySpec
) : AsyncTask<Void?, Void?, Exception?>() {
@@ -320,7 +327,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
Log.e("MY_LOGS", "Failed to get CardService instance", e)
throw e
}
try {
cardService.open()
} catch (e: Exception) {
@@ -341,10 +348,14 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
false,
)
Log.e("MY_LOGS", "service gotten")
service.addAPDUListener(apduLogger)
service.open()
Log.e("MY_LOGS", "service opened")
logAnalyticsEvent("nfc_passport_service_opened")
var paceSucceeded = false
var bacSucceeded = false
try {
Log.e("MY_LOGS", "trying to get cardAccessFile...")
val cardAccessFile = CardAccessFile(service.getInputStream(PassportService.EF_CARD_ACCESS))
@@ -355,16 +366,31 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
if (securityInfo is PACEInfo) {
Log.e("MY_LOGS", "trying PACE...")
eventMessageEmitter(Messages.PACE_STARTED)
service.doPACE(
authKey,
securityInfo.objectIdentifier,
PACEInfo.toParameterSpec(securityInfo.parameterId),
null,
)
apduLogger.setContext("operation", "pace_authentication")
apduLogger.setContext("auth_key_type", authKey.javaClass.simpleName)
// Determine proper PACE key: use CAN key if provided; otherwise derive PACE MRZ key from BAC
val paceKeyToUse: PACEKeySpec? = when (authKey) {
is PACEKeySpec -> authKey
is BACKey -> PACEKeySpec.createMRZKey(authKey)
else -> null
}
if (paceKeyToUse != null) {
service.doPACE(
paceKeyToUse,
securityInfo.objectIdentifier,
PACEInfo.toParameterSpec(securityInfo.parameterId),
null,
)
} else {
throw IllegalStateException("Unsupported auth key for PACE: ${authKey::class.java.simpleName}")
}
Log.e("MY_LOGS", "PACE succeeded")
paceSucceeded = true
logAnalyticsEvent("nfc_pace_succeeded")
eventMessageEmitter(Messages.PACE_SUCCEEDED)
// Stop iterating once PACE succeeds to avoid disrupting session with another attempt
break
}
}
} catch (e: Exception) {
@@ -376,35 +402,31 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
Log.w("MY_LOGS", e)
eventMessageEmitter(Messages.PACE_FAILED)
}
Log.e("MY_LOGS", "Sending select applet command with paceSucceeded: ${paceSucceeded}") // this is false so PACE doesn't succeed
service.sendSelectApplet(paceSucceeded)
// (Reverted) Do not select applet before authentication; proceed to BAC if needed
// Attempt BAC fallback if PACE failed
if (!paceSucceeded && authKey is BACKeySpec) {
var bacSucceeded = false
var attempts = 0
val maxAttempts = 3
eventMessageEmitter(Messages.BAC_STARTED)
apduLogger.setContext("operation", "bac_authentication")
apduLogger.setContext("auth_key_type", authKey.javaClass.simpleName)
while (!bacSucceeded && attempts < maxAttempts) {
try {
attempts++
Log.e("MY_LOGS", "BAC attempt $attempts of $maxAttempts")
if (attempts > 1) {
// Wait before retry
Thread.sleep(500)
}
// Try to read EF_COM first
if (attempts > 1) Thread.sleep(500)
// Try to read EF_COM first; if it fails, do BAC
try {
eventMessageEmitter(Messages.READING_COM)
service.getInputStream(PassportService.EF_COM).read()
} catch (e: Exception) {
// EF_COM failed, do BAC
service.doBAC(authKey)
}
bacSucceeded = true
logAnalyticsEvent("nfc_bac_succeeded", mapOf("attempts" to attempts))
logAnalyticsEvent("nfc_bac_attempted", mapOf(
@@ -414,23 +436,61 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
Log.e("MY_LOGS", "BAC succeeded on attempt $attempts")
eventMessageEmitter(Messages.BAC_SUCCEEDED)
} catch (e: Exception) {
val errClass = e.javaClass.simpleName
val errMsg = e.message ?: ""
logAnalyticsError("nfc_bac_attempt_failed", "BAC attempt $attempts failed: ${e.message}")
logAnalyticsEvent("nfc_bac_attempted", mapOf(
"success" to false,
"attempt" to attempts,
"error_type" to e.javaClass.simpleName
"error_type" to errClass
))
Log.e("MY_LOGS", "BAC attempt $attempts failed: ${e.message}")
Log.e("MY_LOGS", "BAC attempt $attempts failed: $errClass - $errMsg")
if (e is org.jmrtd.CardServiceProtocolException) {
// Provide additional structured diagnostics without sensitive data
logAnalyticsEvent("nfc_bac_protocol_error", mapOf(
"attempt" to attempts,
"message_contains_sw" to (errMsg.contains("SW = ")),
"message_length" to errMsg.length
))
}
if (attempts == maxAttempts) {
eventMessageEmitter(Messages.BAC_FAILED)
throw e // Re-throw on final attempt
throw e
}
}
}
}
// Ensure we have established authentication before reading
if (!paceSucceeded && !bacSucceeded) {
throw IOException("Authentication not established; cannot read data groups")
}
// Select applet after authentication established; handle 0x6982 gracefully
try {
Log.e("MY_LOGS", "Sending select applet command after auth. paceSucceeded=$paceSucceeded, bacSucceeded=$bacSucceeded")
service.sendSelectApplet(paceSucceeded)
logAnalyticsEvent("nfc_select_applet_succeeded", mapOf(
"pace_succeeded" to paceSucceeded,
"bac_succeeded" to bacSucceeded
))
} catch (e: Exception) {
val msg = e.message ?: ""
logAnalyticsError("nfc_select_applet_failed", "Select applet failed: ${e.message}")
if (msg.contains("6982") || msg.contains("SECURITY STATUS NOT SATISFIED", ignoreCase = true)) {
Log.w(TAG, "Select applet returned 6982; proceeding after established auth")
} else {
throw e
}
}
logAnalyticsEvent("nfc_reading_data_groups")
apduLogger.setContext("operation", "reading_data_groups")
apduLogger.setContext("pace_succeeded", paceSucceeded)
apduLogger.setContext("bac_succeeded", bacSucceeded)
eventMessageEmitter(Messages.READING_DG1)
logAnalyticsEvent("nfc_reading_dg1_started")
val dg1In = service.getInputStream(PassportService.EF_DG1)
@@ -509,6 +569,8 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
private fun doChipAuth(service: PassportService) {
try {
apduLogger.setContext("operation", "chip_authentication")
logAnalyticsEvent("nfc_reading_dg14_started")
eventMessageEmitter(Messages.READING_DG14)
val dg14In = service.getInputStream(PassportService.EF_DG14)
@@ -538,6 +600,9 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
private fun doPassiveAuth() {
try {
apduLogger.setContext("operation", "passive_authentication")
apduLogger.setContext("chip_auth_succeeded", chipAuthSucceeded)
logAnalyticsEvent("nfc_passive_auth_started")
Log.d(TAG, "Starting passive authentication...")
val digest = MessageDigest.getInstance(sodFile.digestAlgorithm)
@@ -675,7 +740,9 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
scanPromise?.reject("E_SCAN_FAILED", result)
}
resetState()
apduLogger.clearContext()
resetState()
return
}
@@ -785,6 +852,9 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
eventMessageEmitter(Messages.COMPLETED)
scanPromise?.resolve(passport)
eventMessageEmitter(Messages.RESET)
apduLogger.clearContext()
resetState()
}
}
@@ -811,7 +881,7 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
}
}
private fun logAnalyticsEvent(eventName: String, params: Map<String, Any> = emptyMap()) {
fun logAnalyticsEvent(eventName: String, params: Map<String, Any> = emptyMap()) {
try {
val logData = JSONObject()
logData.put("level", "info")
@@ -863,8 +933,17 @@ class RNPassportReaderModule(private val reactContext: ReactApplicationContext)
@ReactMethod
fun reset() {
logAnalyticsEvent("nfc_scan_reset")
apduLogger.clearContext()
resetState()
}
/**
* Generate a unique session ID for tracking passport reading sessions
*/
private fun generateSessionId(): String {
return "nfc_${System.currentTimeMillis()}_${UUID.randomUUID().toString().take(8)}"
}
companion object {
private val TAG = RNPassportReaderModule::class.java.simpleName

View File

@@ -119,6 +119,10 @@ platform :ios do
# VersionManager doesn't handle semantic versions, use npm
sh("cd .. && npm version #{version_bump} --no-git-tag-version")
UI.success("✅ Bumped #{version_bump} version")
# Sync the new version to iOS project files
sync_version
UI.success("✅ Synced MARKETING_VERSION to iOS project")
when "build"
# Build number is handled in prepare_ios_build
UI.message("📦 Build number will be incremented during build")
@@ -297,6 +301,18 @@ platform :android do
upload_android_build(track: "production")
end
desc "Build Android app without uploading"
lane :build_only do |options|
deployment_track = options[:deployment_track] || "internal"
version_bump = options[:version_bump] || "build"
UI.message("🔨 Building Android app (build only)")
UI.message(" Track: #{deployment_track}")
UI.message(" Version bump: #{version_bump}")
upload_android_build(options.merge(skip_upload: true))
end
desc "Deploy Android app with automatic version management"
lane :deploy_auto do |options|
deployment_track = options[:deployment_track] || "internal"
@@ -338,6 +354,7 @@ platform :android do
private_lane :upload_android_build do |options|
test_mode = options[:test_mode] == true || options[:test_mode] == "true"
skip_upload = options[:skip_upload] == true || options[:skip_upload] == "true"
if local_development
if ENV["ANDROID_KEYSTORE_PATH"].nil?
ENV["ANDROID_KEYSTORE_PATH"] = Fastlane::Helpers.android_create_keystore(android_keystore_path)
@@ -355,8 +372,9 @@ platform :android do
"ANDROID_KEY_ALIAS",
"ANDROID_KEY_PASSWORD",
"ANDROID_PACKAGE_NAME",
"ANDROID_PLAY_STORE_JSON_KEY_PATH",
]
# Only require JSON key path when not running in CI (local development)
required_env_vars << "ANDROID_PLAY_STORE_JSON_KEY_PATH" if local_development
Fastlane::Helpers.verify_env_vars(required_env_vars)
@@ -375,40 +393,69 @@ platform :android do
target_platform = options[:track] == "production" ? "Google Play" : "Internal Testing"
should_upload = Fastlane::Helpers.should_upload_app(target_platform)
validate_play_store_json_key(
json_key: ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"],
)
# Validate JSON key only in local development; CI uses Workload Identity Federation (ADC)
if local_development
validate_play_store_json_key(
json_key: ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"],
)
end
Fastlane::Helpers.with_retry(max_retries: 3, delay: 10) do
gradle(
task: "clean bundleRelease --stacktrace --info",
project_dir: "android/",
properties: {
"android.injected.signing.store.file" => ENV["ANDROID_KEYSTORE_PATH"],
"android.injected.signing.store.password" => ENV["ANDROID_KEYSTORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["ANDROID_KEY_ALIAS"],
"android.injected.signing.key.password" => ENV["ANDROID_KEY_PASSWORD"],
"MYAPP_UPLOAD_STORE_FILE" => ENV["ANDROID_KEYSTORE_PATH"],
"MYAPP_UPLOAD_STORE_PASSWORD" => ENV["ANDROID_KEYSTORE_PASSWORD"],
"MYAPP_UPLOAD_KEY_ALIAS" => ENV["ANDROID_KEY_ALIAS"],
"MYAPP_UPLOAD_KEY_PASSWORD" => ENV["ANDROID_KEY_PASSWORD"] == "EMPTY" ? "" : ENV["ANDROID_KEY_PASSWORD"],
},
)
end
if test_mode
UI.important("🧪 TEST MODE: Skipping Play Store upload")
if test_mode || skip_upload
if skip_upload
UI.important("🔨 BUILD ONLY: Skipping Play Store upload")
else
UI.important("🧪 TEST MODE: Skipping Play Store upload")
end
UI.success("✅ Build completed successfully!")
UI.message("📦 AAB path: #{android_aab_path}")
else
if should_upload
begin
upload_to_play_store(
upload_options = {
track: options[:track],
json_key: ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"],
package_name: ENV["ANDROID_PACKAGE_NAME"],
skip_upload_changelogs: true,
skip_upload_images: true,
skip_upload_screenshots: true,
track_promote_release_status: "completed",
aab: android_aab_path,
)
}
# In local development, use the JSON key file; in CI rely on ADC
if local_development
upload_options[:json_key] = ENV["ANDROID_PLAY_STORE_JSON_KEY_PATH"]
else
# In CI, try to use ADC credentials file directly
adc_creds_path = ENV["GOOGLE_APPLICATION_CREDENTIALS"]
if adc_creds_path && File.exist?(adc_creds_path)
UI.message("🔑 Using ADC credentials file: #{adc_creds_path}")
begin
# Try passing the credentials file content as json_key_data
creds_content = File.read(adc_creds_path)
upload_options[:json_key_data] = creds_content
rescue => e
UI.error("Failed to read ADC credentials: #{e.message}")
# Fallback: let supply try to use ADC automatically
UI.message("🔄 Falling back to automatic ADC detection")
end
else
UI.error("❌ ADC credentials not found at: #{adc_creds_path}")
end
end
upload_to_play_store(upload_options)
rescue => e
if e.message.include?("forbidden") || e.message.include?("403") || e.message.include?("insufficientPermissions")
UI.error("❌ Play Store upload failed: Insufficient permissions")

View File

@@ -80,6 +80,14 @@ Push a new build to Google Play Internal Testing
Push a new build to Google Play Store
### android build_only
```sh
[bundle exec] fastlane android build_only
```
Build Android app without uploading
### android deploy_auto
```sh

View File

@@ -423,7 +423,7 @@
CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassportDebug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 149;
CURRENT_PROJECT_VERSION = 169;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 5B29R5LYHQ;
ENABLE_BITCODE = NO;
@@ -564,7 +564,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassport.entitlements;
CURRENT_PROJECT_VERSION = 149;
CURRENT_PROJECT_VERSION = 169;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 5B29R5LYHQ;
FRAMEWORK_SEARCH_PATHS = (

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
Upload Android AAB to Google Play Store using Workload Identity Federation
This script bypasses Fastlane and uses the Google Play Developer API directly
"""
import os
import sys
import json
import argparse
from pathlib import Path
try:
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
from google.auth import default
except ImportError:
print("❌ Error: Required packages not installed.")
print("Run: pip install google-auth google-auth-oauthlib google-auth-httplib2 google-api-python-client")
sys.exit(1)
def get_credentials():
"""Get credentials using ADC (Workload Identity Federation)"""
print("🔑 Authenticating using Application Default Credentials...")
try:
# Use the default() function which properly handles WIF
# This should work now that the audience is configured correctly
print("🔄 Using Google's default credential chain...")
credentials, project = default(scopes=['https://www.googleapis.com/auth/androidpublisher'])
print(f"✅ Authentication successful! Project: {project}")
print(f"🔍 Credential type: {type(credentials).__name__}")
# Ensure credentials are ready for use
if hasattr(credentials, 'refresh') and hasattr(credentials, 'valid') and not credentials.valid:
print("🔄 Refreshing credentials...")
import google.auth.transport.requests
request = google.auth.transport.requests.Request()
credentials.refresh(request)
print("✅ Credentials refreshed successfully")
return credentials
except Exception as e:
print(f"❌ Authentication failed: {e}")
print(f"❌ Error type: {type(e).__name__}")
# Debug information
creds_file = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS')
if creds_file:
print(f"🔍 Credentials file: {creds_file}")
if os.path.exists(creds_file):
try:
with open(creds_file, 'r') as f:
creds_info = json.load(f)
print(f"🔍 Credential type in file: {creds_info.get('type', 'unknown')}")
if 'audience' in creds_info:
print(f"🔍 Credential audience: {creds_info['audience']}")
except:
print("🔍 Could not read credentials file content")
else:
print("🔍 Credentials file does not exist")
else:
print("🔍 GOOGLE_APPLICATION_CREDENTIALS not set")
sys.exit(1)
def should_hold_for_manual_review(track):
"""
Determine if changes should be held for manual review based on track type.
Returns True only for production releases or when you need manual control.
For internal, alpha, beta tracks, changes are automatically sent for review.
"""
# Only hold for manual review on production track
# For other tracks (internal, alpha, beta), let changes go for automatic review
return track == 'production'
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...")
try:
# Build the service
service = build('androidpublisher', 'v3', credentials=credentials)
# Create an edit
print("🚀 Creating edit transaction...")
edit_request = service.edits().insert(body={}, packageName=package_name)
edit = edit_request.execute()
edit_id = edit['id']
print(f"✅ Edit created: {edit_id}")
# Upload the AAB
print("📦 Uploading AAB file...")
media = MediaFileUpload(aab_path, mimetype='application/octet-stream')
upload_request = service.edits().bundles().upload(
packageName=package_name,
editId=edit_id,
media_body=media
)
bundle_response = upload_request.execute()
version_code = bundle_response['versionCode']
print(f"✅ AAB uploaded. Version code: {version_code}")
# Assign to track
print(f"🎯 Assigning to track: {track}")
track_request = service.edits().tracks().update(
packageName=package_name,
editId=edit_id,
track=track,
body={
'track': track,
'releases': [{
'versionCodes': [str(version_code)],
'status': 'completed'
}]
}
)
track_response = track_request.execute()
print(f"✅ Assigned to track: {track_response['track']}")
# Commit the edit
print("💾 Committing changes...")
# Determine if we should hold changes for manual review
hold_for_manual_review = should_hold_for_manual_review(track)
if hold_for_manual_review:
# For production or when manual review is needed
commit_request = service.edits().commit(
packageName=package_name,
editId=edit_id,
changesNotSentForReview=True
)
commit_response = commit_request.execute()
print(f"✅ Upload completed successfully! Edit ID: {commit_response['id']}")
print(f"📝 Note: Changes committed but held for manual review (production track)")
else:
# For internal, alpha, beta tracks - let changes go for automatic review
commit_request = service.edits().commit(
packageName=package_name,
editId=edit_id
)
commit_response = commit_request.execute()
print(f"✅ Upload completed successfully! Edit ID: {commit_response['id']}")
print(f"📝 Note: Changes committed and sent for automatic review ({track} track)")
return True
except Exception as e:
print(f"❌ Upload failed: {e}")
return False
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)')
args = parser.parse_args()
# Validate AAB file exists
aab_path = Path(args.aab)
if not aab_path.exists():
print(f"❌ Error: AAB file not found: {aab_path}")
sys.exit(1)
print("🚀 Starting Google Play Store upload with Workload Identity Federation")
print(f"📦 AAB: {aab_path}")
print(f"📱 Package: {args.package_name}")
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 success:
print("\n🎉 Upload completed successfully!")
sys.exit(0)
else:
print("\n💥 Upload failed!")
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -13,6 +13,9 @@ const DevFeatureFlagsScreen = lazy(
const DevHapticFeedbackScreen = lazy(
() => import('@/screens/dev/DevHapticFeedbackScreen'),
);
const DevPrivateKeyScreen = lazy(
() => import('@/screens/dev/DevPrivateKeyScreen'),
);
const DevSettingsScreen = lazy(() => import('@/screens/dev/DevSettingsScreen'));
const CreateMockScreen = lazy(() => import('@/screens/dev/CreateMockScreen'));
const CreateMockScreenDeepLink = lazy(
@@ -71,6 +74,13 @@ const devScreens = {
},
} as NativeStackNavigationOptions,
},
DevPrivateKey: {
screen: DevPrivateKeyScreen,
options: {
...devHeaderOptions,
title: 'Private Key',
} as NativeStackNavigationOptions,
},
};
export default devScreens;

View File

@@ -16,7 +16,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { DefaultNavBar } from '@/components/NavBar';
import AppLayout from '@/layouts/AppLayout';
import { getAesopScreens } from '@/navigation/aesop';
import devScreens from '@/navigation/dev';
import devScreens from '@/navigation/devTools';
import documentScreens from '@/navigation/document';
import homeScreens from '@/navigation/home';
import proveScreens from '@/navigation/prove';

View File

@@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback, useEffect, useState } from 'react';
import { Button, Text, XStack, YStack } from 'tamagui';
import Clipboard from '@react-native-clipboard/clipboard';
import { unsafe_getPrivateKey } from '@/providers/authProvider';
import { black, slate50, slate200, teal500, white } from '@/utils/colors';
import { confirmTap } from '@/utils/haptic';
const DevPrivateKeyScreen: React.FC = () => {
const [privateKey, setPrivateKey] = useState<string | null>(
'Loading private key…',
);
const [isPrivateKeyRevealed, setIsPrivateKeyRevealed] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
unsafe_getPrivateKey().then(key =>
setPrivateKey(key || 'No private key found'),
);
}, []);
const handleRevealPrivateKey = useCallback(() => {
confirmTap();
if (!isPrivateKeyRevealed) {
setIsPrivateKeyRevealed(true);
}
if (isPrivateKeyRevealed) {
Clipboard.setString(privateKey || '');
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
}, [isPrivateKeyRevealed, privateKey]);
const getRedactedPrivateKey = useCallback(() => {
if (
!privateKey ||
privateKey === 'Loading private key…' ||
privateKey === 'No private key found'
) {
return privateKey;
}
// If it starts with 0x, show 0x followed by asterisks for the rest
if (privateKey.startsWith('0x')) {
const restLength = privateKey.length - 2;
return '0x' + '*'.repeat(restLength);
}
// Otherwise, show asterisks for the entire length
return '*'.repeat(privateKey.length);
}, [privateKey]);
return (
<YStack padding="$4">
<YStack position="relative" alignItems="stretch" gap={0}>
<XStack
borderColor={slate200}
backgroundColor={slate50}
borderWidth="$1"
borderBottomWidth={0}
borderTopLeftRadius="$5"
borderTopRightRadius="$5"
gap={12}
paddingHorizontal={26}
paddingVertical={28}
flexWrap="wrap"
>
<Text>
{isPrivateKeyRevealed ? privateKey : getRedactedPrivateKey()}
</Text>
</XStack>
<XStack
borderTopColor={slate200}
borderTopWidth="$1"
justifyContent="center"
alignItems="stretch"
>
<Button
unstyled
color={isPrivateKeyRevealed ? (copied ? black : white) : black}
borderColor={
isPrivateKeyRevealed ? (copied ? teal500 : black) : slate200
}
backgroundColor={
isPrivateKeyRevealed ? (copied ? teal500 : black) : slate50
}
borderWidth="$1"
borderTopWidth={0}
borderBottomLeftRadius="$5"
borderBottomRightRadius="$5"
paddingVertical="$2"
onPress={handleRevealPrivateKey}
width="100%"
textAlign="center"
>
{isPrivateKeyRevealed
? `${copied ? 'COPIED' : 'COPY'} TO CLIPBOARD`
: 'TAP TO REVEAL'}
</Button>
</XStack>
</YStack>
</YStack>
);
};
export default DevPrivateKeyScreen;

View File

@@ -126,6 +126,7 @@ const items = [
'DevSettings',
'DevFeatureFlags',
'DevHapticFeedback',
'DevPrivateKey',
'Splash',
'Launch',
'DocumentOnboarding',
@@ -339,7 +340,32 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
title="Debug Shortcuts"
description="Jump directly to any screen for testing"
>
<ScreenSelector />
<YStack gap="$2">
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => {
navigation.navigate('DevPrivateKey');
}}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
View Private Key
</Text>
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<ScreenSelector />
</YStack>
</ParameterSection>
<ParameterSection

View File

@@ -204,6 +204,18 @@ const DocumentNFCScanScreen: React.FC = () => {
// Add timestamp when scan starts
scanCancelledRef.current = false;
const scanStartTime = Date.now();
if (scanTimeoutRef.current) {
clearTimeout(scanTimeoutRef.current);
scanTimeoutRef.current = null;
}
scanTimeoutRef.current = setTimeout(() => {
scanCancelledRef.current = true;
trackEvent(PassportEvents.NFC_SCAN_FAILED, {
error: 'timeout',
});
openErrorModal('Scan timed out. Please try again.');
setIsNfcSheetOpen(false);
}, 30000);
// Mark NFC scanning as active to prevent analytics flush interference
setNfcScanningActive(true);

View File

@@ -316,11 +316,9 @@ const ManageDocumentsScreen: React.FC = () => {
<PrimaryButton onPress={handleScanDocument}>
Scan New ID Document
</PrimaryButton>
{__DEV__ && (
<SecondaryButton onPress={handleGenerateMock}>
Generate Mock Document
</SecondaryButton>
)}
<SecondaryButton onPress={handleGenerateMock}>
Generate Mock Document
</SecondaryButton>
</ButtonsContainer>
</YStack>
</YStack>

View File

@@ -24,6 +24,11 @@ import {
} from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
import { black } from '@/utils/colors';
import {
getAndClearQueuedUrl,
handleUrl,
setDeeplinkParentScreen,
} from '@/utils/deeplinks';
import { impactLight } from '@/utils/haptic';
const SplashScreen: React.FC = ({}) => {
@@ -36,6 +41,7 @@ const SplashScreen: React.FC = ({}) => {
const [nextScreen, setNextScreen] = useState<keyof RootStackParamList | null>(
null,
);
const [queuedDeepLink, setQueuedDeepLink] = useState<string | null>(null);
const dataLoadInitiatedRef = useRef(false);
useEffect(() => {
@@ -66,9 +72,22 @@ const SplashScreen: React.FC = ({}) => {
}
const hasValid = await hasAnyValidRegisteredDocument(selfClient);
setNextScreen(hasValid ? 'Home' : 'Launch');
const parentScreen = hasValid ? 'Home' : 'Launch';
setDeeplinkParentScreen(parentScreen);
const queuedUrl = getAndClearQueuedUrl();
if (queuedUrl) {
if (typeof __DEV__ !== 'undefined' && __DEV__) {
console.log('Processing queued deeplink:', queuedUrl);
}
setQueuedDeepLink(queuedUrl);
} else {
setNextScreen(parentScreen);
}
} catch (error) {
console.error(`Error in SplashScreen data loading: ${error}`);
setDeeplinkParentScreen('Launch');
setNextScreen('Launch');
}
};
@@ -83,12 +102,18 @@ const SplashScreen: React.FC = ({}) => {
}, []);
useEffect(() => {
if (isAnimationFinished && nextScreen) {
requestAnimationFrame(() => {
navigation.navigate(nextScreen as never);
});
if (isAnimationFinished) {
if (queuedDeepLink) {
requestAnimationFrame(() => {
handleUrl(queuedDeepLink);
});
} else if (nextScreen) {
requestAnimationFrame(() => {
navigation.navigate(nextScreen as never);
});
}
}
}, [isAnimationFinished, nextScreen, navigation]);
}, [isAnimationFinished, nextScreen, queuedDeepLink, navigation]);
return (
<LottieView

View File

@@ -72,6 +72,29 @@ const validateAndSanitizeParam = (
return decodedValue;
};
/**
* Creates a proper navigation stack for deeplink navigation
* @param targetScreen - The target screen to navigate to
* @param parentScreen - The parent screen that should appear when user goes back (default: 'Home')
*/
const createDeeplinkNavigationState = (
targetScreen: string,
parentScreen: string = 'Home',
) => ({
index: 1, // Current screen index (targetScreen)
routes: [{ name: parentScreen }, { name: targetScreen }],
});
// Store the correct parent screen determined by splash screen
let correctParentScreen: string = 'Home';
// Function for splash screen to get and clear the queued initial URL
export const getAndClearQueuedUrl = (): string | null => {
const url = queuedInitialUrl;
queuedInitialUrl = null;
return url;
};
export const handleUrl = (uri: string) => {
const validatedParams = parseAndValidateUrlParams(uri);
const { sessionId, selfApp: selfAppStr, mock_passport } = validatedParams;
@@ -81,19 +104,29 @@ export const handleUrl = (uri: string) => {
const selfAppJson = JSON.parse(selfAppStr);
useSelfAppStore.getState().setSelfApp(selfAppJson);
useSelfAppStore.getState().startAppListener(selfAppJson.sessionId);
navigationRef.navigate('Prove');
// Reset navigation stack with correct parent -> ProveScreen
navigationRef.reset(
createDeeplinkNavigationState('ProveScreen', correctParentScreen),
);
return;
} catch (error) {
if (typeof __DEV__ !== 'undefined' && __DEV__) {
console.error('Error parsing selfApp:', error);
}
navigationRef.navigate('QRCodeTrouble');
navigationRef.reset(
createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen),
);
}
} else if (sessionId && typeof sessionId === 'string') {
useSelfAppStore.getState().cleanSelfApp();
useSelfAppStore.getState().startAppListener(sessionId);
navigationRef.navigate('Prove');
// Reset navigation stack with correct parent -> ProveScreen
navigationRef.reset(
createDeeplinkNavigationState('ProveScreen', correctParentScreen),
);
} else if (mock_passport) {
try {
const data = JSON.parse(mock_passport);
@@ -120,12 +153,17 @@ export const handleUrl = (uri: string) => {
gender: rawParams.gender,
});
navigationRef.navigate('MockDataDeepLink');
// Reset navigation stack with correct parent -> MockDataDeepLink
navigationRef.reset(
createDeeplinkNavigationState('MockDataDeepLink', correctParentScreen),
);
} catch (error) {
if (typeof __DEV__ !== 'undefined' && __DEV__) {
console.error('Error parsing mock_passport data or navigating:', error);
}
navigationRef.navigate('QRCodeTrouble');
navigationRef.reset(
createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen),
);
}
} else if (Platform.OS === 'web') {
// TODO: web handle links if we need to idk if we do
@@ -134,7 +172,9 @@ export const handleUrl = (uri: string) => {
if (typeof __DEV__ !== 'undefined' && __DEV__) {
console.error('No sessionId or selfApp found in the data');
}
navigationRef.navigate('QRCodeTrouble');
navigationRef.reset(
createDeeplinkNavigationState('QRCodeTrouble', correctParentScreen),
);
}
};
@@ -166,19 +206,29 @@ export const parseAndValidateUrlParams = (uri: string): ValidatedParams => {
return validatedParams;
};
export const setupUniversalLinkListenerInNavigation = () => {
const handleNavigation = (url: string) => {
handleUrl(url);
};
// Store the initial URL for splash screen to handle after initialization
let queuedInitialUrl: string | null = null;
/**
* Sets the correct parent screen for deeplink navigation
* This should be called by splash screen after determining the correct screen
*/
export const setDeeplinkParentScreen = (screen: string) => {
correctParentScreen = screen;
};
export const setupUniversalLinkListenerInNavigation = () => {
// Get the initial URL and store it for splash screen handling
Linking.getInitialURL().then(url => {
if (url) {
handleNavigation(url);
// Store the initial URL instead of handling it immediately
queuedInitialUrl = url;
}
});
// Handle subsequent URL events normally (when app is already running)
const linkingEventListener = Linking.addEventListener('url', ({ url }) => {
handleNavigation(url);
handleUrl(url);
});
return () => {

View File

@@ -16,7 +16,7 @@ describe('navigation', () => {
'DeferredLinkingInfo',
'DevFeatureFlags',
'DevHapticFeedback',
'DevPrivateKey',
'DevSettings',
'Disclaimer',
'DocumentCamera',

View File

@@ -5,7 +5,11 @@
import { Linking } from 'react-native';
jest.mock('@/navigation', () => ({
navigationRef: { navigate: jest.fn(), isReady: jest.fn(() => true) },
navigationRef: {
navigate: jest.fn(),
isReady: jest.fn(() => true),
reset: jest.fn(),
},
}));
const mockSelfAppStore = { useSelfAppStore: { getState: jest.fn() } };
@@ -60,7 +64,10 @@ describe('deeplinks', () => {
expect(setSelfApp).toHaveBeenCalledWith(selfApp);
expect(startAppListener).toHaveBeenCalledWith('abc');
const { navigationRef } = require('@/navigation');
expect(navigationRef.navigate).toHaveBeenCalledWith('Prove');
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 1,
routes: [{ name: 'Home' }, { name: 'ProveScreen' }],
});
});
it('handles sessionId parameter', () => {
@@ -70,7 +77,10 @@ describe('deeplinks', () => {
expect(cleanSelfApp).toHaveBeenCalled();
expect(startAppListener).toHaveBeenCalledWith('123');
const { navigationRef } = require('@/navigation');
expect(navigationRef.navigate).toHaveBeenCalledWith('Prove');
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 1,
routes: [{ name: 'Home' }, { name: 'ProveScreen' }],
});
});
it('handles mock_passport parameter', () => {
@@ -86,7 +96,10 @@ describe('deeplinks', () => {
gender: undefined,
});
const { navigationRef } = require('@/navigation');
expect(navigationRef.navigate).toHaveBeenCalledWith('MockDataDeepLink');
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 1,
routes: [{ name: 'Home' }, { name: 'MockDataDeepLink' }],
});
});
it('navigates to QRCodeTrouble for invalid data', () => {
@@ -98,7 +111,10 @@ describe('deeplinks', () => {
handleUrl(url);
const { navigationRef } = require('@/navigation');
expect(navigationRef.navigate).toHaveBeenCalledWith('QRCodeTrouble');
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 1,
routes: [{ name: 'Home' }, { name: 'QRCodeTrouble' }],
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error parsing selfApp:',
expect.any(Error),
@@ -119,7 +135,10 @@ describe('deeplinks', () => {
handleUrl(url);
const { navigationRef } = require('@/navigation');
expect(navigationRef.navigate).toHaveBeenCalledWith('QRCodeTrouble');
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 1,
routes: [{ name: 'Home' }, { name: 'QRCodeTrouble' }],
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'No sessionId or selfApp found in the data',
);
@@ -137,7 +156,10 @@ describe('deeplinks', () => {
handleUrl(url);
const { navigationRef } = require('@/navigation');
expect(navigationRef.navigate).toHaveBeenCalledWith('QRCodeTrouble');
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 1,
routes: [{ name: 'Home' }, { name: 'QRCodeTrouble' }],
});
consoleErrorSpy.mockRestore();
});

View File

@@ -1,10 +1,10 @@
{
"ios": {
"build": 163,
"lastDeployed": "2025-08-08T22:35:10Z"
"build": 169,
"lastDeployed": "2025-08-26T16:35:10Z"
},
"android": {
"build": 85,
"lastDeployed": "2025-08-08T15:13:41Z"
"build": 96,
"lastDeployed": "2025-08-29T10:59:07Z"
}
}

View File

@@ -173,7 +173,7 @@ export default defineConfig({
// Other screens
'screens-settings': ['./src/navigation/settings.ts'],
'screens-recovery': ['./src/navigation/recovery.ts'],
'screens-dev': ['./src/navigation/dev.ts'],
'screens-dev': ['./src/navigation/devTools.ts'],
'screens-aesop': ['./src/navigation/aesop.ts'],
},
},

View File

@@ -62,6 +62,26 @@ describe('extractMRZInfo', () => {
expect(info.validation?.overall).toBe(false);
});
it('parses valid TD1 MRZ', () => {
const info = extractMRZInfo(sampleTD1);
expect(info.documentNumber).toBe('X4RTBPFW4');
expect(info.issuingCountry).toBe('FRA');
expect(info.dateOfBirth).toBe('900713');
expect(info.dateOfExpiry).toBe('300211');
expect(info.validation?.overall).toBe(true);
});
it('rejects invalid TD1 MRZ', () => {
const invalid = `FRAX4RTBPFW46`;
expect(() => extractMRZInfo(invalid)).toThrow();
});
it('Fails overall validation for invalid TD1 MRZ', () => {
const invalid = `IDFRAX4RTBPFW46`;
const info = extractMRZInfo(invalid);
expect(info.validation?.overall).toBe(false);
});
it('rejects malformed MRZ', () => {
const invalid = 'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<';
expect(() => extractMRZInfo(invalid)).toThrowError(MrzParseError);