diff --git a/.detoxrc.js b/.detoxrc.js index 9e62492d7a2e..09612ab74bb1 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -132,25 +132,25 @@ module.exports = { 'android.debug': { type: 'android.apk', binaryPath: process.env.PREBUILT_ANDROID_APK_PATH || 'android/app/build/outputs/apk/prod/debug/app-prod-debug.apk', - testBinaryPath: process.env.PREBUILT_ANDROID_TEST_APK_PATH, + testBinaryPath: process.env.PREBUILT_ANDROID_TEST_APK_PATH || 'android/app/build/outputs/apk/androidTest/prod/debug/app-prod-debug-androidTest.apk', build: 'export CONFIGURATION="Debug" && yarn build:android:main:e2e', }, 'android.release': { type: 'android.apk', binaryPath: process.env.PREBUILT_ANDROID_APK_PATH || 'android/app/build/outputs/apk/prod/release/app-prod-release.apk', - testBinaryPath: process.env.PREBUILT_ANDROID_TEST_APK_PATH, + testBinaryPath: process.env.PREBUILT_ANDROID_TEST_APK_PATH || 'android/app/build/outputs/apk/androidTest/prod/release/app-prod-release-androidTest.apk', build: `export CONFIGURATION="Release" && yarn build:android:main:e2e`, }, 'android.flask.debug': { type: 'android.apk', binaryPath: process.env.PREBUILT_ANDROID_APK_PATH || 'android/app/build/outputs/apk/flask/debug/app-flask-debug.apk', - testBinaryPath: process.env.PREBUILT_ANDROID_TEST_APK_PATH, + testBinaryPath: process.env.PREBUILT_ANDROID_TEST_APK_PATH || 'android/app/build/outputs/apk/androidTest/flask/debug/app-flask-debug-androidTest.apk', build: 'export CONFIGURATION="Debug" && yarn build:android:flask:e2e', }, 'android.flask.release': { type: 'android.apk', binaryPath: process.env.PREBUILT_ANDROID_APK_PATH || 'android/app/build/outputs/apk/flask/release/app-flask-release.apk', - testBinaryPath: process.env.PREBUILT_ANDROID_TEST_APK_PATH, + testBinaryPath: process.env.PREBUILT_ANDROID_TEST_APK_PATH || 'android/app/build/outputs/apk/androidTest/flask/release/app-flask-release-androidTest.apk', build: `export CONFIGURATION="Release" && yarn build:android:flask:e2e`, }, }, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f64a6ef52157..3edbae370cbe 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -42,7 +42,6 @@ app/core/Engine/types.ts @MetaMask/mobile-pla app/core/Engine/controllers/remote-feature-flag-controller/ @MetaMask/mobile-platform app/core/DeeplinkManager @MetaMask/mobile-platform scripts/build.sh @MetaMask/mobile-platform -scripts/update-expo-channel.js @MetaMask/mobile-admins # Platform & Snaps Code Fencing File metro.transform.js @MetaMask/mobile-platform @MetaMask/core-platform diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 5c9502a98387..8cba763d005b 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -72,13 +72,13 @@ jobs: echo "🚀 Setting up project..." yarn setup:github-ci --no-build-ios - # Generate fingerprint AFTER setup but BEFORE any build modifications (the fingerprint now is fake we do not want the cached apk) - - name: Generate current fingerprint - id: generate-fingerprint - run: | - FINGERPRINT=$(yarn fingerprint:generate) - echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" - echo "Current fingerprint: ${FINGERPRINT}" + # # Generate fingerprint AFTER setup but BEFORE any build modifications (the fingerprint now is fake we do not want the cached apk) + # - name: Generate current fingerprint + # id: generate-fingerprint + # run: | + # FINGERPRINT=$(yarn fingerprint:generate) + # echo "fingerprint=$FINGERPRINT" >> "$GITHUB_OUTPUT" + # echo "Current fingerprint: ${FINGERPRINT}" - name: Determine target paths and Artifact Names id: determine-target-paths @@ -102,26 +102,25 @@ jobs: exit 1 fi - - name: Check and restore cached APKs if Fingerprint is found - id: apk-cache-restore - uses: cirruslabs/cache@v4 - with: - path: | - ${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk - ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk - ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab - # Include Gradle properties in key to force rebuild when properties change - # Keep the `hashFiles` call for Gradle config in-sync with these steps: - # - "Cache Gradle dependencies" - # - "Cache build artifacts" - key: android-apk-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - android-apk-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}- - android-apk- + # - name: Check and restore cached APKs if Fingerprint is found + # id: apk-cache-restore + # uses: cirruslabs/cache@v4 + # with: + # path: | + # ${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk + # ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk + # ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab + # # Include Gradle properties in key to force rebuild when properties change + # # Keep the `hashFiles` call for Gradle config in-sync with these steps: + # # - "Cache Gradle dependencies" + # # - "Cache build artifacts" + # key: android-apk-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + # restore-keys: | + # android-apk-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}- + # android-apk- - name: Cache Gradle dependencies uses: cirruslabs/cache@v4 - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' }} env: GRADLE_CACHE_VERSION: 1 with: @@ -134,7 +133,6 @@ jobs: key: gradle-${{ env.GRADLE_CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Build Android E2E APKs - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' }} run: | echo "🏗 Building Android E2E APKs..." export NODE_OPTIONS="--max-old-space-size=8192" @@ -183,68 +181,68 @@ 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.apk-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 build:repack:android - echo "đŸ“Ļ Final APK size: $(du -h "${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk" | cut -f1)" - env: - PLATFORM: android - METAMASK_ENVIRONMENT: ${{ inputs.metamask_environment }} - METAMASK_BUILD_TYPE: ${{ inputs.build_type }} - IS_TEST: true - E2E: 'true' - IGNORE_BOXLOGS_DEVELOPMENT: true - GITHUB_CI: 'true' - CI: 'true' - NODE_OPTIONS: '--max-old-space-size=8192' - 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' - 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_WRITE_KEY_FLASK: ${{ secrets.SEGMENT_WRITE_KEY_FLASK }} - SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} - SEGMENT_PROXY_URL_FLASK: ${{ secrets.SEGMENT_PROXY_URL_FLASK }} - SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} - SEGMENT_DELETE_API_SOURCE_ID_FLASK: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_FLASK }} - SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} - SEGMENT_REGULATIONS_ENDPOINT_FLASK: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_FLASK }} - 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 }} - FLASK_IOS_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_IOS_GOOGLE_CLIENT_ID_PROD }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - FLASK_IOS_GOOGLE_REDIRECT_URI_PROD: ${{ secrets.FLASK_IOS_GOOGLE_REDIRECT_URI_PROD }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - FLASK_ANDROID_APPLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_APPLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD }} - 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 }} + # - name: Repack APK with JS updates using @expo/repack-app + # if: ${{ steps.apk-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 build:repack:android + # echo "đŸ“Ļ Final APK size: $(du -h "${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk" | cut -f1)" + # env: + # PLATFORM: android + # METAMASK_ENVIRONMENT: ${{ inputs.metamask_environment }} + # METAMASK_BUILD_TYPE: ${{ inputs.build_type }} + # IS_TEST: true + # E2E: 'true' + # IGNORE_BOXLOGS_DEVELOPMENT: true + # GITHUB_CI: 'true' + # CI: 'true' + # NODE_OPTIONS: '--max-old-space-size=8192' + # 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' + # 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_WRITE_KEY_FLASK: ${{ secrets.SEGMENT_WRITE_KEY_FLASK }} + # SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} + # SEGMENT_PROXY_URL_FLASK: ${{ secrets.SEGMENT_PROXY_URL_FLASK }} + # SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} + # SEGMENT_DELETE_API_SOURCE_ID_FLASK: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_FLASK }} + # SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} + # SEGMENT_REGULATIONS_ENDPOINT_FLASK: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_FLASK }} + # 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 }} + # FLASK_IOS_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_IOS_GOOGLE_CLIENT_ID_PROD }} + # MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} + # FLASK_IOS_GOOGLE_REDIRECT_URI_PROD: ${{ secrets.FLASK_IOS_GOOGLE_REDIRECT_URI_PROD }} + # MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} + # FLASK_ANDROID_APPLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_APPLE_CLIENT_ID_PROD }} + # MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} + # FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD }} + # MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} + # FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD }} + # 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.apk-cache-restore.outputs.cache-hit != 'true' }} - uses: cirruslabs/cache@v4 - with: - path: | - ${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk - ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk - ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab - # Keep the `hashFiles` call for Gradle config in-sync with these steps: - # - "Check and restore cached APKs if Fingerprint is found" - # - "Cache Gradle dependencies" - key: android-apk-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + # - name: Cache build artifacts + # if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' }} + # uses: cirruslabs/cache@v4 + # with: + # path: | + # ${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk + # ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk + # ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab + # # Keep the `hashFiles` call for Gradle config in-sync with these steps: + # # - "Check and restore cached APKs if Fingerprint is found" + # # - "Cache Gradle dependencies" + # key: android-apk-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Upload Android APK id: upload-apk diff --git a/.js.env.example b/.js.env.example index 80a050df90d3..9b60cf2789a1 100644 --- a/.js.env.example +++ b/.js.env.example @@ -44,9 +44,6 @@ export WALLET_CONNECT_PROJECT_ID="" # Default PORT for metro export WATCHER_PORT=8081 -# Expo Project ID for OTA updates -export EXPO_PROJECT_ID="" - # Environment: "production", "pre-release" or "dev" export METAMASK_ENVIRONMENT="dev" diff --git a/android/gradle.properties.github b/android/gradle.properties.github index 80d55b22be0d..768591f08511 100644 --- a/android/gradle.properties.github +++ b/android/gradle.properties.github @@ -1,16 +1,17 @@ # GitHub Actions-specific Gradle settings -# High-performance settings for 64GB/16CPU runners +# Optimized for E2E builds on GitHub Actions runners -# JVM configuration - high-performance for GitHub Actions -org.gradle.jvmargs=-Xmx32g -XX:MaxMetaspaceSize=2g -XX:+UseG1GC -XX:G1HeapRegionSize=32m -XX:+UseStringDeduplication -XX:+OptimizeStringConcat +# JVM configuration - balanced settings to avoid OOM while maintaining performance +# Using 16GB heap to leave room for parallel workers and native memory +org.gradle.jvmargs=-Xmx16g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:+UseStringDeduplication -XX:+OptimizeStringConcat -# Enable all performance optimizations for GitHub Actions +# Enable performance optimizations but limit parallelism to prevent OOM org.gradle.parallel=true org.gradle.configureondemand=true org.gradle.caching=true org.gradle.daemon=true -org.gradle.workers.max=12 -org.gradle.vfs.watch=true +org.gradle.workers.max=6 +org.gradle.vfs.watch=false # CI-specific optimizations - enabled for GitHub Actions kotlin.incremental=true diff --git a/app.config.js b/app.config.js index 575703111504..8c4a31c76b45 100644 --- a/app.config.js +++ b/app.config.js @@ -1,5 +1,3 @@ -const { RUNTIME_VERSION, PROJECT_ID, UPDATE_URL } = require('./ota.config.js'); - module.exports = { name: 'MetaMask', displayName: 'MetaMask', @@ -17,9 +15,7 @@ module.exports = { '../../node_modules/@notifee/react-native/android/libs', ], }, - ios: { - jsEngine: 'hermes', - }, + ios: {}, }, ], [ @@ -40,28 +36,5 @@ module.exports = { ios: { bundleIdentifier: 'io.metamask.MetaMask', usesAppleSignIn: true, - jsEngine: 'hermes', - }, - expo: { - owner: 'metamask-test', - runtimeVersion: RUNTIME_VERSION, - updates: { - url: UPDATE_URL, - // Channel is set by requestHeaders, will be overridden with build script - requestHeaders: { - 'expo-channel-name': 'preview', - }, - }, - extra: { - eas: { - projectId: PROJECT_ID, - }, - }, - android: { - package: 'io.metamask', - }, - ios: { - bundleIdentifier: 'io.metamask.MetaMask', - }, }, }; diff --git a/app/__mocks__/expo-updates.ts b/app/__mocks__/expo-updates.ts deleted file mode 100644 index ca5c0d9c3cc3..000000000000 --- a/app/__mocks__/expo-updates.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Mock for expo-updates module -export const channel = 'test-channel'; -export const runtimeVersion = '1.0.0'; -export const isEmbeddedLaunch = true; -export const isEnabled = true; - -export const checkForUpdateAsync = jest.fn(); -export const fetchUpdateAsync = jest.fn(); -export const reloadAsync = jest.fn(); -export const useUpdates = jest.fn(); - -export const UpdateEventType = { - ERROR: 'error', - NO_UPDATE_AVAILABLE: 'noUpdateAvailable', - UPDATE_AVAILABLE: 'updateAvailable', -}; - -export const UpdateCheckResult = { - isAvailable: false, - manifest: null, -}; - -export default { - channel, - runtimeVersion, - isEmbeddedLaunch, - isEnabled, - checkForUpdateAsync, - fetchUpdateAsync, - reloadAsync, - useUpdates, - UpdateEventType, - UpdateCheckResult, -}; diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 30161217c0d9..8b30f6fa5373 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -128,6 +128,13 @@ import RewardsClaimBottomSheetModal from '../../UI/Rewards/components/Tabs/Level import RewardOptInAccountGroupModal from '../../UI/Rewards/components/Settings/RewardOptInAccountGroupModal'; import ReferralBottomSheetModal from '../../UI/Rewards/components/ReferralBottomSheetModal'; import { selectRewardsSubscriptionId } from '../../../selectors/rewards'; +import { getImportTokenNavbarOptions } from '../../UI/Navbar'; +import { + TOKEN_TITLE, + NFT_TITLE, + TOKEN, +} from '../../Views/AddAsset/AddAsset.constants'; +import { strings } from '../../../../locales/i18n'; const Stack = createStackNavigator(); const Tab = createBottomTabNavigator(); @@ -988,7 +995,15 @@ const MainNavigator = () => { ({ + ...getImportTokenNavbarOptions( + navigation, + strings( + `add_asset.${route.params?.assetType === TOKEN ? TOKEN_TITLE : NFT_TITLE}`, + ), + ), + headerShown: true, + })} /> ({ init: () => mockedEngine.init({}), context: { @@ -60,20 +64,56 @@ const initialState = { }; describe('AssetSearch', () => { - it('renders correctly with selected chain', () => { + beforeEach(() => { + jest.clearAllTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + const mockAllTokens = [ + { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + chainId: '0x1' as const, + }, + ]; + + it('renders correctly with allTokens', () => { const { toJSON } = renderWithProvider( , { state: initialState }, ); expect(toJSON()).toMatchSnapshot(); }); - it('calls onSearch when clear button is pressed', () => { + it('calls onSearch on mount with initial empty results and search query', () => { + const onSearch = jest.fn(); + + renderWithProvider( + , + { state: initialState }, + ); + + expect(onSearch).toHaveBeenCalledWith({ + results: [], + searchQuery: '', + }); + }); + + it('calls onSearch when clear button is pressed with empty results and search query', () => { const onSearch = jest.fn(); const { getByTestId } = renderWithProvider( @@ -81,29 +121,86 @@ describe('AssetSearch', () => { onSearch={onSearch} onFocus={jest.fn} onBlur={jest.fn} - selectedChainId={'0x1'} + allTokens={mockAllTokens} />, { state: initialState }, ); + // Clear initial mount call + onSearch.mockClear(); + + // First, set a search value + const searchBar = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); + fireEvent.changeText(searchBar, 'SNX'); + + // Advance timers to trigger the debounce (300ms default) + act(() => { + jest.advanceTimersByTime(500); + }); + + // Wait for the search to complete and clear previous calls + expect(onSearch).toHaveBeenCalled(); + onSearch.mockClear(); + + // Now clear the search const clearSearchBar = getByTestId( ImportTokenViewSelectorsIDs.CLEAR_SEARCH_BAR, ); fireEvent.press(clearSearchBar); - expect(onSearch).toHaveBeenCalled(); + // Advance timers to trigger the debounce and useEffect + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(onSearch).toHaveBeenCalledWith( + expect.objectContaining({ + results: [], + searchQuery: '', + }), + ); }); - it('renders with null selectedChainId', () => { + it('renders with empty allTokens array', () => { const { toJSON } = renderWithProvider( , { state: initialState }, ); expect(toJSON()).toBeDefined(); }); + + it('calls onSearch with searchResults and debouncedSearchString when search text changes', () => { + const onSearch = jest.fn(); + + const { getByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + onSearch.mockClear(); + + const searchBar = getByTestId(ImportTokenViewSelectorsIDs.SEARCH_BAR); + fireEvent.changeText(searchBar, 'SNX'); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(onSearch).toHaveBeenCalledWith( + expect.objectContaining({ + results: expect.any(Array), + searchQuery: 'SNX', + }), + ); + }); }); diff --git a/app/components/UI/AssetSearch/index.tsx b/app/components/UI/AssetSearch/index.tsx index ca639468e129..b8a4e27f6744 100644 --- a/app/components/UI/AssetSearch/index.tsx +++ b/app/components/UI/AssetSearch/index.tsx @@ -1,15 +1,9 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import { TextInput, View, StyleSheet, TextStyle } from 'react-native'; -import { Hex } from '@metamask/utils'; import { fontStyles } from '../../../styles/common'; import { strings } from '../../../../locales/i18n'; -import Fuse from 'fuse.js'; -import { toLowerCaseEquals } from '../../../util/general'; -import { useSelector } from 'react-redux'; -import { TokenListToken } from '@metamask/assets-controllers'; import { useTheme } from '../../../util/theme'; import { ImportTokenViewSelectorsIDs } from '../../../../e2e/selectors/wallet/ImportTokenView.selectors'; -import { selectERC20TokensByChain } from '../../../selectors/tokenListController'; import Icon, { IconName, IconSize, @@ -17,14 +11,14 @@ import Icon, { import ButtonIcon, { ButtonIconSizes, } from '../../../component-library/components/Buttons/ButtonIcon'; +import { BridgeToken } from '../Bridge/types'; +import { useTokenSearch } from '../Bridge/hooks/useTokenSearch'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const createStyles = (colors: any) => StyleSheet.create({ searchSection: { - marginTop: 16, - flex: 1, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', @@ -34,9 +28,7 @@ const createStyles = (colors: any) => color: colors.text.default, }, searchSectionFocused: { - marginTop: 16, marginBottom: 0, - flex: 1, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', @@ -48,7 +40,6 @@ const createStyles = (colors: any) => textInput: { ...fontStyles.normal, color: colors.text.default, - flex: 1, } as TextStyle, icon: { paddingLeft: 20, @@ -67,25 +58,12 @@ const createStyles = (colors: any) => }, }); -const fuse = new Fuse([], { - shouldSort: true, - threshold: 0.45, - location: 0, - distance: 100, - maxPatternLength: 32, - minMatchCharLength: 1, - keys: [ - { name: 'name', weight: 0.5 }, - { name: 'symbol', weight: 0.5 }, - ], -}); - interface Props { onSearch: ({ results, searchQuery, }: { - results: TokenListToken[]; + results: BridgeToken[]; searchQuery: string; }) => void; /** @@ -100,57 +78,27 @@ interface Props { /** * The selected network chain ID */ - selectedChainId: Hex | null; + allTokens: BridgeToken[]; } // eslint-disable-next-line react/display-name -const AssetSearch = ({ onSearch, onFocus, onBlur, selectedChainId }: Props) => { - const [searchQuery, setSearchQuery] = useState(''); +const AssetSearch = ({ onSearch, onFocus, onBlur, allTokens }: Props) => { const [isFocus, setIsFocus] = useState(false); - const tokenListForAllChains = useSelector(selectERC20TokensByChain); const { colors, themeAppearance } = useTheme(); const styles = createStyles(colors); - const tokenList = useMemo(() => { - // If no network is selected, return empty list - if (!selectedChainId) { - return []; - } - - // Use the selected network's tokens - return Object.values( - tokenListForAllChains?.[selectedChainId]?.data ?? [], - ).map((item) => ({ - ...item, - chainId: selectedChainId, - })); - }, [selectedChainId, tokenListForAllChains]); - - // Update fuse list - useEffect(() => { - if (Array.isArray(tokenList)) { - fuse.setCollection(tokenList); - } - }, [tokenList]); - - const handleSearch = useCallback( - (searchText: string) => { - setSearchQuery(searchText); - const fuseSearchResult = fuse.search(searchText); - const addressSearchResult = tokenList?.filter((token: TokenListToken) => - toLowerCaseEquals(token.address, searchText), - ); - const results = [...addressSearchResult, ...fuseSearchResult]; - onSearch({ searchQuery: searchText, results }); - }, - [setSearchQuery, onSearch, tokenList], - ); + const { + searchString, + setSearchString, + searchResults, + debouncedSearchString, + } = useTokenSearch({ + tokens: allTokens || [], + }); useEffect(() => { - setSearchQuery(''); - handleSearch(''); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedChainId]); + onSearch({ results: searchResults, searchQuery: debouncedSearchString }); + }, [searchResults, debouncedSearchString, onSearch]); return ( { { onFocus(); setIsFocus(true); @@ -175,7 +123,7 @@ const AssetSearch = ({ onSearch, onFocus, onBlur, selectedChainId }: Props) => { }} placeholder={strings('token.search_tokens_placeholder')} placeholderTextColor={colors.text.muted} - onChangeText={handleSearch} + onChangeText={(searchText) => setSearchString(searchText)} testID={ImportTokenViewSelectorsIDs.SEARCH_BAR} keyboardAppearance={themeAppearance} /> @@ -186,8 +134,7 @@ const AssetSearch = ({ onSearch, onFocus, onBlur, selectedChainId }: Props) => { size={ButtonIconSizes.Sm} iconName={IconName.Close} onPress={() => { - setSearchQuery(''); - handleSearch(''); + setSearchString(''); }} testID={ImportTokenViewSelectorsIDs.CLEAR_SEARCH_BAR} /> diff --git a/app/components/UI/Bridge/hooks/useTopTokens/index.ts b/app/components/UI/Bridge/hooks/useTopTokens/index.ts index ff99a0e6d026..647208f7d541 100644 --- a/app/components/UI/Bridge/hooks/useTopTokens/index.ts +++ b/app/components/UI/Bridge/hooks/useTopTokens/index.ts @@ -99,14 +99,21 @@ export const useTopTokens = ({ } => { const swapsChainCache: SwapsControllerState['chainCache'] = useSelector(selectChainCache); - const swapsTopAssets = useMemo( - () => (chainId ? swapsChainCache[chainId]?.topAssets : null), - [chainId, swapsChainCache], - ); - // For non-EVM chains, we don't need to fetch top assets from the Swaps API - const swapsTopAssetsPending = isCaipChainId(chainId) - ? false - : !swapsTopAssets; + const { swapsTopAssets, swapsTopAssetsPending } = useMemo(() => { + if (!chainId) { + return { swapsTopAssets: null, swapsTopAssetsPending: true }; + } + + // For non-EVM chains, we don't need to fetch top assets from the Swaps API + if (isCaipChainId(chainId)) { + return { swapsTopAssets: null, swapsTopAssetsPending: false }; + } + + return { + swapsTopAssets: swapsChainCache[chainId]?.topAssets || null, + swapsTopAssetsPending: false, + }; + }, [chainId, swapsChainCache]); // Get cached tokens from TokenListController const cachedEvmTokensByChain = useSelector(selectERC20TokensByChain); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index f9aee33a798d..85cd8d736af8 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -253,6 +253,11 @@ jest.mock('../../util/getHighestFiatToken', () => ({ getHighestFiatToken: jest.fn(() => mockPriorityToken), })); +// Mock isSolanaChainId +jest.mock('@metamask/bridge-controller', () => ({ + isSolanaChainId: jest.fn(), +})); + // Mock Logger jest.mock('../../../../../util/Logger', () => ({ error: jest.fn(), @@ -289,6 +294,7 @@ jest.mock('../../../../../core/Engine', () => ({ // Import the Engine to get typed references to the mocked functions import Engine from '../../../../../core/Engine'; import { CardHomeSelectors } from '../../../../../../e2e/selectors/Card/CardHome.selectors'; +import { isSolanaChainId } from '@metamask/bridge-controller'; // Get references to the mocked functions const mockSetActiveNetwork = Engine.context.NetworkController @@ -312,6 +318,10 @@ const mockSetSelectedAccount = Engine.context.AccountsController typeof Engine.context.AccountsController.setSelectedAccount >; +const mockIsSolanaChainId = isSolanaChainId as jest.MockedFunction< + typeof isSolanaChainId +>; + jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => { const strings: { [key: string]: string } = { @@ -508,6 +518,7 @@ describe('CardHome Component', () => { methods: [], }); mockSetSelectedAccount.mockClear(); + mockIsSolanaChainId.mockReturnValue(false); // Setup hook mocks with default values (useLoadCardData as jest.Mock).mockReturnValue({ @@ -1923,6 +1934,81 @@ describe('CardHome Component', () => { }); describe('Unsupported Tokens for Spending Limit', () => { + it('hides progress bar for Solana chain', () => { + // Given: authenticated with Solana chain and limited allowance + setupMockSelectors({ isAuthenticated: true }); + mockIsSolanaChainId.mockReturnValue(true); + const solanaToken = { + ...mockPriorityToken, + caipChainId: 'solana:mainnet', + allowanceState: AllowanceState.Limited, + totalAllowance: '1000', + allowance: '500', + }; + setupLoadCardDataMock({ + priorityToken: solanaToken, + allTokens: [solanaToken], + isAuthenticated: true, + warning: null, + }); + + // When: component renders + render(); + + // Then: should not display spending limit progress bar + expect(screen.queryByText('Spending Limit')).not.toBeOnTheScreen(); + }); + + it('hides manage spending limit button for Solana chain', () => { + // Given: authenticated with Solana chain + setupMockSelectors({ isAuthenticated: true }); + mockIsSolanaChainId.mockReturnValue(true); + const solanaToken = { + ...mockPriorityToken, + caipChainId: 'solana:mainnet', + allowanceState: AllowanceState.Limited, + }; + setupLoadCardDataMock({ + priorityToken: solanaToken, + allTokens: [solanaToken], + isAuthenticated: true, + warning: null, + }); + + // When: component renders + render(); + + // Then: should not display manage spending limit button + expect( + screen.queryByTestId(CardHomeSelectors.MANAGE_SPENDING_LIMIT_ITEM), + ).not.toBeOnTheScreen(); + }); + + it('hides close spending limit warning for Solana chain', () => { + // Given: authenticated with Solana chain and close to limit (15% remaining) + setupMockSelectors({ isAuthenticated: true }); + mockIsSolanaChainId.mockReturnValue(true); + const solanaToken = { + ...mockPriorityToken, + caipChainId: 'solana:mainnet', + allowanceState: AllowanceState.Limited, + totalAllowance: '1000', + allowance: '150', // 15% remaining (below 20% threshold) + }; + setupLoadCardDataMock({ + priorityToken: solanaToken, + allTokens: [solanaToken], + isAuthenticated: true, + warning: null, + }); + + // When: component renders + render(); + + // Then: should not show close spending limit warning + expect(screen.queryByText('Spending Limit')).not.toBeOnTheScreen(); + }); + it('hides progress bar for unsupported token (aUSDC)', () => { // Given: authenticated with aUSDC (unsupported token) and limited allowance setupMockSelectors({ isAuthenticated: true }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 2e66c1931e49..1ba9b8d633e2 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -483,13 +483,18 @@ const CardHome = () => { testID={CardHomeSelectors.ADD_FUNDS_BUTTON} /> ); - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ addFundsAction, - priorityTokenWarning, + changeAssetAction, + enableCardAction, + isBaanxLoginEnabled, isLoading, + isLoadingPollCardStatusUntilProvisioned, + isLoadingProvisionCard, isSwapEnabledForPriorityToken, + needToEnableAssets, + needToEnableCard, + styles, ]); // Handle authentication errors (expired token, invalid credentials, etc.) @@ -528,13 +533,16 @@ const CardHome = () => { * Some tokens (e.g., aUSDC) have different allowance behavior and are unsupported. */ const isSpendingLimitSupported = useMemo(() => { - if (!priorityToken?.symbol) { + if ( + !priorityToken?.symbol || + isSolanaChainId(priorityToken.caipChainId ?? '') + ) { return false; } return !SPENDING_LIMIT_UNSUPPORTED_TOKENS.includes( priorityToken.symbol.toUpperCase(), ); - }, [priorityToken?.symbol]); + }, [priorityToken?.symbol, priorityToken?.caipChainId]); /** * This warning is shown when the user is close to their spending limit. diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.styles.ts b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.styles.ts index 296c8350cfa2..72e15e215df8 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.styles.ts +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.styles.ts @@ -3,15 +3,18 @@ import { Theme } from '../../../../../util/theme/models'; const createStyles = (theme: Theme) => StyleSheet.create({ - wrapper: { + safeAreaView: { flex: 1, backgroundColor: theme.colors.background.default, }, - contentContainer: { + wrapper: { flexGrow: 1, paddingHorizontal: 16, + }, + contentContainer: { + flexGrow: 1, paddingTop: 16, - paddingBottom: 32, + paddingBottom: 16, }, assetContainer: { marginBottom: 24, diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx index 68eb3b4d80ab..596a76ee280b 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx @@ -5,7 +5,7 @@ import React, { useContext, useMemo, } from 'react'; -import { ScrollView, TouchableOpacity, View, TextInput } from 'react-native'; +import { TouchableOpacity, View, TextInput } from 'react-native'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { useTheme } from '../../../../../util/theme'; import { @@ -52,6 +52,8 @@ import { mapCaipChainIdToChainName } from '../../util/mapCaipChainIdToChainName' import { clearCacheData } from '../../../../../core/redux/slices/card'; import { useDispatch } from 'react-redux'; import Routes from '../../../../../constants/navigation/Routes'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; const getNetworkFromCaipChainId = (caipChainId: string): CardNetwork => { if (caipChainId === SolScope.Mainnet || caipChainId.startsWith('solana:')) { @@ -472,174 +474,184 @@ const SpendingLimit = ({ }, [tempSelectedOption, isSolanaSelected, customLimit]); return ( - - - - {renderSelectedToken()} - - + + + + + {renderSelectedToken()} + + - - {!showOptions ? ( - // Initial view - only show full access option without radio button - - - {strings('card.card_spending_limit.full_access_title')} - - - {strings('card.card_spending_limit.full_access_description')} - - - - {strings('card.card_spending_limit.set_new_limit')} + + {!showOptions ? ( + // Initial view - only show full access option without radio button + + + {strings('card.card_spending_limit.full_access_title')} - - - ) : ( - // Options view - show both options with radio buttons in a single container - - { - handleOptionSelect('full'); - trackEvent( - createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) - .addProperties({ - action: CardActions.ENABLE_TOKEN_FULL_ACCESS_BUTTON, - }) - .build(), - ); - }} - > - - - {tempSelectedOption === 'full' && ( - - )} - - - {strings('card.card_spending_limit.full_access_title')} - - {strings('card.card_spending_limit.full_access_description')} - - - handleOptionSelect('restricted')} - > - - - {tempSelectedOption === 'restricted' && ( - - )} + + + {strings('card.card_spending_limit.set_new_limit')} + + + + ) : ( + // Options view - show both options with radio buttons in a single container + + { + handleOptionSelect('full'); + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + action: CardActions.ENABLE_TOKEN_FULL_ACCESS_BUTTON, + }) + .build(), + ); + }} + > + + + {tempSelectedOption === 'full' && ( + + )} + + + {strings('card.card_spending_limit.full_access_title')} + + + + {strings('card.card_spending_limit.full_access_description')} + + + + handleOptionSelect('restricted')} + > + + + {tempSelectedOption === 'restricted' && ( + + )} + + + {strings('card.card_spending_limit.restricted_limit_title')} + - {strings('card.card_spending_limit.restricted_limit_title')} + {strings( + 'card.card_spending_limit.restricted_limit_description', + )} - + {tempSelectedOption === 'restricted' && ( + + { + // Allow only numbers and decimal point + const sanitized = text.replace(/[^0-9.]/g, ''); + // Prevent multiple decimal points + const parts = sanitized.split('.'); + const formatted = + parts.length > 2 + ? parts[0] + '.' + parts.slice(1).join('') + : sanitized; + setCustomLimit(formatted); + }} + placeholder="0" + placeholderTextColor={theme.colors.text.muted} + keyboardType="decimal-pad" + returnKeyType="done" + /> + + )} + + + )} + + + + {isSolanaSelected && ( + + - {strings( - 'card.card_spending_limit.restricted_limit_description', - )} + {strings('card.card_spending_limit.solana_not_supported')} - {tempSelectedOption === 'restricted' && ( - - { - // Allow only numbers and decimal point - const sanitized = text.replace(/[^0-9.]/g, ''); - // Prevent multiple decimal points - const parts = sanitized.split('.'); - const formatted = - parts.length > 2 - ? parts[0] + '.' + parts.slice(1).join('') - : sanitized; - setCustomLimit(formatted); - }} - placeholder="0" - placeholderTextColor={theme.colors.text.muted} - keyboardType="decimal-pad" - returnKeyType="done" - /> - - )} - - - )} - - - - {isSolanaSelected && ( - - - - {strings('card.card_spending_limit.solana_not_supported')} - - - )} -