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);