From b6d526e5f8a491919c16ab3be8bcf2fb5d0cfcd2 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Sun, 7 Sep 2025 11:19:59 -0700 Subject: [PATCH] chore: update dev with staging 09/06/25 (#1007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 Co-authored-by: Justin Hernandez * 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 Co-authored-by: Aaron DeRuvo Co-authored-by: Justin Hernandez Co-authored-by: Seshanth.S๐Ÿบ <35675963+seshanthS@users.noreply.github.com> Co-authored-by: Justin Hernandez 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 60fc1f2580c2f6ad3105d8b904d969412a18bd2e. * 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 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 Co-authored-by: pputman-clabs <99900942+pputman-clabs@users.noreply.github.com> Co-authored-by: Self GitHub Actions Co-authored-by: turnoffthiscomputer <98749896+remicolin@users.noreply.github.com> Co-authored-by: Vishalkulkarni45 <109329073+Vishalkulkarni45@users.noreply.github.com> Co-authored-by: ayman Co-authored-by: Aaron DeRuvo 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> --- .coderabbit.yaml | 2 +- .github/workflows/circuits-build.yml | 8 +- .github/workflows/circuits.yml | 11 +- .github/workflows/contracts.yml | 8 +- .github/workflows/mobile-ci.yml | 8 +- .github/workflows/mobile-deploy.yml | 328 ++++++++++++------ .github/workflows/mobile-e2e.yml | 9 +- .github/workflows/qrcode-sdk-ci.yml | 9 +- .github/workflows/web.yml | 6 +- README.md | 4 +- app/Gemfile.lock | 4 +- app/android/app/build.gradle | 2 +- .../src/main/java/io/tradle/nfc/APDULogger.kt | 114 ++++++ .../io/tradle/nfc/RNPassportReaderModule.kt | 133 +++++-- app/fastlane/Fastfile | 73 +++- app/fastlane/README.md | 8 + app/ios/Self.xcodeproj/project.pbxproj | 4 +- app/scripts/upload_to_play_store.py | 192 ++++++++++ app/src/navigation/{dev.ts => devTools.ts} | 10 + app/src/navigation/index.tsx | 2 +- app/src/screens/dev/DevPrivateKeyScreen.tsx | 110 ++++++ app/src/screens/dev/DevSettingsScreen.tsx | 28 +- .../document/DocumentNFCScanScreen.tsx | 12 + .../settings/ManageDocumentsScreen.tsx | 8 +- app/src/screens/system/SplashScreen.tsx | 37 +- app/src/utils/deeplinks.ts | 74 +++- app/tests/src/navigation.test.ts | 2 +- app/tests/utils/deeplinks.test.ts | 36 +- app/version.json | 8 +- app/vite.config.ts | 2 +- .../tests/processing/mrz.test.ts | 20 ++ 31 files changed, 1040 insertions(+), 232 deletions(-) create mode 100644 app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/APDULogger.kt create mode 100644 app/scripts/upload_to_play_store.py rename app/src/navigation/{dev.ts => devTools.ts} (88%) create mode 100644 app/src/screens/dev/DevPrivateKeyScreen.tsx diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 933bcaf0b..af6a93bc6 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -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 diff --git a/.github/workflows/circuits-build.yml b/.github/workflows/circuits-build.yml index 9c4832732..d772e1b05 100644 --- a/.github/workflows/circuits-build.yml +++ b/.github/workflows/circuits-build.yml @@ -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/**" diff --git a/.github/workflows/circuits.yml b/.github/workflows/circuits.yml index b3dff525a..d27071900 100644 --- a/.github/workflows/circuits.yml +++ b/.github/workflows/circuits.yml @@ -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 diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 481dc005b..4e6fcc4a1 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -1,15 +1,9 @@ name: Contracts CI on: - push: - branches: - - dev - - main - paths: - - "contracts/**" - - "common/**" pull_request: branches: - dev + - staging - main paths: - "contracts/**" diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index 801e6664c..8eb3c3b43 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -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 diff --git a/.github/workflows/mobile-deploy.yml b/.github/workflows/mobile-deploy.yml index 1562fa5bd..c829df8db 100644 --- a/.github/workflows/mobile-deploy.yml +++ b/.github/workflows/mobile-deploy.yml @@ -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 diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml index eedf5586a..364e5a90d 100644 --- a/.github/workflows/mobile-e2e.yml +++ b/.github/workflows/mobile-e2e.yml @@ -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" diff --git a/.github/workflows/qrcode-sdk-ci.yml b/.github/workflows/qrcode-sdk-ci.yml index 2d46f9a9a..5409fefb3 100644 --- a/.github/workflows/qrcode-sdk-ci.yml +++ b/.github/workflows/qrcode-sdk-ci.yml @@ -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 diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index ed8cb9726..cb14d7fb7 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -1,7 +1,11 @@ name: Web CI on: - push: + pull_request: + branches: + - dev + - staging + - main paths: - "app/**" - ".github/workflows/web.yml" diff --git a/README.md b/README.md index 874616ec2..fbd81c41d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/Gemfile.lock b/app/Gemfile.lock index 73c629152..37a305777 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -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 diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 11b8e8a17..b4bebee1a 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -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 { diff --git a/app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/APDULogger.kt b/app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/APDULogger.kt new file mode 100644 index 000000000..1c7aae03c --- /dev/null +++ b/app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/APDULogger.kt @@ -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() + + 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().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 +) \ No newline at end of file diff --git a/app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/RNPassportReaderModule.kt b/app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/RNPassportReaderModule.kt index fa0d93e9e..2095733a2 100644 --- a/app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/RNPassportReaderModule.kt +++ b/app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/RNPassportReaderModule.kt @@ -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() { @@ -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 = emptyMap()) { + fun logAnalyticsEvent(eventName: String, params: Map = 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 diff --git a/app/fastlane/Fastfile b/app/fastlane/Fastfile index 3ad892572..f04180a5b 100644 --- a/app/fastlane/Fastfile +++ b/app/fastlane/Fastfile @@ -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") diff --git a/app/fastlane/README.md b/app/fastlane/README.md index 46b5eb95b..a30a2fd3c 100644 --- a/app/fastlane/README.md +++ b/app/fastlane/README.md @@ -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 diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj index 1cbefe9b8..4b0f017b6 100644 --- a/app/ios/Self.xcodeproj/project.pbxproj +++ b/app/ios/Self.xcodeproj/project.pbxproj @@ -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 = ( diff --git a/app/scripts/upload_to_play_store.py b/app/scripts/upload_to_play_store.py new file mode 100644 index 000000000..e22185b73 --- /dev/null +++ b/app/scripts/upload_to_play_store.py @@ -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() diff --git a/app/src/navigation/dev.ts b/app/src/navigation/devTools.ts similarity index 88% rename from app/src/navigation/dev.ts rename to app/src/navigation/devTools.ts index ce716cca5..55f4bb7a6 100644 --- a/app/src/navigation/dev.ts +++ b/app/src/navigation/devTools.ts @@ -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; diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index ca8d85c73..15f879b0f 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -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'; diff --git a/app/src/screens/dev/DevPrivateKeyScreen.tsx b/app/src/screens/dev/DevPrivateKeyScreen.tsx new file mode 100644 index 000000000..04290a04e --- /dev/null +++ b/app/src/screens/dev/DevPrivateKeyScreen.tsx @@ -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( + '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 ( + + + + + {isPrivateKeyRevealed ? privateKey : getRedactedPrivateKey()} + + + + + + + + ); +}; + +export default DevPrivateKeyScreen; diff --git a/app/src/screens/dev/DevSettingsScreen.tsx b/app/src/screens/dev/DevSettingsScreen.tsx index d902b774b..9f84275c2 100644 --- a/app/src/screens/dev/DevSettingsScreen.tsx +++ b/app/src/screens/dev/DevSettingsScreen.tsx @@ -126,6 +126,7 @@ const items = [ 'DevSettings', 'DevFeatureFlags', 'DevHapticFeedback', + 'DevPrivateKey', 'Splash', 'Launch', 'DocumentOnboarding', @@ -339,7 +340,32 @@ const DevSettingsScreen: React.FC = ({}) => { title="Debug Shortcuts" description="Jump directly to any screen for testing" > - + + + + { // 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); diff --git a/app/src/screens/settings/ManageDocumentsScreen.tsx b/app/src/screens/settings/ManageDocumentsScreen.tsx index 7d8b9ae1d..c6d76776f 100644 --- a/app/src/screens/settings/ManageDocumentsScreen.tsx +++ b/app/src/screens/settings/ManageDocumentsScreen.tsx @@ -316,11 +316,9 @@ const ManageDocumentsScreen: React.FC = () => { Scan New ID Document - {__DEV__ && ( - - Generate Mock Document - - )} + + Generate Mock Document + diff --git a/app/src/screens/system/SplashScreen.tsx b/app/src/screens/system/SplashScreen.tsx index 835d422ad..157243d7c 100644 --- a/app/src/screens/system/SplashScreen.tsx +++ b/app/src/screens/system/SplashScreen.tsx @@ -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( null, ); + const [queuedDeepLink, setQueuedDeepLink] = useState(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 ( ({ + 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 () => { diff --git a/app/tests/src/navigation.test.ts b/app/tests/src/navigation.test.ts index 0c93e0fb4..6b8b61602 100644 --- a/app/tests/src/navigation.test.ts +++ b/app/tests/src/navigation.test.ts @@ -16,7 +16,7 @@ describe('navigation', () => { 'DeferredLinkingInfo', 'DevFeatureFlags', 'DevHapticFeedback', - + 'DevPrivateKey', 'DevSettings', 'Disclaimer', 'DocumentCamera', diff --git a/app/tests/utils/deeplinks.test.ts b/app/tests/utils/deeplinks.test.ts index 1ab60a177..b40b58d28 100644 --- a/app/tests/utils/deeplinks.test.ts +++ b/app/tests/utils/deeplinks.test.ts @@ -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(); }); diff --git a/app/version.json b/app/version.json index b969cf758..7de3d5a3d 100644 --- a/app/version.json +++ b/app/version.json @@ -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" } } diff --git a/app/vite.config.ts b/app/vite.config.ts index 6e33de2c1..71b59bdb9 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -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'], }, }, diff --git a/packages/mobile-sdk-alpha/tests/processing/mrz.test.ts b/packages/mobile-sdk-alpha/tests/processing/mrz.test.ts index 63fd0e78f..de44b691f 100644 --- a/packages/mobile-sdk-alpha/tests/processing/mrz.test.ts +++ b/packages/mobile-sdk-alpha/tests/processing/mrz.test.ts @@ -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 extractMRZInfo(invalid)).toThrowError(MrzParseError);