diff --git a/.depcheckrc.yml b/.depcheckrc.yml index 6e0fef62f0ab..c15ff211ce12 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -29,6 +29,8 @@ ignores: # Appium drivers are used by Appwright for mobile automation - 'appium-uiautomator2-driver' - 'appium-xcuitest-driver' + # Used in scripts/repack-android-e2e.js for CI optimization + - '@expo/repack-app' # Note: Everything below this line should be removed after investigation # TODO: Investigate each dependency to see whether it's used diff --git a/.fingerprintignore b/.fingerprintignore new file mode 100644 index 000000000000..b13149a0b1cd --- /dev/null +++ b/.fingerprintignore @@ -0,0 +1,39 @@ +# Ignore scripts directory - changes here don't affect native builds +scripts/**/* + +# Ignore CI/CD and workflow files +.github/**/* + +# Ignore documentation and markdown files +*.md +docs/**/* +LICENSE +attribution.txt + +# Ignore workflow files +**/*.yml + +# Ignore SonarQube config +sonar-project.properties + +# Ignore test files that don't affect native builds +**/*.test.js +**/*.test.ts +**/*.test.tsx +**/*.spec.js +**/*.spec.ts +**/*.spec.tsx + +# Ignore e2e test files +e2e/**/* +wdio/**/* + +# Ignore linting and formatting configs +.eslintrc* +.prettierrc* +.eslintignore +.prettierignore + +# Ignore yarn/npm lock files for stability +yarn.lock +package-lock.json diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index d9740814c766..22b213a28882 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -26,7 +26,7 @@ jobs: apk-uploaded: ${{ steps.upload-apk.outcome == 'success' }} aab-uploaded: ${{ steps.upload-aab.outcome == 'success' }} sourcemap-uploaded: ${{ steps.upload-sourcemap.outcome == 'success' }} - + steps: - name: Checkout repo uses: actions/checkout@v4 @@ -60,7 +60,29 @@ jobs: echo "🚀 Setting up project..." yarn setup:github-ci --no-build-ios + # Generate fingerprint AFTER setup but BEFORE any build modifications + - name: Generate current fingerprint + id: generate-fingerprint + run: | + FINGERPRINT=$(yarn -s fingerprint:generate) + echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" + echo "Current fingerprint: ${FINGERPRINT}" + + - name: Check and restore cached APKs + id: cache-restore + uses: actions/cache@v4 + with: + path: | + android/app/build/outputs/apk/prod/release/app-prod-release.apk + android/app/build/outputs/apk/androidTest/prod/release/app-prod-release-androidTest.apk + android/app/build/outputs/bundle/prodRelease/app-prod-release.aab + key: android-apk-${{ steps.generate-fingerprint.outputs.fingerprint }} + restore-keys: | + android-apk-${{ steps.generate-fingerprint.outputs.fingerprint }} + android-apk- + - name: Build Android E2E APKs + if: ${{ steps.cache-restore.outputs.cache-hit != 'true' }} run: | echo "🏗 Building Android E2E APKs..." export NODE_OPTIONS="--max-old-space-size=8192" @@ -103,6 +125,71 @@ jobs: GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} + - name: Repack APK with JS updates using @expo/repack-app + if: ${{ steps.cache-restore.outputs.cache-hit == 'true' }} + run: | + echo "📦 Repacking APK with updated JavaScript bundle using @expo/repack-app..." + + # Use the optimized repack script which uses @expo/repack-app + yarn repack:android + + # Verify the repacked APK exists + APK_PATH="android/app/build/outputs/apk/prod/release/app-prod-release.apk" + if [ ! -f "$APK_PATH" ]; then + echo "❌ Error: APK not found at $APK_PATH after repack" + exit 1 + fi + + echo "✅ APK successfully repacked with @expo/repack-app" + echo "📦 Final APK size: $(du -h "$APK_PATH" | cut -f1)" + + env: + # Pass ALL the same environment variables as the original build to ensure identical JS bundle + PLATFORM: android + METAMASK_ENVIRONMENT: qa + METAMASK_BUILD_TYPE: main + IS_TEST: true + E2E: "true" + IGNORE_BOXLOGS_DEVELOPMENT: true + GITHUB_CI: "true" + CI: "true" + NODE_OPTIONS: "--max-old-space-size=8192" + MM_UNIFIED_SWAPS_ENABLED: "true" + MM_BRIDGE_ENABLED: "true" + BRIDGE_USE_DEV_APIS: "true" + RAMP_INTERNAL_BUILD: "true" + SEEDLESS_ONBOARDING_ENABLED: "true" + MM_NOTIFICATIONS_UI_ENABLED: "true" + MM_SECURITY_ALERTS_API_ENABLED: "true" + MM_REMOVE_GLOBAL_NETWORK_SELECTOR: "true" + BLOCKAID_FILE_CDN: "static.cx.metamask.io/api/v1/confirmations/ppom" + FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN: ${{ secrets.FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN }} + FEATURES_ANNOUNCEMENTS_SPACE_ID: ${{ secrets.FEATURES_ANNOUNCEMENTS_SPACE_ID }} + SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} + SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} + SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} + SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} + MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} + MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} + MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} + MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} + MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} + MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} + MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} + GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} + GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} + MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} + + # Cache build artifacts with the pre-build fingerprint + - name: Cache build artifacts + if: ${{ steps.cache-restore.outputs.cache-hit != 'true' }} + uses: actions/cache@v4 + with: + path: | + android/app/build/outputs/apk/prod/release/app-prod-release.apk + android/app/build/outputs/apk/androidTest/prod/release/app-prod-release-androidTest.apk + android/app/build/outputs/bundle/prodRelease/app-prod-release.aab + key: android-apk-${{ steps.generate-fingerprint.outputs.fingerprint }} - name: Upload Android APK id: upload-apk diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index 88785ccfe837..ef3851de6e7f 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -68,6 +68,15 @@ jobs: restore-keys: | ${{ runner.os }}-xcode- + # Cache CocoaPods to speed up pod install + - name: Cache CocoaPods + uses: actions/cache@v4 + with: + path: ios/Pods + key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }} + restore-keys: | + pods-${{ runner.os }}- + # Install Node.js, Xcode tools, and other iOS development dependencies - name: Installing iOS Environment Setup uses: MetaMask/github-tools/.github/actions/setup-e2e-env@f932aba72712f44367f89f6e259ea0c8cfedcf68 @@ -103,12 +112,11 @@ jobs: command: | echo "🚀 Setting up project..." yarn setup:github-ci --build-ios --no-build-android - + # Build the iOS E2E app for simulator - name: Build iOS E2E App run: | echo "🏗 Building iOS E2E App..." - export NODE_OPTIONS="--max-old-space-size=8192" yarn build:ios:main:e2e shell: bash env: @@ -121,7 +129,7 @@ jobs: GITHUB_CI: "true" CI: "true" - NODE_OPTIONS: "--max_old_space_size=4096" # Increase memory limit for build + NODE_OPTIONS: "--max-old-space-size=4096" # Increase memory limit for build SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} diff --git a/app.config.js b/app.config.js index 538a0c1c798d..8d2d2e1e389f 100644 --- a/app.config.js +++ b/app.config.js @@ -27,6 +27,9 @@ module.exports = { 'expo-apple-authentication', ], + android: { + package: 'io.metamask', // Required for @expo/repack-app Android repacking + }, ios: { usesAppleSignIn: true, }, diff --git a/package.json b/package.json index b40dd484989b..7be51e698b3d 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "build:android:main:test": "./scripts/build.sh android main test", "build:android:main:e2e": "./scripts/build.sh android main e2e", "build:android:flask:prod": "./scripts/build.sh android flask production", + "fingerprint:generate": "node scripts/generate-fingerprint.js", + "repack:android": "node scripts/repack-e2e.js", "build:android:flask:local": "./scripts/build.sh android flask local", "build:android:flask:test": "./scripts/build.sh android flask test", "build:android:flask:e2e": "./scripts/build.sh android flask e2e", @@ -215,6 +217,7 @@ "@craftzdog/react-native-buffer": "^6.1.0", "@deeeed/hyperliquid-node20": "^0.23.1-node20.1", "@ethersproject/abi": "^5.7.0", + "@expo/fingerprint": "^0.15.0", "@keystonehq/bc-ur-registry-eth": "^0.21.0", "@keystonehq/ur-decoder": "^0.12.2", "@lavamoat/react-native-lockdown": "^0.0.2", @@ -502,6 +505,7 @@ "zxcvbn": "4.4.2" }, "devDependencies": { + "@expo/repack-app": "^0.2.9", "@babel/core": "^7.25.2", "@babel/eslint-parser": "^7.25.1", "@babel/preset-env": "^7.25.3", diff --git a/scripts/generate-fingerprint.js b/scripts/generate-fingerprint.js new file mode 100644 index 000000000000..d9c4904e8386 --- /dev/null +++ b/scripts/generate-fingerprint.js @@ -0,0 +1,15 @@ +const { createFingerprintAsync } = require('@expo/fingerprint'); + +async function generateFingerprint() { + try { + const { hash } = await createFingerprintAsync(process.cwd(), { mode: 'prebuild' }); + // Only output the hash to stdout, no console.log or extra output + process.stdout.write(hash); + } catch (error) { + // Write error to stderr instead of stdout to avoid corrupting the hash output + process.stderr.write(`Error generating fingerprint: ${error.message}\n`); + process.exit(1); + } +} + +generateFingerprint(); diff --git a/scripts/repack-e2e.js b/scripts/repack-e2e.js new file mode 100755 index 000000000000..48030884b1b8 --- /dev/null +++ b/scripts/repack-e2e.js @@ -0,0 +1,172 @@ +#!/usr/bin/env node +/** + * E2E App Repack Script using @expo/repack-app + * Supports Android APK repacking for CI optimization + */ + +const fs = require('fs'); +const path = require('path'); + +/** + * Logger utility + */ +const logger = { + info: (msg) => console.log(`📦 ${msg}`), + success: (msg) => console.log(`✅ ${msg}`), + error: (msg) => console.error(`❌ ${msg}`), + warn: (msg) => console.warn(`⚠️ ${msg}`), +}; + +/** + * Get CI keystore configuration for Android + */ +function getCiKeystoreConfig() { + const isCI = !!process.env.CI; + const keystorePath = process.env.ANDROID_KEYSTORE_PATH; + const keystorePassword = process.env.BITRISEIO_ANDROID_QA_KEYSTORE_PASSWORD; + const keyAlias = process.env.BITRISEIO_ANDROID_QA_KEYSTORE_ALIAS; + const keyPassword = process.env.BITRISEIO_ANDROID_QA_KEYSTORE_PRIVATE_KEY_PASSWORD; + + if (isCI) { + // In CI, all keystore env vars must be set + if (!keystorePath || !keystorePassword || !keyAlias || !keyPassword) { + logger.error( + 'Missing required Android keystore environment variables in CI. ' + + 'Please check that setup-e2e-env action has configure-keystores: true' + ); + process.exit(1); + } + } + + // Use defaults only in local/dev environments + const finalKeystorePath = keystorePath || 'android/app/debug.keystore'; + const finalKeystorePassword = keystorePassword || 'android'; + const finalKeyAlias = keyAlias || 'androiddebugkey'; + const finalKeyPassword = keyPassword || 'android'; + + logger.info(`Using keystore: ${finalKeystorePath}`); + logger.info(`Using key alias: ${finalKeyAlias}`); + + return { + keyStorePath: finalKeystorePath, + // NOTE: @expo/repack-app requires 'pass:' prefix format for inline passwords + // This is not a security issue - it's the tool's required format + keyStorePassword: `pass:${finalKeystorePassword}`, + keyAlias: finalKeyAlias, + keyPassword: `pass:${finalKeyPassword}`, + }; +} + +/** + * Repack Android APK + */ +async function repackAndroid() { + const startTime = Date.now(); + + try { + // Configuration for main APK + const mainSourceApk = 'android/app/build/outputs/apk/prod/release/app-prod-release.apk'; + const mainRepackedApk = 'android/app/build/outputs/apk/prod/release/app-prod-release-repack.apk'; + const mainFinalApk = 'android/app/build/outputs/apk/prod/release/app-prod-release.apk'; + const sourcemapOutputPath = 'sourcemaps/android/index.android.bundle.map'; + + logger.info('🚀 Starting Android E2E APK repack process...'); + logger.info(`Main Source APK: ${mainSourceApk}`); + + // Verify main source APK exists + if (!fs.existsSync(mainSourceApk)) { + throw new Error(`Main APK not found: ${mainSourceApk}`); + } + + // Ensure sourcemap directory exists + const sourcemapDir = path.dirname(sourcemapOutputPath); + if (!fs.existsSync(sourcemapDir)) { + fs.mkdirSync(sourcemapDir, { recursive: true }); + } + + // Dynamic import for ES module compatibility + const { repackAppAndroidAsync } = await import('@expo/repack-app'); + + // Create working directory + const mainWorkingDir = 'android/app/build/repack-working-main'; + if (!fs.existsSync(mainWorkingDir)) { + fs.mkdirSync(mainWorkingDir, { recursive: true }); + logger.info(`Created working directory: ${mainWorkingDir}`); + } + + // Get CI keystore configuration + const signingOptions = getCiKeystoreConfig(); + + // Repack main APK with updated JavaScript bundle + logger.info('⏱️ Repacking main APK with updated JavaScript...'); + await repackAppAndroidAsync({ + platform: 'android', + projectRoot: process.cwd(), + sourceAppPath: mainSourceApk, + outputPath: mainRepackedApk, + workingDirectory: mainWorkingDir, + verbose: true, + androidSigningOptions: signingOptions, + exportEmbedOptions: { + sourcemapOutput: sourcemapOutputPath, + }, + env: process.env, + }); + + // Verify repacked APK was created + if (!fs.existsSync(mainRepackedApk)) { + throw new Error(`Repacked main APK not found: ${mainRepackedApk}`); + } + + // Copy repacked APK to final location + logger.info('Copying repacked APK to final location...'); + fs.copyFileSync(mainRepackedApk, mainFinalApk); + + // Clean up temporary files + fs.unlinkSync(mainRepackedApk); + fs.rmSync(mainWorkingDir, { recursive: true, force: true }); + logger.info('Cleaned up temporary files'); + + const duration = Math.round((Date.now() - startTime) / 1000); + logger.success(`🎉 Android APK repack completed in ${duration}s`); + + // Check sourcemap + if (fs.existsSync(sourcemapOutputPath)) { + logger.success(`Sourcemap: ${sourcemapOutputPath}`); + } + + } catch (error) { + logger.error(`Android repack failed: ${error.message}`); + throw error; + } +} + +/** + * Main entry point + */ +async function main() { + // This script is now Android-specific + logger.info(`🔧 Repack Platform: ANDROID`); + logger.info(`📍 Working Directory: ${process.cwd()}`); + logger.info(`🌍 Environment: ${process.env.CI ? 'CI' : 'Local'}`); + + try { + await repackAndroid(); + } catch (error) { + logger.error(`Repack process failed: ${error.message}`); + if (error.stack) { + logger.error(`Stack trace: ${error.stack}`); + } + process.exit(1); + } +} + +// Run the main process +if (require.main === module) { + main().catch(error => { + console.error(`❌ Unhandled error: ${error.message}`); + process.exit(1); + }); +} + +module.exports = { main, repackAndroid }; diff --git a/yarn.lock b/yarn.lock index e2df67b9fab7..6606239fbac2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2781,6 +2781,23 @@ resolve-from "^5.0.0" semver "^7.6.0" +"@expo/fingerprint@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.15.0.tgz#4e8d8e5b5ec96c6132ef3426dc314a36130304af" + integrity sha512-PrLA6fxScZfnLy7OHZ2GHXsDG9YbE7L5DbNhion6j/U/O+FQgz4VbxJarW5C00kMg1ll2u6EghB7ENAvL1T4qg== + dependencies: + "@expo/spawn-async" "^1.7.2" + arg "^5.0.2" + chalk "^4.1.2" + debug "^4.3.4" + getenv "^2.0.0" + glob "^10.4.2" + ignore "^5.3.1" + minimatch "^9.0.0" + p-limit "^3.1.0" + resolve-from "^5.0.0" + semver "^7.6.0" + "@expo/image-utils@^0.6.5": version "0.6.5" resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.6.5.tgz#af25e9dd79d1168bebea91dc7f1e6f3efd28643c" @@ -2884,6 +2901,14 @@ semver "^7.6.0" xml2js "0.6.0" +"@expo/repack-app@^0.2.9": + version "0.2.9" + resolved "https://registry.yarnpkg.com/@expo/repack-app/-/repack-app-0.2.9.tgz#4386e5054b38cc02ccbfdc47e3a5f83e8966b704" + integrity sha512-ZkFacmnJMZ6Wzt73crL1Bc+cpsXzU0wG+MkXerdYx1wXa89PlLrOdIF0s1RlTbmjEBcjR/aYOKnBnwTngSsikg== + dependencies: + commander "^13.1.0" + picocolors "^1.1.1" + "@expo/rudder-sdk-node@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@expo/rudder-sdk-node/-/rudder-sdk-node-1.1.1.tgz#6aa575f346833eb6290282118766d4919c808c6a" @@ -21472,6 +21497,11 @@ getenv@^1.0.0: resolved "https://registry.yarnpkg.com/getenv/-/getenv-1.0.0.tgz#874f2e7544fbca53c7a4738f37de8605c3fcfc31" integrity sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg== +getenv@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/getenv/-/getenv-2.0.0.tgz#b1698c7b0f29588f4577d06c42c73a5b475c69e0" + integrity sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ== + git-repo-info@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/git-repo-info/-/git-repo-info-2.1.1.tgz#220ffed8cbae74ef8a80e3052f2ccb5179aed058" @@ -25427,7 +25457,7 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.1, minimatch@^9.0.4: +minimatch@^9.0.0, minimatch@^9.0.1, minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==