From d540b757d5230b317edfafa6dc059c3400859b11 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 28 Jun 2024 13:13:44 +0200 Subject: [PATCH 01/59] fix: Stop running fencing logic on `node_modules` (#10164) ## **Description** Stops running fencing logic on `node_modules`, mirroring the extension implementation. There is no reason for us to run fencing on code coming from `node_modules` anyway. This may have a potential positive performance impact as well during build-time. --- metro.transform.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/metro.transform.js b/metro.transform.js index 06560242cf5..0fc4f1dc32f 100644 --- a/metro.transform.js +++ b/metro.transform.js @@ -59,7 +59,10 @@ module.exports.transform = async ({ src, filename, options }) => { * Params based on builds we're code splitting * i.e: flavorDimensions "version" productFlavors from android/app/build.gradle */ - if (fileExtsToScan.includes(path.extname(filename))) { + if ( + !path.normalize(filename).split(path.sep).includes('node_modules') && + fileExtsToScan.includes(path.extname(filename)) + ) { const [processedSource, didModify] = removeFencedCode(filename, src, { all: availableFeatures, active: getBuildTypeFeatures(), From fe986fe650d3dac7ec2d359e3820cc3bb49fe403 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Fri, 28 Jun 2024 13:29:45 +0200 Subject: [PATCH 02/59] fix: memoize token list (#10142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR aims to address performance issue on simulations happens only on mainnet. ## **Related issues** ## **Manual testing steps** There should be no performance discrepancies for simulations between mainnet and any other network. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../hooks/DisplayName/useTokenList.test.ts | 12 ++++++++++++ app/components/hooks/DisplayName/useTokenList.ts | 15 +++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/app/components/hooks/DisplayName/useTokenList.test.ts b/app/components/hooks/DisplayName/useTokenList.test.ts index d012ef581f1..856ed1c042d 100644 --- a/app/components/hooks/DisplayName/useTokenList.test.ts +++ b/app/components/hooks/DisplayName/useTokenList.test.ts @@ -1,3 +1,4 @@ +import React from 'react'; import { type TokenListMap } from '@metamask/assets-controllers'; import { selectChainId } from '../../../selectors/networkController'; import { selectUseTokenDetection } from '../../../selectors/preferencesController'; @@ -69,6 +70,17 @@ describe('useTokenList', () => { selectUseTokenDetectionMock.mockReturnValue(true); selectTokenListMock.mockReturnValue(TOKEN_LIST_MOCK); isMainnetByChainIdMock.mockReturnValue(true); + + const memoizedValues = new Map(); + jest.spyOn(React, 'useMemo').mockImplementation((factory, deps) => { + const depsKey = (deps as []).join('|'); + if (memoizedValues.has(depsKey)) { + return memoizedValues.get(depsKey); + } + const newValue = factory(); + memoizedValues.set(depsKey, newValue); + return newValue; + }); }); it('returns normalized STATIC_MAINNET_TOKEN_LIST if token detection is disabled and chain is mainnet', () => { diff --git a/app/components/hooks/DisplayName/useTokenList.ts b/app/components/hooks/DisplayName/useTokenList.ts index 6488c95f7fd..6726fffbbf3 100644 --- a/app/components/hooks/DisplayName/useTokenList.ts +++ b/app/components/hooks/DisplayName/useTokenList.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { type TokenListMap } from '@metamask/assets-controllers'; import contractMap from '@metamask/contract-metadata'; @@ -25,11 +26,13 @@ export default function useTokenList(): TokenListMap { const chainId = useSelector(selectChainId); const isMainnet = isMainnetByChainId(chainId); const isTokenDetectionEnabled = useSelector(selectUseTokenDetection); - const tokenList = useSelector(selectTokenList) || []; + const tokenList = useSelector(selectTokenList); + const shouldUseStaticList = !isTokenDetectionEnabled && isMainnet; - if (!isTokenDetectionEnabled && isMainnet) { - return NORMALIZED_MAINNET_TOKEN_LIST; - } - - return normalizeTokenAddresses(tokenList); + return useMemo(() => { + if (shouldUseStaticList) { + return NORMALIZED_MAINNET_TOKEN_LIST; + } + return normalizeTokenAddresses(tokenList); + }, [shouldUseStaticList, tokenList]); } From ad1023a4e7cca2ccdb4c9ec37d635bf8f8ea75ba Mon Sep 17 00:00:00 2001 From: Nico MASSART Date: Sat, 29 Jun 2024 00:32:52 +0200 Subject: [PATCH 03/59] fix: convert Sentry messages to log (#10168) - Change call to `Logger.message` for `Logger.log` - Delete `Logger.message` see MetaMask/mobile-planning/issues/1814 --- app/components/Views/BrowserTab/index.js | 2 +- app/core/EngineService/EngineService.ts | 6 ++-- app/util/Logger/index.test.ts | 26 +--------------- app/util/Logger/index.ts | 38 ++++-------------------- 4 files changed, 9 insertions(+), 63 deletions(-) diff --git a/app/components/Views/BrowserTab/index.js b/app/components/Views/BrowserTab/index.js index b7c87e1615a..49b55be4097 100644 --- a/app/components/Views/BrowserTab/index.js +++ b/app/components/Views/BrowserTab/index.js @@ -873,7 +873,7 @@ export const BrowserTab = (props) => { // Continue request loading it the protocol is whitelisted const { protocol } = new URL(url); if (protocolAllowList.includes(protocol)) return true; - Logger.message(`Protocol not allowed ${protocol}`); + Logger.log(`Protocol not allowed ${protocol}`); // If it is a trusted deeplink protocol, do not show the // warning alert. Allow the OS to deeplink the URL diff --git a/app/core/EngineService/EngineService.ts b/app/core/EngineService/EngineService.ts index acebf290499..5698e99ab44 100644 --- a/app/core/EngineService/EngineService.ts +++ b/app/core/EngineService/EngineService.ts @@ -126,7 +126,7 @@ class EngineService { engine?.datamodel?.subscribe?.(() => { if (!engine.context.KeyringController.metadata.vault) { - Logger.message('keyringController vault missing for INIT_BG_STATE_KEY'); + Logger.log('keyringController vault missing for INIT_BG_STATE_KEY'); } if (!this.engineInitialized) { store.dispatch({ type: INIT_BG_STATE_KEY }); @@ -138,9 +138,7 @@ class EngineService { const { name, key = undefined } = controller; const update_bg_state_cb = () => { if (!engine.context.KeyringController.metadata.vault) { - Logger.message( - 'keyringController vault missing for UPDATE_BG_STATE_KEY', - ); + Logger.log('keyringController vault missing for UPDATE_BG_STATE_KEY'); } store.dispatch({ type: UPDATE_BG_STATE_KEY, payload: { key: name } }); }; diff --git a/app/util/Logger/index.test.ts b/app/util/Logger/index.test.ts index a8618900c28..0b0ec078a4c 100644 --- a/app/util/Logger/index.test.ts +++ b/app/util/Logger/index.test.ts @@ -1,9 +1,5 @@ import Logger from '.'; -import { - captureException, - withScope, - captureMessage, -} from '@sentry/react-native'; +import { captureException, withScope } from '@sentry/react-native'; import { AGREED, METRICS_OPT_IN } from '../../constants/storage'; import DefaultPreference from 'react-native-default-preference'; @@ -13,7 +9,6 @@ jest.mock('@sentry/react-native', () => ({ withScope: jest.fn(), })); const mockedCaptureException = jest.mocked(captureException); -const mockedCaptureMessage = jest.mocked(captureMessage); const mockedWithScope = jest.mocked(withScope); describe('Logger', () => { @@ -75,23 +70,4 @@ describe('Logger', () => { expect(mockedCaptureException).toHaveBeenCalledWith(expect.any(Error)); }); }); - - describe('message', () => { - it('skips captureMessage if metrics is opted out', async () => { - DefaultPreference.get = jest.fn((key: string) => { - switch (key) { - case METRICS_OPT_IN: - return Promise.resolve(''); - default: - return Promise.resolve(''); - } - }); - await Logger.message('testMessage'); - expect(mockedCaptureMessage).not.toHaveBeenCalled(); - }); - it('calls captureMessage if metrics is opted in', async () => { - await Logger.message('testMessage'); - expect(mockedCaptureMessage).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/app/util/Logger/index.ts b/app/util/Logger/index.ts index df0771dd54f..5b23c94788f 100644 --- a/app/util/Logger/index.ts +++ b/app/util/Logger/index.ts @@ -1,7 +1,6 @@ import { addBreadcrumb, captureException, - captureMessage, withScope, } from '@sentry/react-native'; import DefaultPreference from 'react-native-default-preference'; @@ -17,6 +16,11 @@ interface ExtraInfo { * console.log and console.error and in the future * we will have flags to do different actions based on * the environment, for ex. log to a remote server if prod + * + * The previously available message function has been removed + * favoring the use of the error or log function: + * - error: for logging errors that you want to see in Sentry, + * - log: for logging general information and sending breadcrumbs only with the next Sentry event. */ export class AsyncLogger { /** @@ -95,26 +99,6 @@ export class AsyncLogger { } } } - - /** - * captureMessage wrapper - * - * @param {object} args - data to be logged - * @returns - void - */ - static async message(...args: unknown[]): Promise { - if (__DEV__) { - args.unshift('[MetaMask DEBUG]:'); - // console.log.apply(null, args); // eslint-disable-line no-console - return; - } - - // Check if user passed accepted opt-in to metrics - const metricsOptIn = await DefaultPreference.get(METRICS_OPT_IN); - if (metricsOptIn === 'agreed') { - captureMessage(JSON.stringify(args)); - } - } } export default class Logger { @@ -146,16 +130,4 @@ export default class Logger { // ignore error but avoid dangling promises }); } - - /** - * captureMessage wrapper - * - * @param {object} args - data to be logged - * @returns - void - */ - static message(...args: unknown[]) { - AsyncLogger.message(...args).catch(() => { - // ignore error but avoid dangling promises - }); - } } From f451c929cb94e8a115d2325d03695ea4db7c8a83 Mon Sep 17 00:00:00 2001 From: CW Date: Sat, 29 Jun 2024 05:39:30 -0700 Subject: [PATCH 04/59] test: add connect to Portfolio (#10015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Automate scenario for connecting wallet to Portfolio by tapping on Portfolio button on Wallet view. ## **Related issues** Related: https://github.com/MetaMask/mobile-planning/issues/1728 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: SamuelSalas --- app/components/UI/Tokens/index.test.tsx | 18 ++--- app/components/UI/Tokens/index.tsx | 12 +-- app/components/Views/Wallet/index.tsx | 6 +- e2e/pages/Browser/BrowserView.js | 33 ++++++-- e2e/pages/Browser/PortfolioHomePage.js | 44 +++++++++++ e2e/pages/Send/SendView.js | 10 +-- e2e/pages/WalletView.js | 78 +++++++++---------- .../Browser/BrowserView.selectors.js | 3 + .../Browser/PortfolioPage.selectors.js | 7 ++ e2e/selectors/wallet/WalletView.selectors.js | 3 + e2e/specs/browser/browser-tests.spec.js | 4 +- .../quarantine/contract-nickname.failing.js | 5 +- e2e/specs/settings/addressbook-tests.spec.js | 6 +- e2e/specs/settings/fiat-on-testnets.spec.js | 6 +- .../wallet/portfolio-connect-account.spec.js | 66 ++++++++++++++++ e2e/utils/Assertions.js | 2 +- e2e/utils/Matchers.js | 16 ++-- .../testIDs/Components/Tokens.testIds.js | 5 -- 18 files changed, 227 insertions(+), 97 deletions(-) create mode 100644 e2e/pages/Browser/PortfolioHomePage.js create mode 100644 e2e/selectors/Browser/PortfolioPage.selectors.js create mode 100644 e2e/specs/wallet/portfolio-connect-account.spec.js diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx index 4a7e22aaabf..bd18705d789 100644 --- a/app/components/UI/Tokens/index.test.tsx +++ b/app/components/UI/Tokens/index.test.tsx @@ -11,15 +11,11 @@ import { IMPORT_TOKEN_BUTTON_ID, MAIN_WALLET_VIEW_VIA_TOKENS_ID, } from '../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; -import { - PORTFOLIO_BUTTON, - STAKE_BUTTON, - TOTAL_BALANCE_TEXT, -} from '../../../../wdio/screen-objects/testIDs/Components/Tokens.testIds'; import initialBackgroundState from '../../../util/test/initial-background-state.json'; import { strings } from '../../../../locales/i18n'; import AppConstants from '../../../../app/core/AppConstants'; import Routes from '../../../../app/constants/navigation/Routes'; +import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; const mockEngine = Engine; @@ -190,17 +186,19 @@ describe('Tokens', () => { it('fiat balance must be defined', () => { const { getByTestId } = renderComponent(initialState); - expect(getByTestId(TOTAL_BALANCE_TEXT)).toBeDefined(); + expect( + getByTestId(WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT), + ).toBeDefined(); }); it('portfolio button should render correctly', () => { const { getByTestId } = renderComponent(initialState); - expect(getByTestId(PORTFOLIO_BUTTON)).toBeDefined(); + expect(getByTestId(WalletViewSelectorsIDs.PORTFOLIO_BUTTON)).toBeDefined(); }); it('navigates to Portfolio url when portfolio button is pressed', () => { const { getByTestId } = renderComponent(initialState); - fireEvent.press(getByTestId(PORTFOLIO_BUTTON)); + fireEvent.press(getByTestId(WalletViewSelectorsIDs.PORTFOLIO_BUTTON)); expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { params: { newTabUrl: `${AppConstants.PORTFOLIO.URL}/?metamaskEntry=mobile`, @@ -258,12 +256,12 @@ describe('Tokens', () => { it('renders stake button correctly', () => { const { getByTestId } = renderComponent(initialState); - expect(getByTestId(STAKE_BUTTON)).toBeDefined(); + expect(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeDefined(); }); it('navigates to Portfolio Stake url when stake button is pressed', () => { const { getByTestId } = renderComponent(initialState); - fireEvent.press(getByTestId(STAKE_BUTTON)); + fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, { params: { newTabUrl: `${AppConstants.STAKE.URL}?metamaskEntry=mobile`, diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index 5e81b74159e..e182cc29e5f 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -78,12 +78,6 @@ import Icon, { IconSize, } from '../../../component-library/components/Icons/Icon'; -import { - PORTFOLIO_BUTTON, - STAKE_BUTTON, - TOTAL_BALANCE_TEXT, -} from '../../../../wdio/screen-objects/testIDs/Components/Tokens.testIds'; - import { BrowserTab, TokenI, TokensI } from './types'; import useRampNetwork from '../Ramp/hooks/useRampNetwork'; import Badge from '../../../component-library/components/Badges/Badge/Badge'; @@ -257,7 +251,7 @@ const Tokens: React.FC = ({ tokens }) => { return ( @@ -624,7 +618,7 @@ const Tokens: React.FC = ({ tokens }) => { {fiatBalance} @@ -635,7 +629,7 @@ const Tokens: React.FC = ({ tokens }) => { style={styles.buyButton} onPress={onOpenPortfolio} label={strings('asset_overview.portfolio_button')} - {...generateTestId(Platform, PORTFOLIO_BUTTON)} + testID={WalletViewSelectorsIDs.PORTFOLIO_BUTTON} endIconName={IconName.Export} /> diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 597391cce20..a77d9d97d81 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -82,6 +82,7 @@ import { showNftFetchingLoadingIndicator as showNftFetchingLoadingIndicatorAction, } from '../../../reducers/collectibles'; import { getCurrentRoute } from '../../../reducers/navigation'; +import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; const createStyles = ({ colors, typography }: Theme) => StyleSheet.create({ @@ -502,7 +503,10 @@ const Wallet = ({ assets = tokens; } return ( - + {!basicFunctionalityEnabled ? ( { await TabBarComponent.tapBrowser(); // Check that we are on the browser screen - await Assertions.checkIfVisible(await Browser.browserScreenID); + await Assertions.checkIfVisible(Browser.browserScreenID); }); it('should connect to the test dapp', async () => { @@ -91,7 +91,7 @@ describe(SmokeCore('Browser Tests'), () => { // Clear text & Navigate to URL await Browser.navigateToURL(ExternalSites.PHISHING_SITE); await Browser.waitForBrowserPageToLoad(); - await Assertions.checkIfVisible(await Browser.backToSafetyButton); + await Assertions.checkIfVisible(Browser.backToSafetyButton); await Browser.tapBackToSafetyButton(); // Check that we are on the browser screen diff --git a/e2e/specs/quarantine/contract-nickname.failing.js b/e2e/specs/quarantine/contract-nickname.failing.js index af2ae09bd88..cb055fe407a 100644 --- a/e2e/specs/quarantine/contract-nickname.failing.js +++ b/e2e/specs/quarantine/contract-nickname.failing.js @@ -189,7 +189,7 @@ describe('Adding Contract Nickname', () => { await TabBarComponent.tapActions(); await WalletActionsModal.tapSendButton(); // Make sure view with my accounts visible - await Assertions.checkIfVisible(await SendView.CurrentAccountElement); + await Assertions.checkIfVisible(SendView.CurrentAccountElement); }); it('should verify the contract nickname does not appear in send flow', async () => { @@ -209,7 +209,8 @@ describe('Adding Contract Nickname', () => { await TabBarComponent.tapActions(); await WalletActionsModal.tapSendButton(); // Make sure view with my accounts visible - await SendView.isMyAccountsVisible(); + //TODO: Update SendView.isMyAccountsVisible, this method does not exist + //await SendView.isMyAccountsVisible(); }); it('should verify the contract nickname does not appear in recents', async () => { diff --git a/e2e/specs/settings/addressbook-tests.spec.js b/e2e/specs/settings/addressbook-tests.spec.js index dad43cf0b10..87048797d12 100644 --- a/e2e/specs/settings/addressbook-tests.spec.js +++ b/e2e/specs/settings/addressbook-tests.spec.js @@ -52,20 +52,20 @@ describe(SmokeCore('Addressbook Tests'), () => { await TabBarComponent.tapActions(); await WalletActionsModal.tapSendButton(); // Make sure view with my accounts visible - await Assertions.checkIfVisible(await SendView.CurrentAccountElement); + await Assertions.checkIfVisible(SendView.CurrentAccountElement); }); it('should show invalid address error message', async () => { await SendView.inputAddress(TETHER_ADDRESS); //Input token address to test for error - await Assertions.checkIfVisible(await SendView.contractWarning); + await Assertions.checkIfVisible(SendView.contractWarning); await SendView.removeAddress(); }); it('should input a valid address to send to', async () => { await SendView.inputAddress(MYTH_ADDRESS); - await Assertions.checkIfVisible(await SendView.zeroBalanceWarning); + await Assertions.checkIfVisible(SendView.zeroBalanceWarning); }); it('should add a new address to address book via send flow', async () => { diff --git a/e2e/specs/settings/fiat-on-testnets.spec.js b/e2e/specs/settings/fiat-on-testnets.spec.js index 063198e0f3d..0b308dc8c88 100644 --- a/e2e/specs/settings/fiat-on-testnets.spec.js +++ b/e2e/specs/settings/fiat-on-testnets.spec.js @@ -10,11 +10,11 @@ import NetworkListModal from '../../pages/modals/NetworkListModal'; import WalletView from '../../pages/WalletView'; import NetworkEducationModal from '../../pages/modals/NetworkEducationModal'; import AdvancedSettingsView from '../../pages/Settings/AdvancedView'; -import { TOTAL_BALANCE_TEXT } from '../../../wdio/screen-objects/testIDs/Components/Tokens.testIds.js'; import FiatOnTestnetsModal from '../../pages/modals/FiatOnTestnetsModal.js'; import Assertions from '../../utils/Assertions.js'; import Matchers from '../../utils/Matchers.js'; import TestHelpers from '../../helpers.js'; +import { WalletViewSelectorsIDs } from '../../selectors/wallet/WalletView.selectors'; const SEPOLIA = CustomNetworks.Sepolia.providerConfig.nickname; @@ -40,7 +40,7 @@ describe(SmokeCore('Fiat On Testnets Setting'), () => { // Verify no fiat values displayed await Assertions.checkIfHasText( - Matchers.getElementByID(TOTAL_BALANCE_TEXT), + Matchers.getElementByID(WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT), '$0', ); @@ -57,7 +57,7 @@ describe(SmokeCore('Fiat On Testnets Setting'), () => { // Verify fiat values are displayed await TabBarComponent.tapWallet(); await Assertions.checkIfElementNotToHaveText( - Matchers.getElementByID(TOTAL_BALANCE_TEXT), + Matchers.getElementByID(WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT), '$0', ); }, diff --git a/e2e/specs/wallet/portfolio-connect-account.spec.js b/e2e/specs/wallet/portfolio-connect-account.spec.js new file mode 100644 index 00000000000..719b8d42bab --- /dev/null +++ b/e2e/specs/wallet/portfolio-connect-account.spec.js @@ -0,0 +1,66 @@ +'use strict'; +import { SmokeCore } from '../../tags'; +import TabBarComponent from '../../pages/TabBarComponent'; +import { loginToApp } from '../../viewHelper'; +import { + loadFixture, + startFixtureServer, + stopFixtureServer, +} from '../../fixtures/fixture-helper'; +import FixtureBuilder from '../../fixtures/fixture-builder'; +import TestHelpers from '../../helpers'; +import WalletView from '../../pages/WalletView'; +import { getFixturesServerPort } from '../../fixtures/utils'; +import FixtureServer from '../../fixtures/fixture-server'; +import BrowserView from '../../pages/Browser/BrowserView'; +import PortfolioHomePage from '../../pages/Browser/PortfolioHomePage'; +import Assertions from '../../utils/Assertions'; +import ConnectModal from '../../pages/modals/ConnectModal'; +const fixtureServer = new FixtureServer(); +describe(SmokeCore('Connect account to Portfolio'), () => { + beforeAll(async () => { + await TestHelpers.reverseServerPort(); + const fixture = new FixtureBuilder().withKeyringController().build(); + fixture.state.user.seedphraseBackedUp = false; + await startFixtureServer(fixtureServer); + await loadFixture(fixtureServer, { fixture }); + await device.launchApp({ + permissions: { notifications: 'YES' }, + launchArgs: { fixtureServerPort: `${getFixturesServerPort()}` }, + }); + }); + afterAll(async () => { + await stopFixtureServer(fixtureServer); + }); + + it('should connect wallet account to portfolio', async () => { + await loginToApp(); + await Assertions.checkIfVisible(WalletView.container); + await TabBarComponent.tapBrowser(); + await BrowserView.tapOpenAllTabsButton(); + await BrowserView.tapCloseTabsButton(); + await Assertions.checkIfVisible(BrowserView.noTabsMessage); + await TabBarComponent.tapWallet(); + await WalletView.tapPortfolio(); + await BrowserView.waitForBrowserPageToLoad(); + + try { + await PortfolioHomePage.closePrivacyModal(); + } catch { + /* eslint-disable no-console */ + console.log('The Portfolio privacy modal is not visible'); + } + await PortfolioHomePage.tapConnectMetaMask(); + await device.disableSynchronization(); + await ConnectModal.tapConnectButton(); + await device.enableSynchronization(); + }); + + it('should not open additional browser tabs to portfolio', async () => { + await Assertions.checkIfHasText(BrowserView.tabsNumber, '1'); + await TabBarComponent.tapWallet(); + await WalletView.tapPortfolio(); + await BrowserView.waitForBrowserPageToLoad(); + await Assertions.checkIfHasText(BrowserView.tabsNumber, '1'); + }); +}); diff --git a/e2e/utils/Assertions.js b/e2e/utils/Assertions.js index 6168e28076d..3b19d651385 100644 --- a/e2e/utils/Assertions.js +++ b/e2e/utils/Assertions.js @@ -99,7 +99,7 @@ class Assertions { /** * Check if an element with the specified ID does not have the specified label. * @param {Promise} elementId - The ID of the element to check. - * @param {string} text - The label content to check. + * @param {string} label - The label content to check. * @param {number} [timeout=TIMEOUT] - Timeout in milliseconds. */ static async checkIfElementDoesNotHaveLabel( diff --git a/e2e/utils/Matchers.js b/e2e/utils/Matchers.js index 8f9c1afc6aa..413a67c3433 100644 --- a/e2e/utils/Matchers.js +++ b/e2e/utils/Matchers.js @@ -89,7 +89,7 @@ class Matchers { * Get element by CSS selector. * @param {string} webviewID - The web ID of the browser webview * @param {string} selector - CSS selector to locate the element - * @return {Promise} - Resolves to the located element + * @return {Promise} - Resolves to the located element */ static async getElementByCSS(webviewID, selector) { @@ -101,17 +101,21 @@ class Matchers { * Get element by XPath. * @param {string} webviewID - The web ID of the browser webview * @param {string} xpath - XPath expression to locate the element - * @return {Promise} - Resolves to the located element + * @param {number} index - index to locate the webview (iOS only) + * @return {Promise} - Resolves to the located element */ - static async getElementByXPath(webviewID, xpath) { - const myWebView = web(by.id(webviewID)); + static async getElementByXPath(webviewID, xpath, index = 0) { + const myWebView = + device.getPlatform() === 'ios' + ? web(by.id(webviewID)).atIndex(index) + : web(by.id(webviewID)); return myWebView.element(by.web.xpath(xpath)).atIndex(0); } /** * Get element by href. * @param {string} webviewID - The web ID of the browser webview - * @param {string} xpath - XPath expression to locate the element - * @return {Promise} - Resolves to the located element + * @param {string} url - URL string to locate the element + * @return {Promise} - Resolves to the located element */ static async getElementByHref(webviewID, url) { const myWebView = web(by.id(webviewID)); diff --git a/wdio/screen-objects/testIDs/Components/Tokens.testIds.js b/wdio/screen-objects/testIDs/Components/Tokens.testIds.js index 430a9c8cd9d..e69de29bb2d 100644 --- a/wdio/screen-objects/testIDs/Components/Tokens.testIds.js +++ b/wdio/screen-objects/testIDs/Components/Tokens.testIds.js @@ -1,5 +0,0 @@ -export const TOTAL_BALANCE_TEXT = 'total-balance-text'; - -export const PORTFOLIO_BUTTON = 'portfolio-button'; - -export const STAKE_BUTTON = 'stake-button'; From d356b7e0baf9998d672f883ce6cf053f0cb769a7 Mon Sep 17 00:00:00 2001 From: Jonathan Ferreira <44679989+Jonathansoufer@users.noreply.github.com> Date: Mon, 1 Jul 2024 09:40:01 +0100 Subject: [PATCH 05/59] chore: bump snaps exec env version (#10187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates the Snaps execution environment version to the latest version. This had been missed in the previous PR to bump all of the other Snaps packages. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/lib/snaps/SnapsExecutionWebView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/snaps/SnapsExecutionWebView.tsx b/app/lib/snaps/SnapsExecutionWebView.tsx index db1aa6db3e2..4959a6cce2a 100644 --- a/app/lib/snaps/SnapsExecutionWebView.tsx +++ b/app/lib/snaps/SnapsExecutionWebView.tsx @@ -21,7 +21,7 @@ interface SnapsExecutionWebViewProps { let resolveGetWebView: (arg0: SnapsExecutionWebViewProps) => void; let rejectGetWebView: (error: NativeSyntheticEvent) => void; -const SNAPS_EE_URL = 'https://execution.metamask.io/webview/4.0.0/index.html'; +const SNAPS_EE_URL = 'https://execution.metamask.io/webview/6.5.0/index.html'; export const getSnapsWebViewPromise = new Promise( (resolve, reject) => { From 2478527e69cf3fe1278e0f6e059ca22062f50902 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 1 Jul 2024 15:15:46 +0200 Subject: [PATCH 06/59] feat: Revamp Snap connection screen (#10189) ## **Description** This PR reworks the Snaps connection screen to add the Snap icon and simplify the implementation. It also tweaks the fencing to allow connecting to Snaps with only the `preinstalled-snaps` flag on. ## **Screenshots/Recordings** --- .../InstallSnapApproval.constants.ts | 2 +- .../InstallSnapApproval.styles.ts | 6 +- .../InstallSnapApproval.tsx | 78 +++++++++++-------- .../InstallSnapApproval.types.ts | 3 +- .../InstallSnapConnectionRequest.tsx | 32 +++----- .../InstallSnapConnectionRequest/index.ts | 2 +- .../InstallSnapConnectionRequest.test.tsx | 34 +++++++- .../InstallSnapApproval/components/index.ts | 9 ++- .../Approvals/InstallSnapApproval/index.ts | 2 +- .../test/InstallSnapApproval.test.tsx | 52 ++++++++++--- app/components/Nav/Main/RootRPCMethodsUI.js | 4 +- .../UI/Snaps/SnapAvatar/SnapAvatar.styles.ts | 39 ++++++++++ .../UI/Snaps/SnapAvatar/SnapAvatar.test.tsx | 63 +++++++++++++++ .../UI/Snaps/SnapAvatar/SnapAvatar.tsx | 78 +++++++++++++++++++ app/core/EngineService/EngineService.ts | 2 +- app/selectors/snaps/permissionController.ts | 30 +++++++ app/selectors/snaps/snapController.ts | 37 +++++++++ locales/languages/en.json | 2 +- 18 files changed, 402 insertions(+), 73 deletions(-) create mode 100644 app/components/UI/Snaps/SnapAvatar/SnapAvatar.styles.ts create mode 100644 app/components/UI/Snaps/SnapAvatar/SnapAvatar.test.tsx create mode 100644 app/components/UI/Snaps/SnapAvatar/SnapAvatar.tsx create mode 100644 app/selectors/snaps/permissionController.ts create mode 100644 app/selectors/snaps/snapController.ts diff --git a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.constants.ts b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.constants.ts index 7cca5eabcff..f5f3353dd06 100644 --- a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.constants.ts +++ b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.constants.ts @@ -1,4 +1,4 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) export const SNAP_INSTALL_FLOW = 'snap-install-flow'; export const SNAP_INSTALL_OK = 'snap-install-ok'; ///: END:ONLY_INCLUDE_IF diff --git a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.styles.ts b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.styles.ts index 0788c1ba427..54af3331d92 100644 --- a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.styles.ts +++ b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.styles.ts @@ -1,4 +1,4 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import { StyleSheet } from 'react-native'; import { Theme } from '../../../util/theme/models'; import Device from '../../../util/device'; @@ -37,6 +37,10 @@ const styleSheet = (params: { theme: Theme }) => { snapCell: { marginVertical: 16, }, + snapAvatar: { + alignSelf: 'center', + marginTop: 16, + }, snapPermissionContainer: { maxHeight: 300, borderWidth: 1, diff --git a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.tsx b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.tsx index f30f44189e5..4438da9e273 100644 --- a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.tsx +++ b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.tsx @@ -1,32 +1,50 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import React, { useEffect, useState } from 'react'; import ApprovalModal from '../ApprovalModal'; import useApprovalRequest from '../../Views/confirmations/hooks/useApprovalRequest'; import { ApprovalTypes } from '../../../core/RPCMethods/RPCMethodMiddleware'; -import Logger from '../../../util/Logger'; import { SnapInstallState } from './InstallSnapApproval.types'; import { InstallSnapConnectionRequest, + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) InstallSnapError, InstallSnapPermissionsRequest, InstallSnapSuccess, + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) } from './components'; -import { SNAP_INSTALL_FLOW } from './InstallSnapApproval.constants'; import { ApprovalRequest } from '@metamask/approval-controller'; +import { useSelector } from 'react-redux'; +import { selectSnapsMetadata } from '../../../selectors/snaps/snapController'; +import { + WALLET_SNAP_PERMISSION_KEY, + stripSnapPrefix, +} from '@metamask/snaps-utils'; const InstallSnapApproval = () => { + const snapsMetadata = useSelector(selectSnapsMetadata); + const [installState, setInstallState] = useState< SnapInstallState | undefined >(undefined); - const [isFinished, setIsFinished] = useState(false); + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) const [installError, setInstallError] = useState( undefined, ); + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) const { approvalRequest, onConfirm, onReject } = useApprovalRequest(); useEffect(() => { if (approvalRequest) { - if (approvalRequest.type === ApprovalTypes.REQUEST_PERMISSIONS) { + if ( + approvalRequest.type === ApprovalTypes.REQUEST_PERMISSIONS && + Object.keys(approvalRequest?.requestData?.permissions).includes( + WALLET_SNAP_PERMISSION_KEY, + ) + ) { setInstallState(SnapInstallState.Confirm); } else if ( approvalRequest.type === ApprovalTypes.INSTALL_SNAP && @@ -41,14 +59,11 @@ const InstallSnapApproval = () => { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - const getSnapName = (request: ApprovalRequest): string => { + const getSnapId = (request: ApprovalRequest): string => { // We first look for the name inside the snapId approvalRequest data const snapId = request?.requestData?.snapId; if (typeof snapId === 'string') { - const colonIndex = snapId.indexOf(':'); - if (colonIndex !== -1) { - return snapId.substring(colonIndex + 1); - } + return snapId; } // If there is no snapId present in the approvalRequest data, we look for the name inside the snapIds caveat const snapIdsCaveat = @@ -57,15 +72,16 @@ const InstallSnapApproval = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (c: any) => c.type === 'snapIds', ); - // return an empty string if we can't find the snap name in the approvalRequest data - return snapIdsCaveat?.value ? Object.keys(snapIdsCaveat.value)[0] : ''; + return Object.keys(snapIdsCaveat.value)[0]; }; + const getSnapMetadata = (snapId: string) => + snapsMetadata[snapId] ?? { name: stripSnapPrefix(snapId) }; + if (!approvalRequest) return null; - const onInstallSnapFinished = () => { - setIsFinished(true); - }; + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) const onPermissionsConfirm = async () => { try { @@ -75,54 +91,55 @@ const InstallSnapApproval = () => { }); setInstallState(SnapInstallState.SnapInstalled); } catch (error) { - Logger.error( - error as Error, - `${SNAP_INSTALL_FLOW} Failed to install snap`, - ); setInstallError(error as Error); setInstallState(SnapInstallState.SnapInstallError); } }; + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) - if (!approvalRequest) return null; + if (!approvalRequest || installState === undefined) return null; - const snapName = getSnapName(approvalRequest); + const snapId = getSnapId(approvalRequest); + const snapName = getSnapMetadata(snapId).name; + // TODO: This component should support connecting to multiple Snaps at once. const renderModalContent = () => { switch (installState) { case SnapInstallState.Confirm: return ( ); + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) case SnapInstallState.AcceptPermissions: return ( ); case SnapInstallState.SnapInstalled: - return ( - - ); + return ; case SnapInstallState.SnapInstallError: return ( ); + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) default: return null; } @@ -131,10 +148,7 @@ const InstallSnapApproval = () => { const content = renderModalContent(); return content ? ( - + {content} ) : null; diff --git a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.types.ts b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.types.ts index 00315095b33..60b1b30372d 100644 --- a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.types.ts +++ b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.types.ts @@ -1,8 +1,9 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) interface InstallSnapFlowProps { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any approvalRequest: any; + snapId: string; snapName: string; onConfirm: () => void; onCancel: () => void; diff --git a/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/InstallSnapConnectionRequest.tsx b/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/InstallSnapConnectionRequest.tsx index ad646e4b063..d7675b83a25 100644 --- a/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/InstallSnapConnectionRequest.tsx +++ b/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/InstallSnapConnectionRequest.tsx @@ -1,6 +1,6 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import React, { useMemo } from 'react'; -import { ImageSourcePropType, View } from 'react-native'; +import { View } from 'react-native'; import { InstallSnapFlowProps } from '../../InstallSnapApproval.types'; import styleSheet from '../../InstallSnapApproval.styles'; import { strings } from '../../../../../../locales/i18n'; @@ -11,10 +11,6 @@ import Text, { import TagUrl from '../../../../../component-library/components/Tags/TagUrl'; import { getUrlObj, prefixUrlWithProtocol } from '../../../../../util/browser'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; -import Cell, { - CellVariant, -} from '../../../../../component-library/components/Cells/Cell'; -import { AvatarVariant } from '../../../../../component-library/components/Avatars/Avatar'; import { ButtonSize, ButtonVariants, @@ -29,15 +25,18 @@ import { SNAP_INSTALL_CONNECT, SNAP_INSTALL_CONNECTION_REQUEST, } from './InstallSnapConnectionRequest.constants'; +import { useFavicon } from '../../../../hooks/useFavicon'; +import { SnapAvatar } from '../../../../UI/Snaps/SnapAvatar/SnapAvatar'; const InstallSnapConnectionRequest = ({ approvalRequest, + snapId, snapName, onConfirm, onCancel, }: Pick< InstallSnapFlowProps, - 'approvalRequest' | 'onConfirm' | 'onCancel' | 'snapName' + 'approvalRequest' | 'onConfirm' | 'onCancel' | 'snapId' | 'snapName' >) => { const { styles } = useStyles(styleSheet, {}); @@ -46,10 +45,7 @@ const InstallSnapConnectionRequest = ({ [approvalRequest.origin], ); - const favicon: ImageSourcePropType = useMemo(() => { - const iconUrl = `https://api.faviconkit.com/${origin}/50`; - return { uri: iconUrl }; - }, [origin]); + const favicon = useFavicon(origin); const urlWithProtocol = prefixUrlWithProtocol(origin); @@ -85,6 +81,11 @@ const InstallSnapConnectionRequest = ({ label={urlWithProtocol} iconName={secureIcon} /> + {strings('install_snap.description', { @@ -92,15 +93,6 @@ const InstallSnapConnectionRequest = ({ snap: snapName, })} - { expectsResult: false, }; + const mockStore = configureMockStore(); + const mockInitialState = { + settings: {}, + engine: { + backgroundState: { + SubjectMetadataController: { + subjectMetadata: {}, + }, + SnapController: { + snaps: {}, + }, + }, + }, + }; + const store = mockStore(mockInitialState); + + // TODO: Replace "any" with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Wrapper = ({ children }: any) => ( + {children} + ); + const onConfirm = jest.fn(); const onCancel = jest.fn(); @@ -51,8 +74,10 @@ describe('InstallSnapConnectionRequest', () => { approvalRequest={requestPermissionsData} onConfirm={onConfirm} onCancel={onCancel} + snapId="npm:@metamask/bip32-example-snap" snapName="@metamask/bip32-example-snap" />, + { wrapper: Wrapper }, ); expect(getByTestId(SNAP_INSTALL_CONNECTION_REQUEST)).toBeDefined(); }); @@ -63,8 +88,10 @@ describe('InstallSnapConnectionRequest', () => { approvalRequest={requestPermissionsData} onConfirm={onConfirm} onCancel={onCancel} + snapId="npm:@metamask/bip32-example-snap" snapName="@metamask/bip32-example-snap" />, + { wrapper: Wrapper }, ); fireEvent.press(getByTestId(SNAP_INSTALL_CONNECT)); @@ -77,8 +104,10 @@ describe('InstallSnapConnectionRequest', () => { approvalRequest={requestPermissionsData} onConfirm={onConfirm} onCancel={onCancel} + snapId="npm:@metamask/bip32-example-snap" snapName="@metamask/bip32-example-snap" />, + { wrapper: Wrapper }, ); fireEvent.press(getByTestId(SNAP_INSTALL_CANCEL)); @@ -91,12 +120,13 @@ describe('InstallSnapConnectionRequest', () => { approvalRequest={requestPermissionsData} onConfirm={onConfirm} onCancel={onCancel} + snapId="npm:@metamask/bip32-example-snap" snapName="@metamask/bip32-example-snap" />, + { wrapper: Wrapper }, ); const expectedUrl = 'https://metamask.github.io'; expect(getByText(expectedUrl)).toBeTruthy(); }); }); -///: END:ONLY_INCLUDE_IF diff --git a/app/components/Approvals/InstallSnapApproval/components/index.ts b/app/components/Approvals/InstallSnapApproval/components/index.ts index bf7c5e92a05..94543d785e1 100644 --- a/app/components/Approvals/InstallSnapApproval/components/index.ts +++ b/app/components/Approvals/InstallSnapApproval/components/index.ts @@ -1,11 +1,18 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +/* eslint-disable import/prefer-default-export */ +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps) import { InstallSnapConnectionRequest } from './InstallSnapConnectionRequest'; +///: END:ONLY_INCLUDE_IF +///: BEGIN:ONLY_INCLUDE_IF(external-snaps) import { InstallSnapSuccess } from './InstallSnapSuccess'; import { InstallSnapError } from './InstallSnapError'; import { InstallSnapPermissionsRequest } from './InstallSnapPermissionsRequest'; +///: END:ONLY_INCLUDE_IF +///: BEGIN:ONLY_INCLUDE_IF(external-snaps) export { InstallSnapPermissionsRequest }; export { InstallSnapError }; export { InstallSnapSuccess }; +///: END:ONLY_INCLUDE_IF +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps) export { InstallSnapConnectionRequest }; ///: END:ONLY_INCLUDE_IF diff --git a/app/components/Approvals/InstallSnapApproval/index.ts b/app/components/Approvals/InstallSnapApproval/index.ts index fab24aeafb5..14ab6d8ca0f 100644 --- a/app/components/Approvals/InstallSnapApproval/index.ts +++ b/app/components/Approvals/InstallSnapApproval/index.ts @@ -1,3 +1,3 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) export { default } from './InstallSnapApproval'; ///: END:ONLY_INCLUDE_IF diff --git a/app/components/Approvals/InstallSnapApproval/test/InstallSnapApproval.test.tsx b/app/components/Approvals/InstallSnapApproval/test/InstallSnapApproval.test.tsx index 941d1f51b7e..ea9775037b0 100644 --- a/app/components/Approvals/InstallSnapApproval/test/InstallSnapApproval.test.tsx +++ b/app/components/Approvals/InstallSnapApproval/test/InstallSnapApproval.test.tsx @@ -1,8 +1,9 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react-native'; -import InstallSnapApproval from '../InstallSnapApproval'; import { ApprovalRequest } from '@metamask/approval-controller'; +import configureMockStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; +import InstallSnapApproval from '../InstallSnapApproval'; import useApprovalRequest from '../../../Views/confirmations/hooks/useApprovalRequest'; import { SNAP_INSTALL_CANCEL, @@ -127,20 +128,46 @@ describe('InstallSnapApprovalFlow', () => { expectsResult: false, }; + const mockStore = configureMockStore(); + const mockInitialState = { + settings: {}, + engine: { + backgroundState: { + SubjectMetadataController: { + subjectMetadata: {}, + }, + SnapController: { + snaps: {}, + }, + }, + }, + }; + const store = mockStore(mockInitialState); + + // TODO: Replace "any" with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Wrapper = ({ children }: any) => ( + {children} + ); + afterEach(() => { jest.clearAllMocks(); }); it('renders InstallSnapConnectionRequest component initially when approval type is wallet_requestPermissions', () => { mockApprovalRequest(requestPermissionsData); - const { getByTestId } = render(); + const { getByTestId } = render(, { + wrapper: Wrapper, + }); const connectionRequest = getByTestId(SNAP_INSTALL_CONNECTION_REQUEST); expect(connectionRequest).toBeDefined(); }); it('renders InstallSnapPermissionsRequest when approval type is wallet_installSnap', async () => { mockApprovalRequest(installSnapData); - const { findByTestId } = render(); + const { findByTestId } = render(, { + wrapper: Wrapper, + }); const permissionsRequest = await findByTestId( SNAP_INSTALL_PERMISSIONS_REQUEST, ); @@ -149,7 +176,9 @@ describe('InstallSnapApprovalFlow', () => { it('calls onConfirm when Approve button is pressed in InstallSnapPermissionsRequest', async () => { mockApprovalRequest(installSnapData); - const { getByTestId } = render(); + const { getByTestId } = render(, { + wrapper: Wrapper, + }); const permissionsApproveButton = getByTestId( SNAP_INSTALL_PERMISSIONS_REQUEST_APPROVE, ); @@ -159,7 +188,9 @@ describe('InstallSnapApprovalFlow', () => { it('renders InstallSnapSuccess on successful installation', async () => { mockApprovalRequest(installSnapData); - const { getByTestId, findByTestId } = render(); + const { getByTestId, findByTestId } = render(, { + wrapper: Wrapper, + }); const permissionsRequest = await findByTestId( SNAP_INSTALL_PERMISSIONS_REQUEST, ); @@ -178,7 +209,9 @@ describe('InstallSnapApprovalFlow', () => { throw new Error('Installation error'); }); - const { getByTestId, findByTestId } = render(); + const { getByTestId, findByTestId } = render(, { + wrapper: Wrapper, + }); const permissionsRequest = getByTestId(SNAP_INSTALL_PERMISSIONS_REQUEST); expect(permissionsRequest).toBeDefined(); const permissionsConfirmButton = getByTestId( @@ -192,10 +225,11 @@ describe('InstallSnapApprovalFlow', () => { it('calls onCancel on cancel button click', () => { mockApprovalRequest(installSnapData); - const { getByTestId } = render(); + const { getByTestId } = render(, { + wrapper: Wrapper, + }); const cancelButton = getByTestId(SNAP_INSTALL_CANCEL); fireEvent.press(cancelButton); expect(onReject).toHaveBeenCalledTimes(1); }); }); -///: END:ONLY_INCLUDE_IF diff --git a/app/components/Nav/Main/RootRPCMethodsUI.js b/app/components/Nav/Main/RootRPCMethodsUI.js index 5a59acd5385..2b331b13333 100644 --- a/app/components/Nav/Main/RootRPCMethodsUI.js +++ b/app/components/Nav/Main/RootRPCMethodsUI.js @@ -67,7 +67,7 @@ import { selectShouldUseSmartTransaction } from '../../../selectors/smartTransac import { STX_NO_HASH_ERROR } from '../../../util/smart-transactions/smart-publish-hook'; import { getSmartTransactionMetricsProperties } from '../../../util/smart-transactions'; -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import InstallSnapApproval from '../../Approvals/InstallSnapApproval'; ///: END:ONLY_INCLUDE_IF @@ -487,7 +487,7 @@ const RootRPCMethodsUI = (props) => { { - ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) } { diff --git a/app/components/UI/Snaps/SnapAvatar/SnapAvatar.styles.ts b/app/components/UI/Snaps/SnapAvatar/SnapAvatar.styles.ts new file mode 100644 index 00000000000..04571d4ea74 --- /dev/null +++ b/app/components/UI/Snaps/SnapAvatar/SnapAvatar.styles.ts @@ -0,0 +1,39 @@ +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../util/theme/models'; + +/** + * + * @param params Style sheet params. + * @param params.theme App theme from ThemeContext. + * @param params.vars Inputs that the style sheet depends on. + * @returns StyleSheet object. + */ +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + return StyleSheet.create({ + avatar: { + backgroundColor: colors.background.alternativeHover, + }, + fallbackAvatar: { + backgroundColor: colors.background.alternativeHover, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: colors.text.alternative, + }, + fallbackAvatarText: { + textTransform: 'uppercase', + }, + badge: { + backgroundColor: colors.info.default, + color: colors.info.inverse, + borderColor: colors.background.alternative, + borderWidth: 2, + }, + }); +}; + +export default styleSheet; +///: END:ONLY_INCLUDE_IF diff --git a/app/components/UI/Snaps/SnapAvatar/SnapAvatar.test.tsx b/app/components/UI/Snaps/SnapAvatar/SnapAvatar.test.tsx new file mode 100644 index 00000000000..ffabfea630f --- /dev/null +++ b/app/components/UI/Snaps/SnapAvatar/SnapAvatar.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; +import { render } from '@testing-library/react-native'; +import { SnapAvatar } from './SnapAvatar'; + +describe('SnapAvatar', () => { + const mockStore = configureMockStore(); + const mockInitialState = { + settings: {}, + engine: { + backgroundState: { + SubjectMetadataController: { + subjectMetadata: { + 'npm:@metamask/bip32-example-snap': { + extensionId: null, + iconUrl: null, + name: 'BIP-32 Example Snap', + origin: 'npm:@metamask/bip32-example-snap', + subjectType: 'snap', + svgIcon: '', + version: '1.0.0', + }, + }, + }, + }, + }, + }; + const store = mockStore(mockInitialState); + + // TODO: Replace "any" with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Wrapper = ({ children }: any) => ( + {children} + ); + + it('renders an icon using subject metadata', () => { + const { getByTestId } = render( + , + { + wrapper: Wrapper, + }, + ); + expect(getByTestId('snap-avatar-icon')).toBeDefined(); + }); + + it('falls back to the first non symbol in the snap name', () => { + const { getByTestId, getByText } = render( + , + { + wrapper: Wrapper, + }, + ); + expect(getByText('B')).toBeDefined(); + expect(getByTestId('snap-avatar-fallback')).toBeDefined(); + }); +}); diff --git a/app/components/UI/Snaps/SnapAvatar/SnapAvatar.tsx b/app/components/UI/Snaps/SnapAvatar/SnapAvatar.tsx new file mode 100644 index 00000000000..b8602c81881 --- /dev/null +++ b/app/components/UI/Snaps/SnapAvatar/SnapAvatar.tsx @@ -0,0 +1,78 @@ +/* eslint-disable react/prop-types */ +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) +import React from 'react'; +import { useSelector } from 'react-redux'; +import AvatarFavicon from '../../../../component-library/components/Avatars/Avatar/variants/AvatarFavicon'; +import { selectTargetSubjectMetadata } from '../../../../selectors/snaps/permissionController'; +import BadgeWrapper from '../../../../component-library/components/Badges/BadgeWrapper'; +import { BadgePosition } from '../../../../component-library/components/Badges/BadgeWrapper/BadgeWrapper.types'; +import AvatarIcon from '../../../../component-library/components/Avatars/Avatar/variants/AvatarIcon'; +import { + IconColor, + IconName, +} from '../../../..//component-library/components/Icons/Icon'; +import AvatarBase from '../../../../component-library/components/Avatars/Avatar/foundation/AvatarBase'; +import Text from '../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../component-library/hooks'; +import styleSheet from './SnapAvatar.styles'; +import { RootState } from '../../../../reducers'; +import { AvatarSize } from '../../../../component-library/components/Avatars/Avatar'; +import { ViewStyle } from 'react-native'; + +const getAvatarFallbackLetter = (subjectName: string) => + subjectName?.match(/[a-z0-9]/iu)?.[0]; + +export interface SnapAvatarProps { + snapId: string; + snapName: string; // TODO: Don't pass this in, derive it in the component instead. + style?: ViewStyle; +} + +export const SnapAvatar: React.FunctionComponent = ({ + snapId, + snapName, + style, +}) => { + const { styles } = useStyles(styleSheet, {}); + + const subjectMetadata = useSelector((state: RootState) => + selectTargetSubjectMetadata(state, snapId), + ); + + const iconUrl = subjectMetadata?.iconUrl; + + const fallbackAvatar = getAvatarFallbackLetter(snapName); + + return ( + + } + badgePosition={BadgePosition.BottomRight} + > + {iconUrl ? ( + + ) : ( + + {fallbackAvatar} + + )} + + ); +}; +///: END:ONLY_INCLUDE_IF diff --git a/app/core/EngineService/EngineService.ts b/app/core/EngineService/EngineService.ts index 5698e99ab44..ecec1d552cf 100644 --- a/app/core/EngineService/EngineService.ts +++ b/app/core/EngineService/EngineService.ts @@ -99,7 +99,7 @@ class EngineService { key: `${engine.context.SnapController.name}:stateChange`, }, { - name: 'subjectMetadataController', + name: 'SubjectMetadataController', key: `${engine.context.SubjectMetadataController.name}:stateChange`, }, ///: END:ONLY_INCLUDE_IF diff --git a/app/selectors/snaps/permissionController.ts b/app/selectors/snaps/permissionController.ts new file mode 100644 index 00000000000..08ae3291e28 --- /dev/null +++ b/app/selectors/snaps/permissionController.ts @@ -0,0 +1,30 @@ +import { RootState } from '../../reducers'; +import { memoize } from 'lodash'; +import { SubjectType } from '@metamask/permission-controller'; + +export const selectPermissionControllerState = (state: RootState) => + state.engine.backgroundState.PermissionController; + +export const selectSubjectMetadataControllerState = (state: RootState) => + state.engine.backgroundState.SubjectMetadataController; + +const getEmbeddableSvg = memoize( + (svgString) => `data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`, +); + +function selectSubjectMetadata(state: RootState) { + return selectSubjectMetadataControllerState(state).subjectMetadata; +} + +export function selectTargetSubjectMetadata(state: RootState, origin: string) { + const metadata = selectSubjectMetadata(state)[origin]; + + if (metadata?.subjectType === SubjectType.Snap) { + return { + ...metadata, + iconUrl: metadata.svgIcon ? getEmbeddableSvg(metadata.svgIcon) : null, + }; + } + + return metadata; +} diff --git a/app/selectors/snaps/snapController.ts b/app/selectors/snaps/snapController.ts new file mode 100644 index 00000000000..25e8053ada7 --- /dev/null +++ b/app/selectors/snaps/snapController.ts @@ -0,0 +1,37 @@ +import { createSelector } from 'reselect'; +import { RootState } from '../../reducers'; +import { createDeepEqualSelector } from '../util'; +import { getLocalizedSnapManifest } from '@metamask/snaps-utils'; + +// TODO: Filter out huge values +export const selectSnapControllerState = (state: RootState) => + state.engine.backgroundState.SnapController; + +export const selectSnaps = createSelector( + selectSnapControllerState, + (controller) => controller.snaps, +); + +export const selectSnapsMetadata = createDeepEqualSelector( + selectSnaps, + (snaps) => + Object.values(snaps).reduce< + Record + >((snapsMetadata, snap) => { + const snapId = snap.id; + const manifest = snap.localizationFiles + ? getLocalizedSnapManifest( + snap.manifest, + // TODO: Use actual locale here. + 'en', + snap.localizationFiles, + ) + : snap.manifest; + + snapsMetadata[snapId] = { + name: manifest.proposedName, + description: manifest.description, + }; + return snapsMetadata; + }, {}), +); diff --git a/locales/languages/en.json b/locales/languages/en.json index 4a6875a0bc8..c78ad3a84ca 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3075,7 +3075,7 @@ }, "install_snap": { "title": "Connection request", - "description": "{{origin}} wants to download and connect with {{snap}}. Make sure you trust the authors before you proceed.", + "description": "{{origin}} wants to use {{snap}}.", "permissions_request_title": "Permissions request", "permissions_request_description": "{{origin}} wants to install {{snap}}, which is requesting the following permissions.", "approve_permissions": "Approve", From d528b817c7fba2d5f9fd3aba822b287586e43a3d Mon Sep 17 00:00:00 2001 From: sethkfman <10342624+sethkfman@users.noreply.github.com> Date: Mon, 1 Jul 2024 15:09:59 -0600 Subject: [PATCH 07/59] chore: added the requirement for PRs to contain a QA label (#10167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR add the requirement for all PRs to contain 1 of 3 QA labels: - `No QA Needed` - `Run E2E Smoke` - `QA Passed` ## **Related issues** Fixes: ## **Manual testing steps** 1. PASS - Add no QA labels and see CI Check template and add labels / check-template-and-add-labels Fail 2. PASS - Add `No QA Needed` QA labels and see CI Check template and add labels / check-template-and-add-labels Pass 3. PASS - Add `Run E2E Smoke` QA labels and see CI Check template and add labels / check-template-and-add-labels Pass 4. PASS - Add `QA Passed` QA labels and see CI Check template and add labels / check-template-and-add-labels Pass ## **Screenshots/Recordings** ### **Before** NA ### **After** NA ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .github/guidelines/LABELING_GUIDELINES.md | 6 ++++-- .github/scripts/check-pr-has-required-labels.ts | 13 +++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/guidelines/LABELING_GUIDELINES.md b/.github/guidelines/LABELING_GUIDELINES.md index 4d3077b5128..ee49fad0639 100644 --- a/.github/guidelines/LABELING_GUIDELINES.md +++ b/.github/guidelines/LABELING_GUIDELINES.md @@ -16,11 +16,13 @@ It's essential to ensure that PRs have the appropriate labels before they are co ### Mandatory QA labels: Every PR shall include one the QA labels below: - **needs-qa**: If the PR includes a new features, complex testing steps, or large refactors, this label must be added to indicated PR requires a full manual QA prior being merged and added to a release. -- **No QA/E2E only**: If the PR does not require any manual QA effort, this label must be added. However, prior to merging, you must ensure end-to-end test runs in Bitrise are successful. + - **Spot check on release build**: If PR does not require feature QA but needs non-automated verification, this label must be added. Furthermore, when that label is added, you must provide test scenarios in the description section, as well as add screenshots, and or recordings of what was tested. -Once PR has been tested by QA (only if the PR was labeled with `needs-qa`): +To merge your PR one of the following QA labels are required: - **QA Passed**: If the PR was labeled with `needs-qa`, this label must be added once QA has signed off +- **No QA Needed**: If the PR does not require any QA effort. This label should only be used in case you are updating a README or other files that does not impact the building or runtime of the application. +- **Run E2E Smoke**: This label will kick-off E2E testing and trigger a check to make sure the E2E tests pass. ### Optional labels: - **regression-develop**: This label can manually be added to a bug report issue at the time of its creation if the bug is present on the development branch, i.e., `main`, but is not yet released in production. diff --git a/.github/scripts/check-pr-has-required-labels.ts b/.github/scripts/check-pr-has-required-labels.ts index fadb2856ac2..bc91e6f8ac7 100644 --- a/.github/scripts/check-pr-has-required-labels.ts +++ b/.github/scripts/check-pr-has-required-labels.ts @@ -50,6 +50,7 @@ async function main(): Promise { 'DO-NOT-MERGE', ]; let hasTeamLabel = false; + let hasQALabel = false; // Check pull request has at least required QA label and team label for (const label of pullRequestLabels) { @@ -57,22 +58,30 @@ async function main(): Promise { console.log(`PR contains a team label as expected: ${label}`); hasTeamLabel = true; } + if (label.includes('Run Smoke E2E') || label.includes('No QA Needed') || label.includes('QA Passed') ) { + console.log(`PR contains a QA label as expected: ${label}`); + hasQALabel = true; + } if (preventMergeLabels.includes(label)) { core.setFailed( `PR cannot be merged because it still contains this label: ${label}`, ); process.exit(1); } - if (hasTeamLabel) { + if (hasTeamLabel && hasQALabel) { return; } } - // Otherwise, throw an arror to prevent from merging + // Otherwise, throw an error to prevent from merging let errorMessage = ''; if (!hasTeamLabel) { errorMessage += 'No team labels found on the PR. '; } + + if (!hasQALabel) { + errorMessage += 'No \'Run E2E Smoke\' or \'No QA Needed\' or \'QA Passed\' label. '; + } errorMessage += `Please make sure the PR is appropriately labeled before merging it.\n\nSee labeling guidelines for more detail: https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md`; core.setFailed(errorMessage); process.exit(1); From 304604611ff4044b4ab96e3ed6879267b3656d3e Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 2 Jul 2024 15:32:04 +0530 Subject: [PATCH 08/59] fix: blockaid validations for deeplink transactions (#10192) --- app/components/Views/confirmations/SendFlow/Confirm/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/components/Views/confirmations/SendFlow/Confirm/index.js b/app/components/Views/confirmations/SendFlow/Confirm/index.js index 45705c0f935..b9e6918beab 100644 --- a/app/components/Views/confirmations/SendFlow/Confirm/index.js +++ b/app/components/Views/confirmations/SendFlow/Confirm/index.js @@ -474,7 +474,9 @@ class Confirm extends PureComponent { id, jsonrpc: '2.0', method: 'eth_sendTransaction', - origin: TransactionTypes.MM, + origin: isPaymentRequest + ? AppConstants.DEEPLINKS.ORIGIN_DEEPLINK + : TransactionTypes.MM, params: [ { from, From 67eb69271adf06d6a2cf487faa86a2947e92784f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Loureiro?= <1649425+jpcloureiro@users.noreply.github.com> Date: Tue, 2 Jul 2024 12:55:53 +0100 Subject: [PATCH 09/59] feat: upgrade react-native-webview (#7759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Moving away from `.patch` changes on the webview Our [RNWebView fork](https://github.com/MetaMask/react-native-webview-mm) contains the security patches. The "Javascript Injection" patch was removed and the related patches to fix bugs introduced by that. Jest snapshots updates to reflect the new RNWebView component ## **Related issues** [mobile-planning #1294](https://github.com/MetaMask/mobile-planning/issues/1294) ## Acceptance Criteria - [x] https://github.com/MetaMask/mobile-planning/issues/1618 ~must be closed with zero bugs~ Done ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've clearly explained what problem this PR is solving and how it is solved. - [ ] I've linked related issues - [ ] I've included manual testing steps - [ ] I've included screenshots/recordings if applicable - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. - [ ] I’ve properly set the pull request status: - [ ] In case it's not yet "ready for review", I've set it to "draft". - [ ] In case it's "ready for review", I've changed it from "draft" to "non-draft". ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: tommasini <46944231+tommasini@users.noreply.github.com> Co-authored-by: sethkfman <10342624+sethkfman@users.noreply.github.com> Co-authored-by: Curtis David Co-authored-by: legobt <6wbvkn0j@anonaddy.me> Co-authored-by: legobeat <109787230+legobeat@users.noreply.github.com> Co-authored-by: jiexi --- .../Modals/ModalMandatory/ModalMandatory.tsx | 2 +- .../__snapshots__/index.test.tsx.snap | 1510 +++++++++++++++ .../Base/AnimatedFox/index.test.tsx | 37 + app/components/Base/AnimatedFox/index.tsx | 1580 ++++++++++++++++ app/components/UI/Fox/index.js | 2 +- app/components/UI/Ramp/Views/Checkout.tsx | 2 +- .../Quotes/__snapshots__/Quotes.test.tsx.snap | 1622 ++++++++++++++++- app/components/UI/Ramp/index.tsx | 2 +- app/components/UI/WebviewError/index.js | 2 +- app/components/Views/BrowserTab/index.js | 2 +- app/components/Views/ChoosePassword/index.js | 2 +- app/components/Views/Login/index.js | 2 +- app/components/Views/Onboarding/index.js | 2 +- app/components/Views/ResetPassword/index.js | 2 +- .../__snapshots__/index.test.tsx.snap | 9 - app/components/Views/SimpleWebview/index.js | 2 +- .../ShowBlockExplorer/index.tsx | 2 +- app/core/BackgroundBridge/Port.ts | 4 +- app/lib/ppom/PPOMView.tsx | 2 +- .../ppom/__snapshots__/PPOMView.test.tsx.snap | 58 +- app/lib/snaps/SnapsExecutionWebView.tsx | 4 +- app/util/test/testSetup.js | 14 + e2e/utils/Matchers.js | 30 +- ios/Podfile.lock | 12 +- jest.config.js | 1 + package.json | 3 +- patches/react-native-webview+11.13.0.patch | 1401 -------------- scripts/build-inpage-bridge.sh | 6 +- scripts/inpage-bridge/content-script/build.js | 14 - .../inpage-bridge/inpage/webpack.config.js | 63 - .../{inpage => src}/MobilePortStream.js | 0 .../ReactNativePostMessageStream.js | 0 .../{content-script => src}/index.js | 41 +- .../{inpage/index.js => src/provider.js} | 47 +- scripts/inpage-bridge/webpack.config.js | 46 +- yarn.lock | 23 +- 36 files changed, 4860 insertions(+), 1691 deletions(-) create mode 100644 app/components/Base/AnimatedFox/__snapshots__/index.test.tsx.snap create mode 100644 app/components/Base/AnimatedFox/index.test.tsx create mode 100644 app/components/Base/AnimatedFox/index.tsx delete mode 100644 patches/react-native-webview+11.13.0.patch delete mode 100644 scripts/inpage-bridge/content-script/build.js delete mode 100644 scripts/inpage-bridge/inpage/webpack.config.js rename scripts/inpage-bridge/{inpage => src}/MobilePortStream.js (100%) rename scripts/inpage-bridge/{inpage => src}/ReactNativePostMessageStream.js (100%) rename scripts/inpage-bridge/{content-script => src}/index.js (73%) rename scripts/inpage-bridge/{inpage/index.js => src/provider.js} (83%) diff --git a/app/component-library/components/Modals/ModalMandatory/ModalMandatory.tsx b/app/component-library/components/Modals/ModalMandatory/ModalMandatory.tsx index d4b38eda8c7..1ac25e15493 100644 --- a/app/component-library/components/Modals/ModalMandatory/ModalMandatory.tsx +++ b/app/component-library/components/Modals/ModalMandatory/ModalMandatory.tsx @@ -8,7 +8,7 @@ import { TouchableOpacity, View, } from 'react-native'; -import { WebView } from 'react-native-webview'; +import { WebView } from '@metamask/react-native-webview'; // External dependencies. import ButtonPrimary from '../../Buttons/Button/variants/ButtonPrimary'; diff --git a/app/components/Base/AnimatedFox/__snapshots__/index.test.tsx.snap b/app/components/Base/AnimatedFox/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..cba50627e80 --- /dev/null +++ b/app/components/Base/AnimatedFox/__snapshots__/index.test.tsx.snap @@ -0,0 +1,1510 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnimatedFox renders correctly and matches snapshot 1`] = ` + + + + + --- + + + +
+ + + + ", + } + } + style={ + { + "flex": 1, + } + } +/> +`; diff --git a/app/components/Base/AnimatedFox/index.test.tsx b/app/components/Base/AnimatedFox/index.test.tsx new file mode 100644 index 00000000000..31d3fde17b3 --- /dev/null +++ b/app/components/Base/AnimatedFox/index.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import AnimatedFox from './'; +import { getTotalMemorySync } from 'react-native-device-info'; + +jest.mock('react-native-device-info', () => ({ + getTotalMemorySync: jest.fn(), +})); + +jest.mock('react-native-sensors', () => ({ + gyroscope: { + subscribe: jest.fn(({ next }) => { + next({ x: 1, y: 2 }); + + return { unsubscribe: jest.fn() }; + }), + }, + setUpdateIntervalForType: jest.fn(), + SensorTypes: { + gyroscope: 'gyroscope', + }, +})); + +describe('AnimatedFox', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders correctly and matches snapshot', () => { + // Mock device memory to ensure consistent environment for snapshot + (getTotalMemorySync as jest.Mock).mockReturnValueOnce( + 3 * 1024 * 1024 * 1024, + ); // Mock 3GB device + + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/Base/AnimatedFox/index.tsx b/app/components/Base/AnimatedFox/index.tsx new file mode 100644 index 00000000000..51eb1afc844 --- /dev/null +++ b/app/components/Base/AnimatedFox/index.tsx @@ -0,0 +1,1580 @@ +/* eslint-disable react/prop-types */ +import React, { useEffect, useRef } from 'react'; +import { WebView } from '@metamask/react-native-webview'; +import { + gyroscope, + SensorTypes, + setUpdateIntervalForType, +} from 'react-native-sensors'; +import { getTotalMemorySync } from 'react-native-device-info'; + +interface AnimatedFoxProps { + bgColor: string; +} +const round = (value: number, decimals: number): number => + Number(Math.round(Number(value + 'e' + decimals)) + 'e-' + decimals); + +const styles = { flex: 1 }; + +const AnimatedFox: React.FC = ({ bgColor }) => { + const webviewRef = useRef(null); + const position = useRef({ beta: 0, gamma: 0 }); + + /** + * If a device have lower than 2GB Ram we consider a low end device + * @returns boolean + */ + const isLowEndDevice = () => { + // Total device memory in bytes. + const totalMemory = getTotalMemorySync(); + const oneGigaByte = 1024 * 1024 * 1024; + return totalMemory <= 2 * oneGigaByte; + }; + + useEffect(() => { + const updateInterval = isLowEndDevice() ? 1000 / 30 : 1000 / 60; // 30Hz for low-end, 60Hz for others. + setUpdateIntervalForType(SensorTypes.gyroscope, updateInterval); + + const subscription = gyroscope.subscribe({ + next: ({ x, y }) => { + position.current = { + beta: position.current.beta - round(x * -10, 4), + gamma: position.current.gamma - round(y * -10, 4), + }; + + requestAnimationFrame(() => { + const JS = ` + (function () { + const event = new CustomEvent('nativedeviceorientation', { + detail: { + beta:${position.current.beta}, + gamma:${position.current.gamma} + } + }); + + window.dispatchEvent(event); + })(); + `; + webviewRef.current?.injectJavaScript(JS); + }); + }, + error: () => { + // gyroscope is not available + }, + }); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + return ( + + + + + --- + + + +
+ + + + `, + }} + javaScriptEnabled + bounces={false} + scrollEnabled={false} + injectedJavaScript={`document.body.style.background="${bgColor}"`} + /> + ); +}; + +AnimatedFox.defaultProps = { + bgColor: 'white', +}; + +export default AnimatedFox; diff --git a/app/components/UI/Fox/index.js b/app/components/UI/Fox/index.js index 8e18da54c6f..8767c05f41f 100644 --- a/app/components/UI/Fox/index.js +++ b/app/components/UI/Fox/index.js @@ -1,7 +1,7 @@ import React, { forwardRef } from 'react'; import PropTypes from 'prop-types'; import { StyleSheet } from 'react-native'; -import { WebView } from 'react-native-webview'; +import { WebView } from '@metamask/react-native-webview'; import { useTheme } from '../../../util/theme'; import Animated, { useAnimatedStyle, diff --git a/app/components/UI/Ramp/Views/Checkout.tsx b/app/components/UI/Ramp/Views/Checkout.tsx index f532aee5b94..85eb8b52baf 100644 --- a/app/components/UI/Ramp/Views/Checkout.tsx +++ b/app/components/UI/Ramp/Views/Checkout.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { View } from 'react-native'; import { useDispatch } from 'react-redux'; import { parseUrl } from 'query-string'; -import { WebView, WebViewNavigation } from 'react-native-webview'; +import { WebView, WebViewNavigation } from '@metamask/react-native-webview'; import { useNavigation } from '@react-navigation/native'; import { Provider } from '@consensys/on-ramp-sdk'; import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; diff --git a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap index 8efadd056c1..442f0245b8a 100644 --- a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap @@ -1273,59 +1273,1591 @@ exports[`Quotes renders animation on first fetching 1`] = ` } > + + + + --- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ", + } + } style={ [ { + "backgroundColor": "#ffffff", "flex": 1, - "overflow": "hidden", }, undefined, ] } - > - - + />
diff --git a/app/components/UI/Ramp/index.tsx b/app/components/UI/Ramp/index.tsx index 91a26fec925..799fcf3810d 100644 --- a/app/components/UI/Ramp/index.tsx +++ b/app/components/UI/Ramp/index.tsx @@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import { Order } from '@consensys/on-ramp-sdk'; import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; -import WebView from 'react-native-webview'; +import WebView from '@metamask/react-native-webview'; import AppConstants from '../../../core/AppConstants'; import NotificationManager from '../../../core/NotificationManager'; import { FIAT_ORDER_STATES } from '../../../constants/on-ramp'; diff --git a/app/components/UI/WebviewError/index.js b/app/components/UI/WebviewError/index.js index 0ea91894b55..5665243ee35 100644 --- a/app/components/UI/WebviewError/index.js +++ b/app/components/UI/WebviewError/index.js @@ -4,7 +4,7 @@ import { Image, StyleSheet, View, Text, Platform } from 'react-native'; import StyledButton from '../StyledButton'; import { strings } from '../../../../locales/i18n'; import { fontStyles } from '../../../styles/common'; -import AnimatedFox from '@metamask/react-native-animated-fox'; +import AnimatedFox from '../../Base/AnimatedFox'; import { ThemeContext, mockTheme } from '../../../util/theme'; import Device from '../../../util/device'; import generateTestId from '../../../../wdio/utils/generateTestId'; diff --git a/app/components/Views/BrowserTab/index.js b/app/components/Views/BrowserTab/index.js index 49b55be4097..0198d6fdf62 100644 --- a/app/components/Views/BrowserTab/index.js +++ b/app/components/Views/BrowserTab/index.js @@ -11,7 +11,7 @@ import { } from 'react-native'; import { isEqual } from 'lodash'; import { withNavigation } from '@react-navigation/compat'; -import { WebView } from 'react-native-webview'; +import { WebView } from '@metamask/react-native-webview'; import Icon from 'react-native-vector-icons/FontAwesome'; import MaterialCommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'; import BrowserBottomBar from '../../UI/BrowserBottomBar'; diff --git a/app/components/Views/ChoosePassword/index.js b/app/components/Views/ChoosePassword/index.js index 4057e52d9aa..cfa89ab4c41 100644 --- a/app/components/Views/ChoosePassword/index.js +++ b/app/components/Views/ChoosePassword/index.js @@ -57,7 +57,7 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import { Authentication } from '../../../core'; import AUTHENTICATION_TYPE from '../../../constants/userProperties'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import AnimatedFox from '@metamask/react-native-animated-fox'; +import AnimatedFox from '../../Base/AnimatedFox'; import { LoginOptionsSwitch } from '../../UI/LoginOptionsSwitch'; import navigateTermsOfUse from '../../../util/termsOfUse/termsOfUse'; diff --git a/app/components/Views/Login/index.js b/app/components/Views/Login/index.js index 2a449c674d4..a7558291ec5 100644 --- a/app/components/Views/Login/index.js +++ b/app/components/Views/Login/index.js @@ -47,7 +47,7 @@ import DefaultPreference from 'react-native-default-preference'; import { Authentication } from '../../../core'; import AUTHENTICATION_TYPE from '../../../constants/userProperties'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import AnimatedFox from '@metamask/react-native-animated-fox'; +import AnimatedFox from '../../Base/AnimatedFox'; import { LoginOptionsSwitch } from '../../UI/LoginOptionsSwitch'; import { createRestoreWalletNavDetailsNested } from '../RestoreWallet/RestoreWallet'; import { parseVaultValue } from '../../../util/validators'; diff --git a/app/components/Views/Onboarding/index.js b/app/components/Views/Onboarding/index.js index a94b4179995..7c7599904c6 100644 --- a/app/components/Views/Onboarding/index.js +++ b/app/components/Views/Onboarding/index.js @@ -43,7 +43,7 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import { withMetricsAwareness } from '../../hooks/useMetrics'; import { Authentication } from '../../../core'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import AnimatedFox from '@metamask/react-native-animated-fox'; +import AnimatedFox from '../../Base/AnimatedFox'; import { OnboardingSelectorIDs } from '../../../../e2e/selectors/Onboarding/Onboarding.selectors'; import Routes from '../../../constants/navigation/Routes'; diff --git a/app/components/Views/ResetPassword/index.js b/app/components/Views/ResetPassword/index.js index d917a16806a..8afcb7af43b 100644 --- a/app/components/Views/ResetPassword/index.js +++ b/app/components/Views/ResetPassword/index.js @@ -49,7 +49,7 @@ import { import { Authentication } from '../../../core'; import AUTHENTICATION_TYPE from '../../../constants/userProperties'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import AnimatedFox from '@metamask/react-native-animated-fox'; +import AnimatedFox from '../../Base/AnimatedFox'; import { LoginOptionsSwitch } from '../../UI/LoginOptionsSwitch'; import { recreateVaultWithNewPassword } from '../../../core/Vault'; import Logger from '../../../util/Logger'; diff --git a/app/components/Views/SimpleWebview/__snapshots__/index.test.tsx.snap b/app/components/Views/SimpleWebview/__snapshots__/index.test.tsx.snap index e4b861e5208..8db2c8f4330 100644 --- a/app/components/Views/SimpleWebview/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/SimpleWebview/__snapshots__/index.test.tsx.snap @@ -9,20 +9,11 @@ exports[`SimpleWebview should render correctly 1`] = ` } >
`; diff --git a/app/components/Views/SimpleWebview/index.js b/app/components/Views/SimpleWebview/index.js index c9b9dd933b3..5ff7318ca50 100644 --- a/app/components/Views/SimpleWebview/index.js +++ b/app/components/Views/SimpleWebview/index.js @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { View } from 'react-native'; -import { WebView } from 'react-native-webview'; +import { WebView } from '@metamask/react-native-webview'; import { getWebviewNavbar } from '../../UI/Navbar'; import Share from 'react-native-share'; // eslint-disable-line import/default import Logger from '../../../util/Logger'; diff --git a/app/components/Views/confirmations/components/ApproveTransactionReview/ShowBlockExplorer/index.tsx b/app/components/Views/confirmations/components/ApproveTransactionReview/ShowBlockExplorer/index.tsx index 601b18837cd..1b9d97bb4f5 100644 --- a/app/components/Views/confirmations/components/ApproveTransactionReview/ShowBlockExplorer/index.tsx +++ b/app/components/Views/confirmations/components/ApproveTransactionReview/ShowBlockExplorer/index.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { SafeAreaView, StyleSheet, View } from 'react-native'; import AntDesignIcon from 'react-native-vector-icons/AntDesign'; -import { WebView } from 'react-native-webview'; +import { WebView } from '@metamask/react-native-webview'; import type { NetworkState } from '@metamask/network-controller'; import Text, { diff --git a/app/core/BackgroundBridge/Port.ts b/app/core/BackgroundBridge/Port.ts index dd841df9516..83249bc413b 100644 --- a/app/core/BackgroundBridge/Port.ts +++ b/app/core/BackgroundBridge/Port.ts @@ -23,9 +23,7 @@ class Port extends EventEmitter { const js = this._isMainFrame ? JS_POST_MESSAGE_TO_PROVIDER(msg, origin) : JS_IFRAME_POST_MESSAGE_TO_PROVIDER(msg, origin); - if (this._window.webViewRef?.current) { - this._window?.injectJavaScript(js); - } + this._window?.injectJavaScript(js); }; } diff --git a/app/lib/ppom/PPOMView.tsx b/app/lib/ppom/PPOMView.tsx index 718d2a24438..f061caad6e0 100644 --- a/app/lib/ppom/PPOMView.tsx +++ b/app/lib/ppom/PPOMView.tsx @@ -1,6 +1,6 @@ import React, { Component, RefObject } from 'react'; import { StyleSheet, View } from 'react-native'; -import { WebView } from 'react-native-webview'; +import { WebView } from '@metamask/react-native-webview'; import createInvoke from 'react-native-webview-invoke/native'; import { fromByteArray } from 'react-native-quick-base64'; diff --git a/app/lib/ppom/__snapshots__/PPOMView.test.tsx.snap b/app/lib/ppom/__snapshots__/PPOMView.test.tsx.snap index 66d7efb75a2..d3de6215cac 100644 --- a/app/lib/ppom/__snapshots__/PPOMView.test.tsx.snap +++ b/app/lib/ppom/__snapshots__/PPOMView.test.tsx.snap @@ -11,49 +11,21 @@ exports[`PPOMView should render correctly deeply 1`] = ` } > - Webpack App", } - useSharedProcessPool={true} - /> - + } + />
`; diff --git a/app/lib/snaps/SnapsExecutionWebView.tsx b/app/lib/snaps/SnapsExecutionWebView.tsx index 4959a6cce2a..5cee96bc182 100644 --- a/app/lib/snaps/SnapsExecutionWebView.tsx +++ b/app/lib/snaps/SnapsExecutionWebView.tsx @@ -4,10 +4,10 @@ ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import React, { Component, RefObject } from 'react'; import { View, ScrollView, NativeSyntheticEvent } from 'react-native'; -import WebView, { WebViewMessageEvent } from 'react-native-webview'; +import WebView, { WebViewMessageEvent } from '@metamask/react-native-webview'; import { createStyles } from './styles'; import { WebViewInterface } from '@metamask/snaps-controllers/dist/types/services/webview/WebViewMessageStream'; -import { WebViewError } from 'react-native-webview/lib/WebViewTypes'; +import { WebViewError } from '@metamask/react-native-webview/lib/WebViewTypes'; import { PostMessageEvent } from '@metamask/post-message-stream'; const styles = createStyles(); diff --git a/app/util/test/testSetup.js b/app/util/test/testSetup.js index 78c88376375..3b8b49ba068 100644 --- a/app/util/test/testSetup.js +++ b/app/util/test/testSetup.js @@ -20,6 +20,20 @@ jest.mock('react-native', () => { return originalModule; }); +/* + * NOTE: react-native-webview requires a jest mock starting on v12. + * More info on https://github.com/react-native-webview/react-native-webview/issues/2934 + */ +jest.mock('@metamask/react-native-webview', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const { View } = require('react-native'); + const WebView = (props) => ; + + return { + WebView, + }; +}); + jest.mock('../../lib/snaps/preinstalled-snaps'); const mockFs = { diff --git a/e2e/utils/Matchers.js b/e2e/utils/Matchers.js index 413a67c3433..e9522b6d2a1 100644 --- a/e2e/utils/Matchers.js +++ b/e2e/utils/Matchers.js @@ -73,15 +73,29 @@ class Matchers { return element(by.id(childElement).withAncestor(by.id(parentElement))); } + /** + * Get Native WebView instance by elementId + * + * Because Android Webview might have more that one WebView instance present on the main activity, the correct element + * is select based on its parent element id. + * @param {string} elementId The web ID of the browser webview + * @returns {Detox.WebViewElement} WebView element + */ + static getWebViewByID(elementId) { + return device.getPlatform() === 'ios' + ? web(by.id(elementId)) + : web(by.type('android.webkit.WebView').withAncestor(by.id(elementId))); + } + /** * Get element by web ID. * - * * @param {string} webviewID - The web ID of the inner element to locate within the webview - * @param {string} innerID - The web ID of the browser webview + * @param {string} webviewID - The web ID of the inner element to locate within the webview + * @param {string} innerID - The web ID of the browser webview * @return {Promise} Resolves to the located element */ static async getElementByWebID(webviewID, innerID) { - const myWebView = web(by.id(webviewID)); + const myWebView = this.getWebViewByID(webviewID); return myWebView.element(by.web.id(innerID)); } @@ -101,15 +115,11 @@ class Matchers { * Get element by XPath. * @param {string} webviewID - The web ID of the browser webview * @param {string} xpath - XPath expression to locate the element - * @param {number} index - index to locate the webview (iOS only) * @return {Promise} - Resolves to the located element */ - static async getElementByXPath(webviewID, xpath, index = 0) { - const myWebView = - device.getPlatform() === 'ios' - ? web(by.id(webviewID)).atIndex(index) - : web(by.id(webviewID)); - return myWebView.element(by.web.xpath(xpath)).atIndex(0); + static async getElementByXPath(webviewID, xpath) { + const myWebView = this.getWebViewByID(webviewID); + return myWebView.element(by.web.xpath(xpath)); } /** * Get element by href. diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f3c1ddfaecb..b0636a31545 100755 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -500,7 +500,7 @@ PODS: - React-Core - react-native-view-shot (3.1.2): - React - - react-native-webview (11.13.0): + - react-native-webview-mm (14.0.1): - React-Core - React-perflogger (0.71.15) - React-RCTActionSheet (0.71.15): @@ -762,7 +762,7 @@ DEPENDENCIES: - "react-native-splash-screen (from `../node_modules/@metamask/react-native-splash-screen`)" - react-native-video (from `../node_modules/react-native-video`) - react-native-view-shot (from `../node_modules/react-native-view-shot`) - - react-native-webview (from `../node_modules/react-native-webview`) + - "react-native-webview-mm (from `../node_modules/@metamask/react-native-webview`)" - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) @@ -939,8 +939,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-video" react-native-view-shot: :path: "../node_modules/react-native-view-shot" - react-native-webview: - :path: "../node_modules/react-native-webview" + react-native-webview-mm: + :path: "../node_modules/@metamask/react-native-webview" React-perflogger: :path: "../node_modules/react-native/ReactCommon/reactperflogger" React-RCTActionSheet: @@ -1028,7 +1028,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Base64: cecfb41a004124895a7bcee567a89bae5a89d49b - boost: 7dcd2de282d72e344012f7d6564d024930a6a440 + boost: 57d2868c099736d80fcd648bf211b4431e51a558 Branch: 4ac024cb3c29b0ef628048694db3c4cfa679beb0 BVLinearGradient: e3aad03778a456d77928f594a649e96995f1c872 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 @@ -1104,7 +1104,7 @@ SPEC CHECKSUMS: react-native-splash-screen: 49a7160705f32169d27ab8dff9dda53331592412 react-native-video: c26780b224543c62d5e1b2a7244a5cd1b50e8253 react-native-view-shot: 4475fde003fe8a210053d1f98fb9e06c1d834e1c - react-native-webview: 133a6a5149f963259646e710b4545c67ef35d7c9 + react-native-webview-mm: 067bec145f395c6c46c598c8721f557dc3deafcd React-perflogger: 0cc42978a483a47f3696171dac2e7033936fc82d React-RCTActionSheet: ea922b476d24f6d40b8e02ac3228412bd3637468 React-RCTAnimation: 7be2c148398eaa5beac950b2b5ec7102389ec3ad diff --git a/jest.config.js b/jest.config.js index 2cbdf35c327..8f6df8f69fb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -31,6 +31,7 @@ const config = { '/app/util/testUtils/', '/app/lib/ppom/ppom.html.js', '/app/lib/ppom/blockaid-version.js', + '/app/core/InpageBridgeWeb3.js', ], coverageReporters: ['text-summary', 'lcov'], coverageDirectory: '/tests/coverage', diff --git a/package.json b/package.json index 552e94579b0..4fe7634522e 100644 --- a/package.json +++ b/package.json @@ -168,11 +168,11 @@ "@metamask/ppom-validator": "0.31.0", "@metamask/preferences-controller": "^11.0.0", "@metamask/react-native-actionsheet": "2.4.2", - "@metamask/react-native-animated-fox": "^2.1.0", "@metamask/react-native-button": "^3.0.0", "@metamask/react-native-payments": "^2.0.0", "@metamask/react-native-search-api": "1.0.1", "@metamask/react-native-splash-screen": "^3.2.0", + "@metamask/react-native-webview": "^14.0.1", "@metamask/rpc-errors": "^6.2.1", "@metamask/scure-bip39": "^2.1.0", "@metamask/sdk-communication-layer": "^0.20.2", @@ -326,7 +326,6 @@ "react-native-vector-icons": "6.4.2", "react-native-video": "5.2.1", "react-native-view-shot": "^3.1.2", - "react-native-webview": "11.13.0", "react-native-webview-invoke": "^0.6.2", "react-redux": "^8.1.3", "readable-stream": "2.3.7", diff --git a/patches/react-native-webview+11.13.0.patch b/patches/react-native-webview+11.13.0.patch deleted file mode 100644 index d613485d404..00000000000 --- a/patches/react-native-webview+11.13.0.patch +++ /dev/null @@ -1,1401 +0,0 @@ -diff --git a/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/CustomCookieJar.java b/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/CustomCookieJar.java -new file mode 100644 -index 0000000..f4a6af9 ---- /dev/null -+++ b/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/CustomCookieJar.java -@@ -0,0 +1,78 @@ -+package com.reactnativecommunity.webview; -+import android.util.Log; -+import android.webkit.CookieManager; -+import android.webkit.ValueCallback; -+import java.net.HttpURLConnection; -+import java.net.URL; -+import java.util.ArrayList; -+import java.util.Collection; -+import java.util.Iterator; -+import java.util.List; -+import java.util.Map; -+import okhttp3.Cookie; -+import okhttp3.CookieJar; -+import okhttp3.HttpUrl; -+ -+class CustomCookieJar implements CookieJar { -+ private Worker worker; -+ private CookieManager cookieManager; -+ -+ public CustomCookieJar() { -+ worker = new Worker(); -+ cookieManager = this.getCookieManager(); -+ } -+ -+ private CookieManager getCookieManager() { -+ CookieManager cookieManager = CookieManager.getInstance(); -+ cookieManager.setAcceptCookie(true); -+ return cookieManager; -+ } -+ -+ @Override -+ public void saveFromResponse(HttpUrl url, List cookies) { -+ worker.execute(() -> { -+ try { -+ -+ for (Cookie cookie : cookies) { -+ String _url = url.toString(); -+ String _cookie = cookie.toString(); -+ cookieManager.setCookie(_url, _cookie, new ValueCallback() { -+ @Override -+ public void onReceiveValue(Boolean value) {} -+ }); -+ cookieManager.flush(); -+ } -+ } catch (Exception e) { -+ e.printStackTrace(); -+ } -+ }); -+ } -+ -+ @Override -+ public List loadForRequest(HttpUrl httpUrl) { -+ List cookieList = new ArrayList(); -+ try { -+ -+ if (cookieManager.hasCookies()) { -+ String response = cookieManager.getCookie(httpUrl.toString()); -+ -+ if (response != null) { -+ String[] browserCookies = response.split(";"); -+ -+ for (String cookieStr : browserCookies) { -+ Cookie cookie = Cookie.parse(httpUrl, cookieStr); -+ if (cookie == null) { -+ continue; -+ } -+ cookieList.add(cookie); -+ } -+ } -+ -+ } -+ return cookieList; -+ } catch (Exception e) { -+ e.printStackTrace(); -+ return cookieList; -+ } -+ } -+} -diff --git a/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java b/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java -index f743bbc..b520532 100644 ---- a/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java -+++ b/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManager.java -@@ -5,6 +5,7 @@ import android.annotation.TargetApi; - import android.app.Activity; - import android.app.DownloadManager; - import android.content.Context; -+import android.content.Intent; - import android.content.pm.ActivityInfo; - import android.content.pm.PackageManager; - import android.graphics.Bitmap; -@@ -14,6 +15,7 @@ import android.net.http.SslError; - import android.net.Uri; - import android.os.Build; - import android.os.Environment; -+import android.os.Handler; - import android.os.Message; - import android.os.SystemClock; - import android.text.TextUtils; -@@ -24,12 +26,17 @@ import android.view.View; - import android.view.ViewGroup; - import android.view.ViewGroup.LayoutParams; - import android.view.WindowManager; -+import android.view.inputmethod.InputMethodManager; - import android.webkit.ConsoleMessage; - import android.webkit.CookieManager; - import android.webkit.DownloadListener; - import android.webkit.GeolocationPermissions; - import android.webkit.JavascriptInterface; -+import android.webkit.JsPromptResult; -+import android.webkit.JsResult; - import android.webkit.RenderProcessGoneDetail; -+import android.webkit.ServiceWorkerClient; -+import android.webkit.ServiceWorkerController; - import android.webkit.SslErrorHandler; - import android.webkit.PermissionRequest; - import android.webkit.URLUtil; -@@ -40,6 +47,7 @@ import android.webkit.WebResourceResponse; - import android.webkit.WebSettings; - import android.webkit.WebView; - import android.webkit.WebViewClient; -+import android.widget.Button; - import android.widget.FrameLayout; - - import androidx.annotation.Nullable; -@@ -88,18 +96,54 @@ import com.reactnativecommunity.webview.events.TopRenderProcessGoneEvent; - import org.json.JSONException; - import org.json.JSONObject; - -+import java.io.ByteArrayInputStream; -+import java.io.IOException; -+import java.io.InputStream; - import java.io.UnsupportedEncodingException; -+import java.lang.reflect.Field; -+import java.net.CookieStore; -+import java.net.HttpCookie; -+import java.net.HttpURLConnection; - import java.net.MalformedURLException; -+import java.net.URI; -+import java.net.URISyntaxException; - import java.net.URL; - import java.net.URLEncoder; -+import java.nio.charset.Charset; -+import java.nio.charset.StandardCharsets; -+import java.nio.charset.UnsupportedCharsetException; -+import java.text.Bidi; - import java.util.ArrayList; -+import java.util.Arrays; -+import java.util.Collection; - import java.util.Collections; - import java.util.HashMap; -+import java.util.HashSet; - import java.util.List; - import java.util.Locale; - import java.util.Map; -+import java.util.Objects; -+import java.util.Set; - import java.util.concurrent.atomic.AtomicReference; - -+import okhttp3.MediaType; -+import okhttp3.OkHttpClient; -+import okhttp3.Request; -+import okhttp3.RequestBody; -+import okhttp3.Response; -+ -+import android.view.inputmethod.BaseInputConnection; -+import android.view.inputmethod.EditorInfo; -+import android.view.inputmethod.InputConnection; -+ -+ -+import android.content.DialogInterface; -+import android.os.Bundle; -+import android.widget.Toast; -+ -+import androidx.appcompat.app.AlertDialog; -+import androidx.appcompat.app.AppCompatActivity; -+ - /** - * Manages instances of {@link WebView} - *

-@@ -137,13 +181,19 @@ public class RNCWebViewManager extends SimpleViewManager { - public static final int COMMAND_LOAD_URL = 7; - public static final int COMMAND_FOCUS = 8; - -+ protected static final String MIME_UNKNOWN = "application/octet-stream"; -+ protected static final String HTML_ENCODING = "UTF-8"; -+ protected static final long BYTES_IN_MEGABYTE = 1000000; -+ - // android commands - public static final int COMMAND_CLEAR_FORM_DATA = 1000; - public static final int COMMAND_CLEAR_CACHE = 1001; - public static final int COMMAND_CLEAR_HISTORY = 1002; - - protected static final String REACT_CLASS = "RNCWebView"; -- protected static final String HTML_ENCODING = "UTF-8"; -+ -+ protected static final String HEADER_CONTENT_TYPE = "content-type"; -+ - protected static final String HTML_MIME_TYPE = "text/html"; - protected static final String JAVASCRIPT_INTERFACE = "ReactNativeWebView"; - protected static final String HTTP_METHOD_POST = "POST"; -@@ -155,13 +205,19 @@ public class RNCWebViewManager extends SimpleViewManager { - - protected RNCWebChromeClient mWebChromeClient = null; - protected boolean mAllowsFullscreenVideo = false; -- protected @Nullable String mUserAgent = null; -- protected @Nullable String mUserAgentWithApplicationName = null; -+ protected @Nullable String RNUserAgent = null; -+ protected @Nullable String RNUserAgentWithApplicationName = null; -+ protected static String deviceUserAgent; -+ -+ protected static OkHttpClient httpClient; - - public RNCWebViewManager() { -- mWebViewConfig = new WebViewConfig() { -- public void configWebView(WebView webView) { -- } -+ mWebViewConfig = webView -> { -+ httpClient = new OkHttpClient.Builder() -+ .cookieJar(new CustomCookieJar()) -+ .followRedirects(false) -+ .followSslRedirects(false) -+ .build(); - }; - } - -@@ -182,6 +238,7 @@ public class RNCWebViewManager extends SimpleViewManager { - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - protected WebView createViewInstance(ThemedReactContext reactContext) { - RNCWebView webView = createRNCWebViewInstance(reactContext); -+ deviceUserAgent = webView.getSettings().getUserAgentString(); - setupWebChromeClient(reactContext, webView); - reactContext.addLifecycleEventListener(webView); - mWebViewConfig.configWebView(webView); -@@ -209,47 +266,161 @@ public class RNCWebViewManager extends SimpleViewManager { - } - - webView.setDownloadListener(new DownloadListener() { -+ protected ReactContext mReactContext; -+ - public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimetype, long contentLength) { - webView.setIgnoreErrFailedForThisURL(url); -+ this.mReactContext = reactContext; - - RNCWebViewModule module = getModule(reactContext); -- - DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url)); -- - String fileName = URLUtil.guessFileName(url, contentDisposition, mimetype); -- String downloadMessage = "Downloading " + fileName; -+ //Filename validation checking for files that use RTL characters and do not allow those types -+ if(fileName == null || (fileName != null && (new Bidi(fileName, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isMixed()))) { -+ Toast.makeText(mReactContext, "Invalid filename or type", Toast.LENGTH_SHORT).show(); -+ } else { -+ AlertDialog.Builder builder = new AlertDialog.Builder(mReactContext); -+ builder.setMessage("Do you want to download \n" + fileName + "?"); -+ builder.setCancelable(false); -+ builder.setPositiveButton("Download", new DialogInterface.OnClickListener() { -+ public void onClick(DialogInterface dialog, int which) { -+ String downloadMessage = "Downloading " + fileName; -+ -+ //Attempt to add cookie, if it exists -+ URL urlObj = null; -+ try { -+ urlObj = new URL(url); -+ String baseUrl = urlObj.getProtocol() + "://" + urlObj.getHost(); -+ String cookie = CookieManager.getInstance().getCookie(baseUrl); -+ request.addRequestHeader("Cookie", cookie); -+ } catch (MalformedURLException e) { -+ System.out.println("Error getting cookie for DownloadManager: " + e.toString()); -+ e.printStackTrace(); -+ } - -- //Attempt to add cookie, if it exists -- URL urlObj = null; -- try { -- urlObj = new URL(url); -- String baseUrl = urlObj.getProtocol() + "://" + urlObj.getHost(); -- String cookie = CookieManager.getInstance().getCookie(baseUrl); -- request.addRequestHeader("Cookie", cookie); -- } catch (MalformedURLException e) { -- System.out.println("Error getting cookie for DownloadManager: " + e.toString()); -- e.printStackTrace(); -+ //Finish setting up request -+ request.addRequestHeader("User-Agent", userAgent); -+ request.setTitle(fileName); -+ request.setDescription(downloadMessage); -+ request.allowScanningByMediaScanner(); -+ request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); -+ request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); -+ module.setDownloadRequest(request); -+ if (module.grantFileDownloaderPermissions()) { -+ module.downloadFile(); -+ } -+ } -+ }); -+ builder.setNegativeButton("Cancel", (DialogInterface.OnClickListener) (dialog, which) -> { -+ return; -+ }); -+ AlertDialog alertDialog = builder.create(); -+ alertDialog.show(); - } -+ } -+ }); - -- //Finish setting up request -- request.addRequestHeader("User-Agent", userAgent); -- request.setTitle(fileName); -- request.setDescription(downloadMessage); -- request.allowScanningByMediaScanner(); -- request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED); -- request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName); -+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { -+ ServiceWorkerController swController = ServiceWorkerController.getInstance(); -+ swController.setServiceWorkerClient(new ServiceWorkerClient() { -+ @Override -+ public WebResourceResponse shouldInterceptRequest(WebResourceRequest request) { -+ String method = request.getMethod(); - -- module.setDownloadRequest(request); -+ if (method.equals("GET")) { -+ WebResourceResponse response = RNCWebViewManager.this.shouldInterceptRequest(request, false, webView); -+ if (response != null) { -+ return response; -+ } -+ } - -- if (module.grantFileDownloaderPermissions()) { -- module.downloadFile(); -+ return super.shouldInterceptRequest(request); - } -- } -- }); -+ }); -+ } - - return webView; - } - -+ private Boolean urlStringLooksInvalid(String urlString) { -+ return urlString == null || -+ urlString.trim().equals("") || -+ !(urlString.startsWith("http") && !urlString.startsWith("www")) || -+ urlString.contains("|"); -+ } -+ -+ public static Boolean responseRequiresJSInjection(Response response) { -+ if (response.isRedirect()) { -+ return false; -+ } -+ final String contentTypeAndCharset = response.header(HEADER_CONTENT_TYPE, MIME_UNKNOWN); -+ final int responseCode = response.code(); -+ -+ boolean contentTypeIsHtml = contentTypeAndCharset.startsWith(HTML_MIME_TYPE); -+ boolean responseCodeIsInjectible = responseCode == 200; -+ String responseBody = ""; -+ -+ if (contentTypeIsHtml && responseCodeIsInjectible) { -+ try { -+ assert response.body() != null; -+ responseBody = response.peekBody(BYTES_IN_MEGABYTE).string(); -+ } catch (IOException e) { -+ e.printStackTrace(); -+ return false; -+ } -+ -+ -+ boolean responseBodyContainsHTMLLikeString = responseBody.matches("[\\S\\s]*<[a-z]+[\\S\\s]*>[\\S\\s]*"); -+ return responseBodyContainsHTMLLikeString; -+ } else { -+ return false; -+ } -+ } -+ -+ public WebResourceResponse shouldInterceptRequest(WebResourceRequest request, Boolean onlyMainFrame, RNCWebView webView) { -+ Uri url = request.getUrl(); -+ String urlStr = url.toString(); -+ -+ if (onlyMainFrame && !request.isForMainFrame() || -+ urlStringLooksInvalid(urlStr)) { -+ return null; -+ } -+ -+ String _userAgent; -+ -+ if (RNUserAgent != null) { -+ _userAgent = RNUserAgent; -+ } else { -+ _userAgent = deviceUserAgent; -+ } -+ -+ try { -+ Request req = new Request.Builder() -+ .url(urlStr) -+ .header("User-Agent", _userAgent) -+ .build(); -+ -+ Response response = httpClient.newCall(req).execute(); -+ -+ if (!responseRequiresJSInjection(response)) { -+ return null; -+ } -+ -+ InputStream is = response.body().byteStream(); -+ MediaType contentType = response.body().contentType(); -+ Charset charset = contentType != null ? contentType.charset(StandardCharsets.UTF_8) : StandardCharsets.UTF_8; -+ -+ RNCWebView reactWebView = (RNCWebView) webView; -+ if (response.code() == HttpURLConnection.HTTP_OK) { -+ is = new InputStreamWithInjectedJS(is, reactWebView.injectedJSBeforeContentLoaded, charset); -+ } -+ -+ return new WebResourceResponse("text/html", charset.name(), is); -+ } catch (IOException e) { -+ return null; -+ } -+ } -+ - @ReactProp(name = "javaScriptEnabled") - public void setJavaScriptEnabled(WebView view, boolean enabled) { - view.getSettings().setJavaScriptEnabled(enabled); -@@ -285,13 +456,10 @@ public class RNCWebViewManager extends SimpleViewManager { - if (enabled) { - Context ctx = view.getContext(); - if (ctx != null) { -- view.getSettings().setAppCachePath(ctx.getCacheDir().getAbsolutePath()); - view.getSettings().setCacheMode(WebSettings.LOAD_DEFAULT); -- view.getSettings().setAppCacheEnabled(true); - } - } else { - view.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); -- view.getSettings().setAppCacheEnabled(false); - } - } - -@@ -327,12 +495,12 @@ public class RNCWebViewManager extends SimpleViewManager { - public void setLayerType(WebView view, String layerTypeString) { - int layerType = View.LAYER_TYPE_NONE; - switch (layerTypeString) { -- case "hardware": -- layerType = View.LAYER_TYPE_HARDWARE; -- break; -- case "software": -- layerType = View.LAYER_TYPE_SOFTWARE; -- break; -+ case "hardware": -+ layerType = View.LAYER_TYPE_HARDWARE; -+ break; -+ case "software": -+ layerType = View.LAYER_TYPE_SOFTWARE; -+ break; - } - view.setLayerType(layerType, null); - } -@@ -387,9 +555,9 @@ public class RNCWebViewManager extends SimpleViewManager { - @ReactProp(name = "userAgent") - public void setUserAgent(WebView view, @Nullable String userAgent) { - if (userAgent != null) { -- mUserAgent = userAgent; -+ RNUserAgent = userAgent; - } else { -- mUserAgent = null; -+ RNUserAgent = null; - } - this.setUserAgentString(view); - } -@@ -399,19 +567,19 @@ public class RNCWebViewManager extends SimpleViewManager { - if(applicationName != null) { - if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - String defaultUserAgent = WebSettings.getDefaultUserAgent(view.getContext()); -- mUserAgentWithApplicationName = defaultUserAgent + " " + applicationName; -+ RNUserAgentWithApplicationName = defaultUserAgent + " " + applicationName; - } - } else { -- mUserAgentWithApplicationName = null; -+ RNUserAgentWithApplicationName = null; - } - this.setUserAgentString(view); - } - - protected void setUserAgentString(WebView view) { -- if(mUserAgent != null) { -- view.getSettings().setUserAgentString(mUserAgent); -- } else if(mUserAgentWithApplicationName != null) { -- view.getSettings().setUserAgentString(mUserAgentWithApplicationName); -+ if(RNUserAgent != null) { -+ view.getSettings().setUserAgentString(RNUserAgent); -+ } else if(RNUserAgentWithApplicationName != null) { -+ view.getSettings().setUserAgentString(RNUserAgentWithApplicationName); - } else if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - // handle unsets of `userAgent` prop as long as device is >= API 17 - view.getSettings().setUserAgentString(WebSettings.getDefaultUserAgent(view.getContext())); -@@ -490,7 +658,6 @@ public class RNCWebViewManager extends SimpleViewManager { - - // Disable caching - view.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); -- view.getSettings().setAppCacheEnabled(false); - view.clearHistory(); - view.clearCache(true); - -@@ -842,13 +1009,116 @@ public class RNCWebViewManager extends SimpleViewManager { - } - } - -- protected static class RNCWebViewClient extends WebViewClient { -+ public static class InputStreamWithInjectedJS extends InputStream { -+ private InputStream pageIS; -+ private InputStream scriptIS; -+ private Charset charset; -+ private static final String REACT_CLASS = "InputStreamWithInjectedJS"; -+ private static Map script = new HashMap<>(); -+ -+ private boolean hasJS = false; -+ private boolean headWasFound = false; -+ private boolean scriptWasInjected = false; -+ -+ private int lowercaseD = 100; -+ private int closingTag = 62; -+ private boolean hasClosingHead = false; -+ -+ private StringBuffer contentBuffer = new StringBuffer(); -+ -+ @SuppressLint("LongLogTag") -+ private static Charset getCharset(String charsetName) { -+ Charset cs = StandardCharsets.UTF_8; -+ try { -+ if (charsetName != null) { -+ cs = Charset.forName(charsetName); -+ } -+ } catch (UnsupportedCharsetException e) { -+ Log.d(REACT_CLASS, "wrong charset: " + charsetName); -+ } -+ -+ return cs; -+ } -+ -+ private static InputStream getScript(Charset charset) { -+ String js = script.get(charset); -+ if (js == null) { -+ String defaultJs = script.get(StandardCharsets.UTF_8); -+ js = new String(defaultJs.getBytes(StandardCharsets.UTF_8), charset); -+ script.put(charset, js); -+ } -+ -+ return new ByteArrayInputStream(js.getBytes(charset)); -+ } -+ -+ InputStreamWithInjectedJS(InputStream is, String js, Charset charset) { -+ if (js == null) { -+ this.pageIS = is; -+ } else { -+ this.hasJS = true; -+ this.charset = charset; -+ Charset cs = StandardCharsets.UTF_8; -+ String jsScript = ""; -+ script.put(cs, jsScript); -+ this.pageIS = is; -+ } -+ } -+ -+ @Override -+ public int read() throws IOException { -+ if (scriptWasInjected || !hasJS) { -+ return pageIS.read(); -+ } -+ -+ if (!scriptWasInjected && headWasFound) { -+ int nextByte; -+ if (!hasClosingHead) { -+ nextByte = pageIS.read(); -+ if (nextByte != closingTag) { -+ return nextByte; -+ } -+ hasClosingHead = true; -+ return nextByte; -+ } -+ nextByte = scriptIS.read(); -+ if (nextByte == -1) { -+ scriptIS.close(); -+ scriptWasInjected = true; -+ return pageIS.read(); -+ } else { -+ return nextByte; -+ } -+ } -+ -+ if (!headWasFound) { -+ int nextByte = pageIS.read(); -+ contentBuffer.append((char) nextByte); -+ int bufferLength = contentBuffer.length(); -+ if (nextByte == lowercaseD && bufferLength >= 5) { -+ if (contentBuffer.substring(bufferLength - 5).equals(" { - @Override - public void onPageFinished(WebView webView, String url) { - super.onPageFinished(webView, url); -+ // Only return the URL that the web view is currently showing. -+ String visibleUrl = webView.getUrl(); -+ Boolean isFinishedLoading = url.equals(visibleUrl); - -- if (!mLastLoadFailed) { -+ if (!mLastLoadFailed && isFinishedLoading) { -+ if(Objects.nonNull(mWebChromeClient)) mWebChromeClient.blockJsDuringLoading = false; - RNCWebView reactWebView = (RNCWebView) webView; - - reactWebView.callInjectedJavaScript(); - -- emitFinishEvent(webView, url); -+ emitFinishEvent(webView, visibleUrl); - } - } - - @Override - public void onPageStarted(WebView webView, String url, Bitmap favicon) { - super.onPageStarted(webView, url, favicon); -+ if(Objects.nonNull(mWebChromeClient)) mWebChromeClient.blockJsDuringLoading = true; - mLastLoadFailed = false; - -- RNCWebView reactWebView = (RNCWebView) webView; -- reactWebView.callInjectedJavaScriptBeforeContentLoaded(); -- - ((RNCWebView) webView).dispatchEvent( - webView, - new TopLoadingStartEvent( -@@ -882,6 +1154,20 @@ public class RNCWebViewManager extends SimpleViewManager { - createWebViewEvent(webView, url))); - } - -+ @Override -+ public WebResourceResponse shouldInterceptRequest(WebView webView, WebResourceRequest request) { -+ String method = request.getMethod(); -+ -+ if (method.equals("GET")) { -+ WebResourceResponse response = RNCWebViewManager.this.shouldInterceptRequest(request, true, (RNCWebView)webView); -+ if (response != null) { -+ return response; -+ } -+ } -+ -+ return super.shouldInterceptRequest(webView, request); -+ } -+ - @Override - public boolean shouldOverrideUrlLoading(WebView view, String url) { - final RNCWebView rncWebView = (RNCWebView) view; -@@ -891,7 +1177,6 @@ public class RNCWebViewManager extends SimpleViewManager { - final Pair> lock = RNCWebViewModule.shouldOverrideUrlLoadingLock.getNewLock(); - final int lockIdentifier = lock.first; - final AtomicReference lockObject = lock.second; -- - final WritableMap event = createWebViewEvent(view, url); - event.putInt("lockIdentifier", lockIdentifier); - rncWebView.sendDirectMessage("onShouldStartLoadWithRequest", event); -@@ -919,6 +1204,17 @@ public class RNCWebViewManager extends SimpleViewManager { - RNCWebViewModule.shouldOverrideUrlLoadingLock.removeLock(lockIdentifier); - - return shouldOverride; -+ } else if (url != null && Arrays.asList(DEEPLINK_ALLOW_LIST).contains(url)) { -+ // This case is used to support deeplinking within the webview. We are limiting this but -+ // if more links are to be supported we should consider a more scaleable solution. That is -+ // secure and scaleable. -+ Intent intent = new Intent(Intent.ACTION_VIEW); -+ intent.setData(Uri.parse(url)); -+ if(intent.resolveActivity(view.getContext().getPackageManager()) != null) { -+ view.getContext().startActivity(intent); -+ return true; -+ } else -+ return false; - } else { - FLog.w(TAG, "Couldn't use blocking synchronous call for onShouldStartLoadWithRequest due to debugging or missing Catalyst instance, falling back to old event-and-load."); - progressChangedFilter.setWaitingForCommandLoadUrl(true); -@@ -934,67 +1230,86 @@ public class RNCWebViewManager extends SimpleViewManager { - @TargetApi(Build.VERSION_CODES.N) - @Override - public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { -+ if(Objects.nonNull(mWebChromeClient)) mWebChromeClient.blockJsDuringLoading = true; -+ -+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { -+ -+ /* -+ * In order to follow redirects properly, we return null in interceptRequest(). -+ * Doing this breaks the web3 injection on the resulting page, so we have to reload to -+ * make sure web3 is available. -+ * */ -+ -+ if (request.isForMainFrame() && request.isRedirect()) { -+ view.loadUrl(request.getUrl().toString()); -+ return true; -+ } -+ } -+ - final String url = request.getUrl().toString(); -+ - return this.shouldOverrideUrlLoading(view, url); - } - -+ -+ - @Override - public void onReceivedSslError(final WebView webView, final SslErrorHandler handler, final SslError error) { -- // onReceivedSslError is called for most requests, per Android docs: https://developer.android.com/reference/android/webkit/WebViewClient#onReceivedSslError(android.webkit.WebView,%2520android.webkit.SslErrorHandler,%2520android.net.http.SslError) -- // WebView.getUrl() will return the top-level window URL. -- // If a top-level navigation triggers this error handler, the top-level URL will be the failing URL (not the URL of the currently-rendered page). -- // This is desired behavior. We later use these values to determine whether the request is a top-level navigation or a subresource request. -- String topWindowUrl = webView.getUrl(); -- String failingUrl = error.getUrl(); -- -- // Cancel request after obtaining top-level URL. -- // If request is cancelled before obtaining top-level URL, undesired behavior may occur. -- // Undesired behavior: Return value of WebView.getUrl() may be the current URL instead of the failing URL. -- handler.cancel(); -- -- if (!topWindowUrl.equalsIgnoreCase(failingUrl)) { -- // If error is not due to top-level navigation, then do not call onReceivedError() -- Log.w("RNCWebViewManager", "Resource blocked from loading due to SSL error. Blocked URL: "+failingUrl); -- return; -- } -+ // onReceivedSslError is called for most requests, per Android docs: https://developer.android.com/reference/android/webkit/WebViewClient#onReceivedSslError(android.webkit.WebView,%2520android.webkit.SslErrorHandler,%2520android.net.http.SslError) -+ // WebView.getUrl() will return the top-level window URL. -+ // If a top-level navigation triggers this error handler, the top-level URL will be the failing URL (not the URL of the currently-rendered page). -+ // This is desired behavior. We later use these values to determine whether the request is a top-level navigation or a subresource request. -+ String topWindowUrl = webView.getUrl(); -+ String failingUrl = error.getUrl(); -+ -+ // Cancel request after obtaining top-level URL. -+ // If request is cancelled before obtaining top-level URL, undesired behavior may occur. -+ // Undesired behavior: Return value of WebView.getUrl() may be the current URL instead of the failing URL. -+ handler.cancel(); -+ -+ if (!topWindowUrl.equalsIgnoreCase(failingUrl)) { -+ // If error is not due to top-level navigation, then do not call onReceivedError() -+ Log.w("RNCWebViewManager", "Resource blocked from loading due to SSL error. Blocked URL: "+failingUrl); -+ return; -+ } - -- int code = error.getPrimaryError(); -- String description = ""; -- String descriptionPrefix = "SSL error: "; -- -- // https://developer.android.com/reference/android/net/http/SslError.html -- switch (code) { -- case SslError.SSL_DATE_INVALID: -- description = "The date of the certificate is invalid"; -- break; -- case SslError.SSL_EXPIRED: -- description = "The certificate has expired"; -- break; -- case SslError.SSL_IDMISMATCH: -- description = "Hostname mismatch"; -- break; -- case SslError.SSL_INVALID: -- description = "A generic error occurred"; -- break; -- case SslError.SSL_NOTYETVALID: -- description = "The certificate is not yet valid"; -- break; -- case SslError.SSL_UNTRUSTED: -- description = "The certificate authority is not trusted"; -- break; -- default: -- description = "Unknown SSL Error"; -- break; -- } -+ int code = error.getPrimaryError(); -+ String description = ""; -+ String descriptionPrefix = "SSL error: "; - -- description = descriptionPrefix + description; -+ // https://developer.android.com/reference/android/net/http/SslError.html -+ switch (code) { -+ case SslError.SSL_DATE_INVALID: -+ description = "The date of the certificate is invalid"; -+ break; -+ case SslError.SSL_EXPIRED: -+ description = "The certificate has expired"; -+ break; -+ case SslError.SSL_IDMISMATCH: -+ description = "Hostname mismatch"; -+ break; -+ case SslError.SSL_INVALID: -+ description = "A generic error occurred"; -+ break; -+ case SslError.SSL_NOTYETVALID: -+ description = "The certificate is not yet valid"; -+ break; -+ case SslError.SSL_UNTRUSTED: -+ description = "The certificate authority is not trusted"; -+ break; -+ default: -+ description = "Unknown SSL Error"; -+ break; -+ } - -- this.onReceivedError( -- webView, -- code, -- description, -- failingUrl -- ); -+ description = descriptionPrefix + description; -+ -+ this.onReceivedError( -+ webView, -+ code, -+ description, -+ failingUrl -+ ); - } - - @Override -@@ -1005,9 +1320,9 @@ public class RNCWebViewManager extends SimpleViewManager { - String failingUrl) { - - if (ignoreErrFailedForThisURL != null -- && failingUrl.equals(ignoreErrFailedForThisURL) -- && errorCode == -1 -- && description.equals("net::ERR_FAILED")) { -+ && failingUrl.equals(ignoreErrFailedForThisURL) -+ && errorCode == -1 -+ && description.equals("net::ERR_FAILED")) { - - // This is a workaround for a bug in the WebView. - // See these chromium issues for more context: -@@ -1056,36 +1371,36 @@ public class RNCWebViewManager extends SimpleViewManager { - @TargetApi(Build.VERSION_CODES.O) - @Override - public boolean onRenderProcessGone(WebView webView, RenderProcessGoneDetail detail) { -- // WebViewClient.onRenderProcessGone was added in O. -- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { -- return false; -- } -- super.onRenderProcessGone(webView, detail); -+ // WebViewClient.onRenderProcessGone was added in O. -+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { -+ return false; -+ } -+ super.onRenderProcessGone(webView, detail); - -- if(detail.didCrash()){ -- Log.e("RNCWebViewManager", "The WebView rendering process crashed."); -- } -- else{ -- Log.w("RNCWebViewManager", "The WebView rendering process was killed by the system."); -- } -+ if(detail.didCrash()){ -+ Log.e("RNCWebViewManager", "The WebView rendering process crashed."); -+ } -+ else{ -+ Log.w("RNCWebViewManager", "The WebView rendering process was killed by the system."); -+ } - -- // if webView is null, we cannot return any event -- // since the view is already dead/disposed -- // still prevent the app crash by returning true. -- if(webView == null){ -- return true; -- } -+ // if webView is null, we cannot return any event -+ // since the view is already dead/disposed -+ // still prevent the app crash by returning true. -+ if(webView == null){ -+ return true; -+ } - -- WritableMap event = createWebViewEvent(webView, webView.getUrl()); -- event.putBoolean("didCrash", detail.didCrash()); -+ WritableMap event = createWebViewEvent(webView, webView.getUrl()); -+ event.putBoolean("didCrash", detail.didCrash()); - - ((RNCWebView) webView).dispatchEvent( -- webView, -- new TopRenderProcessGoneEvent(webView.getId(), event) -- ); -+ webView, -+ new TopRenderProcessGoneEvent(webView.getId(), event) -+ ); - -- // returning false would crash the app. -- return true; -+ // returning false would crash the app. -+ return true; - } - - protected void emitFinishEvent(WebView webView, String url) { -@@ -1138,6 +1453,7 @@ public class RNCWebViewManager extends SimpleViewManager { - - protected View mVideoView; - protected WebChromeClient.CustomViewCallback mCustomViewCallback; -+ protected boolean blockJsDuringLoading = true; //This boolean block JS prompts and alerts from displaying during loading - - /* - * - Permissions - -@@ -1211,8 +1527,8 @@ public class RNCWebViewManager extends SimpleViewManager { - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - @Override - public void onPermissionRequest(final PermissionRequest request) { -- - grantedPermissions = new ArrayList<>(); -+ ArrayList requestPermissionIdentifiers = new ArrayList<>(); - - ArrayList requestedAndroidPermissions = new ArrayList<>(); - for (String requestedResource : request.getResources()) { -@@ -1220,36 +1536,74 @@ public class RNCWebViewManager extends SimpleViewManager { - - if (requestedResource.equals(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) { - androidPermission = Manifest.permission.RECORD_AUDIO; -+ requestPermissionIdentifiers.add("microphone"); - } else if (requestedResource.equals(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) { - androidPermission = Manifest.permission.CAMERA; -+ requestPermissionIdentifiers.add("camera"); - } else if(requestedResource.equals(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)) { - androidPermission = PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID; - } - // TODO: RESOURCE_MIDI_SYSEX, RESOURCE_PROTECTED_MEDIA_ID. -- - if (androidPermission != null) { -- if (ContextCompat.checkSelfPermission(mReactContext, androidPermission) == PackageManager.PERMISSION_GRANTED) { -- grantedPermissions.add(requestedResource); -- } else { -- requestedAndroidPermissions.add(androidPermission); -- } -+ if (ContextCompat.checkSelfPermission(mReactContext, androidPermission) == PackageManager.PERMISSION_GRANTED) { -+ grantedPermissions.add(requestedResource); -+ } else { -+ requestedAndroidPermissions.add(androidPermission); -+ } - } - } - -- // If all the permissions are already granted, send the response to the WebView synchronously -- if (requestedAndroidPermissions.isEmpty()) { -- request.grant(grantedPermissions.toArray(new String[0])); -- grantedPermissions = null; -- return; -- } -- -- // Otherwise, ask to Android System for native permissions asynchronously -+ if (!requestedAndroidPermissions.isEmpty()) { -+ // Show the dialog and request the permissions -+ AlertDialog.Builder builder = new AlertDialog.Builder(mReactContext); -+ String permissionsIdentifiers = TextUtils.join(" and ", requestPermissionIdentifiers); -+ builder.setMessage("The app needs access to your " + permissionsIdentifiers + ". Allow?"); -+ builder.setCancelable(false); -+ builder.setPositiveButton("Allow", new DialogInterface.OnClickListener() { -+ public void onClick(DialogInterface dialog, int which) { -+ permissionRequest = request; -+ requestPermissions(requestedAndroidPermissions); -+ } -+ }); -+ builder.setNegativeButton("Don't allow", (DialogInterface.OnClickListener) (dialog, which) -> { -+ request.deny(); -+ }); - -- this.permissionRequest = request; -+ AlertDialog alertDialog = builder.create(); -+ alertDialog.show(); -+ // Delay making `allow` clickable for 500ms -+ Button posButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); -+ posButton.setEnabled(false); -+ this.runDelayed(() -> posButton.setEnabled(true), 500); -+ } else if (!grantedPermissions.isEmpty()) { -+ // You need to show to the user that the website is requesting permissions -+ // If that happens and the permissions are already granted you need to ask again -+ AlertDialog.Builder builder = new AlertDialog.Builder(mReactContext); -+ String permissionsIdentifiers = TextUtils.join(" and ", requestPermissionIdentifiers); -+ builder.setMessage("The app needs access to your " + permissionsIdentifiers + ". Allow?"); -+ builder.setCancelable(false); -+ builder.setPositiveButton("Allow", new DialogInterface.OnClickListener() { -+ public void onClick(DialogInterface dialog, int which) { -+ request.grant(grantedPermissions.toArray(new String[0])); -+ } -+ }); -+ builder.setNegativeButton("Don't allow", (DialogInterface.OnClickListener) (dialog, which) -> { -+ request.deny(); -+ }); - -- requestPermissions(requestedAndroidPermissions); -+ AlertDialog alertDialog = builder.create(); -+ alertDialog.show(); -+ // Delay making `allow` clickable for 500ms -+ Button posButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); -+ posButton.setEnabled(false); -+ this.runDelayed(() -> posButton.setEnabled(true), 500); -+ } - } - -+ private void runDelayed(Runnable function, long delayMillis) { -+ Handler handler = new Handler(); -+ handler.postDelayed(function, delayMillis); -+ } - - @Override - public void onGeolocationPermissionsShowPrompt(String origin, GeolocationPermissions.Callback callback) { -@@ -1266,7 +1620,22 @@ public class RNCWebViewManager extends SimpleViewManager { - requestPermissions(Collections.singletonList(Manifest.permission.ACCESS_FINE_LOCATION)); - - } else { -- callback.invoke(origin, true, false); -+ String alertMessage = String.format("Allow this app to use your location?"); -+ AlertDialog.Builder builder = new AlertDialog.Builder(this.mWebView.getContext()); -+ builder.setMessage(alertMessage); -+ builder.setCancelable(false); -+ builder.setPositiveButton("Allow", (dialog, which) -> { -+ callback.invoke(origin, true, false); -+ }); -+ builder.setNegativeButton("Don't allow", (dialog, which) -> { -+ callback.invoke(origin, false, false); -+ }); -+ AlertDialog alertDialog = builder.create(); -+ alertDialog.show(); -+ //Delay making `allow` clickable for 500ms to avoid unwanted presses. -+ Button posButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); -+ posButton.setEnabled(false); -+ this.runDelayed(() -> posButton.setEnabled(true), 500); - } - } - -@@ -1402,6 +1771,15 @@ public class RNCWebViewManager extends SimpleViewManager { - } - } - -+ @Override -+ public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { -+ if(blockJsDuringLoading) { -+ result.cancel(); -+ return true; -+ } else -+ return super.onJsPrompt(view, url, message, defaultValue, result); -+ } -+ - @Override - public void onHostPause() { } - -@@ -1447,6 +1825,13 @@ public class RNCWebViewManager extends SimpleViewManager { - protected boolean nestedScrollEnabled = false; - protected ProgressChangedFilter progressChangedFilter; - -+ /** -+ * Taken from EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING We can't use that -+ * value directly as it was only added on Oreo, but we can apply the value -+ * anyway. -+ */ -+ private static final int IME_FLAG_NO_PERSONALIZED_LEARNING = 0x1000000; -+ - /** - * WebView must be created with an context of the current activity - *

-@@ -1475,6 +1860,42 @@ public class RNCWebViewManager extends SimpleViewManager { - this.nestedScrollEnabled = nestedScrollEnabled; - } - -+ @Override -+ public InputConnection onCreateInputConnection(EditorInfo outAttrs) { -+ InputConnection inputConnection; -+ if (!usingGoogleKeyboard()) { -+ inputConnection = super.onCreateInputConnection(outAttrs); -+ } else { -+ inputConnection = new BaseInputConnection(this, false); -+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { -+ outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING; -+ } else { -+ // Cover OS versions below Oreo -+ outAttrs.imeOptions = IME_FLAG_NO_PERSONALIZED_LEARNING; -+ } -+ } -+ -+ return inputConnection; -+ } -+ -+ public boolean usingGoogleKeyboard() { -+ final InputMethodManager richImm = -+ (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); -+ -+ boolean isKeyboard = false; -+ -+ final Field field; -+ try { -+ field = richImm.getClass().getDeclaredField("mCurId"); -+ field.setAccessible(true); -+ Object value = field.get(richImm); -+ isKeyboard = Objects.equals(value, "com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME"); -+ } catch (IllegalAccessException | NoSuchFieldException e) { -+ return false; -+ } -+ return isKeyboard; -+ } -+ - @Override - public void onHostResume() { - // do nothing -@@ -1533,6 +1954,8 @@ public class RNCWebViewManager extends SimpleViewManager { - } - } - -+ -+ - public @Nullable - RNCWebViewClient getRNCWebViewClient() { - return mRNCWebViewClient; -@@ -1609,8 +2032,8 @@ public class RNCWebViewManager extends SimpleViewManager { - - public void callInjectedJavaScriptBeforeContentLoaded() { - if (getSettings().getJavaScriptEnabled() && -- injectedJSBeforeContentLoaded != null && -- !TextUtils.isEmpty(injectedJSBeforeContentLoaded)) { -+ injectedJSBeforeContentLoaded != null && -+ !TextUtils.isEmpty(injectedJSBeforeContentLoaded)) { - evaluateJavascriptWithFallback("(function() {\n" + injectedJSBeforeContentLoaded + ";\n})();"); - } - } -@@ -1672,16 +2095,16 @@ public class RNCWebViewManager extends SimpleViewManager { - - if (mOnScrollDispatchHelper.onScrollChanged(x, y)) { - ScrollEvent event = ScrollEvent.obtain( -- this.getId(), -- ScrollEventType.SCROLL, -- x, -- y, -- mOnScrollDispatchHelper.getXFlingVelocity(), -- mOnScrollDispatchHelper.getYFlingVelocity(), -- this.computeHorizontalScrollRange(), -- this.computeVerticalScrollRange(), -- this.getWidth(), -- this.getHeight()); -+ this.getId(), -+ ScrollEventType.SCROLL, -+ x, -+ y, -+ mOnScrollDispatchHelper.getXFlingVelocity(), -+ mOnScrollDispatchHelper.getYFlingVelocity(), -+ this.computeHorizontalScrollRange(), -+ this.computeVerticalScrollRange(), -+ this.getWidth(), -+ this.getHeight()); - - dispatchEvent(this, event); - } -diff --git a/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/Worker.java b/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/Worker.java -new file mode 100644 -index 0000000..b9581ac ---- /dev/null -+++ b/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/Worker.java -@@ -0,0 +1,21 @@ -+package com.reactnativecommunity.webview; -+ -+import android.os.Handler; -+import android.os.HandlerThread; -+ -+class Worker extends HandlerThread { -+ private Handler handler; -+ -+ private static final String TAG = "WORKER"; -+ -+ public Worker() { -+ super(TAG); -+ start(); -+ handler = new Handler(getLooper()); -+ } -+ -+ public Worker execute(Runnable task) { -+ handler.post(task); -+ return this; -+ } -+} -\ No newline at end of file -diff --git a/node_modules/react-native-webview/apple/RNCWebView.m b/node_modules/react-native-webview/apple/RNCWebView.m -index 28c078a..9bb5368 100644 ---- a/node_modules/react-native-webview/apple/RNCWebView.m -+++ b/node_modules/react-native-webview/apple/RNCWebView.m -@@ -105,6 +105,7 @@ static NSDictionary* customCertificatesForHost; - UIStatusBarStyle _savedStatusBarStyle; - #endif // !TARGET_OS_OSX - BOOL _savedStatusBarHidden; -+ BOOL _disablePromptDuringLoading; //Disables the display of prompts during site navigation/loading - - #if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 /* __IPHONE_11_0 */ - UIScrollViewContentInsetAdjustmentBehavior _savedContentInsetAdjustmentBehavior; -@@ -139,6 +140,7 @@ static NSDictionary* customCertificatesForHost; - _injectedJavaScriptForMainFrameOnly = YES; - _injectedJavaScriptBeforeContentLoaded = nil; - _injectedJavaScriptBeforeContentLoadedForMainFrameOnly = YES; -+ _disablePromptDuringLoading = YES; - - #if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000 /* __IPHONE_11_0 */ - _savedContentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; -@@ -417,6 +419,7 @@ static NSDictionary* customCertificatesForHost; - - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context{ - if ([keyPath isEqual:@"estimatedProgress"] && object == self.webView) { - if(_onLoadingProgress){ -+ _disablePromptDuringLoading = YES; - NSMutableDictionary *event = [self baseEvent]; - [event addEntriesFromDictionary:@{@"progress":[NSNumber numberWithDouble:self.webView.estimatedProgress]}]; - _onLoadingProgress(event); -@@ -492,6 +495,7 @@ static NSDictionary* customCertificatesForHost; - NSMutableDictionary *event = [self baseEvent]; - [event addEntriesFromDictionary: @{@"navigationType": message.body}]; - _onLoadingFinish(event); -+ _disablePromptDuringLoading = NO; - } - } else if ([message.name isEqualToString:MessageHandlerName]) { - if (_onMessage) { -@@ -851,11 +855,13 @@ static NSDictionary* customCertificatesForHost; - - (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler - { - #if !TARGET_OS_OSX -- UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"" message:message preferredStyle:UIAlertControllerStyleAlert]; -- [alert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { -- completionHandler(); -- }]]; -- [[self topViewController] presentViewController:alert animated:YES completion:NULL]; -+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.7 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ -+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"" message:message preferredStyle:UIAlertControllerStyleAlert]; -+ [alert addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { -+ completionHandler(); -+ }]]; -+ [[self topViewController] presentViewController:alert animated:YES completion:NULL]; -+ }); - #else - NSAlert *alert = [[NSAlert alloc] init]; - [alert setMessageText:message]; -@@ -868,6 +874,51 @@ static NSDictionary* customCertificatesForHost; - /** - * confirm - */ -+// This patch made to overridde the restrictions that webView is imposing to the native Alert, by restricting its size. -+- (void)webView:(WKWebView *)webView requestMediaCapturePermissionForOrigin:(WKSecurityOrigin *)origin initiatedByFrame:(WKFrameInfo *)frame type:(WKMediaCaptureType)type decisionHandler:(void (^)(WKPermissionDecision decision))decisionHandler API_AVAILABLE(ios(15.0)){ -+ -+ NSString *deviceType; -+ -+ switch (type) { -+ case WKMediaCaptureTypeCamera: -+ deviceType = @"camera"; -+ break; -+ case WKMediaCaptureTypeMicrophone: -+ deviceType = @"microphone"; -+ break; -+ case WKMediaCaptureTypeCameraAndMicrophone: -+ deviceType = @"camera and microphone"; -+ break; -+ default: -+ deviceType = @"unknown device"; -+ } -+ -+ NSString *message = [NSString stringWithFormat:@"The webpage %@ is requesting access to your %@. Do you want to allow this?", origin.host, deviceType]; -+ -+ UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Permission Request" -+ message:message -+ preferredStyle:UIAlertControllerStyleAlert]; -+ -+ UIAlertAction *allowAction = [UIAlertAction actionWithTitle:@"Allow" -+ style:UIAlertActionStyleDefault -+ handler:^(UIAlertAction * _Nonnull action) { -+ decisionHandler(WKPermissionDecisionGrant); -+ } -+ ]; -+ -+ UIAlertAction *denyAction = [UIAlertAction actionWithTitle:@"Deny" -+ style:UIAlertActionStyleCancel -+ handler:^(UIAlertAction * _Nonnull action) { -+ decisionHandler(WKPermissionDecisionDeny); -+ } -+ ]; -+ -+ [alertController addAction:allowAction]; -+ [alertController addAction:denyAction]; -+ -+ [[self topViewController] presentViewController:alertController animated:YES completion:NULL]; -+} -+ - - (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler{ - #if !TARGET_OS_OSX - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"" message:message preferredStyle:UIAlertControllerStyleAlert]; -@@ -894,44 +945,49 @@ static NSDictionary* customCertificatesForHost; - * prompt - */ - - (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString *))completionHandler{ --#if !TARGET_OS_OSX -- UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"" message:prompt preferredStyle:UIAlertControllerStyleAlert]; -- [alert addTextFieldWithConfigurationHandler:^(UITextField *textField) { -- textField.text = defaultText; -- }]; -- UIAlertAction *okAction = [UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { -- completionHandler([[alert.textFields lastObject] text]); -- }]; -- [alert addAction:okAction]; -- UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { -- completionHandler(nil); -- }]; -- [alert addAction:cancelAction]; -- alert.preferredAction = okAction; -- [[self topViewController] presentViewController:alert animated:YES completion:NULL]; --#else -- NSAlert *alert = [[NSAlert alloc] init]; -- [alert setMessageText:prompt]; -- -- const NSRect RCTSingleTextFieldFrame = NSMakeRect(0.0, 0.0, 275.0, 22.0); -- NSTextField *textField = [[NSTextField alloc] initWithFrame:RCTSingleTextFieldFrame]; -- textField.cell.scrollable = YES; -- if (@available(macOS 10.11, *)) { -- textField.maximumNumberOfLines = 1; -- } -- textField.stringValue = defaultText; -- [alert setAccessoryView:textField]; - -- [alert addButtonWithTitle:NSLocalizedString(@"OK", @"OK button")]; -- [alert addButtonWithTitle:NSLocalizedString(@"Cancel", @"Cancel button")]; -- [alert beginSheetModalForWindow:[NSApp keyWindow] completionHandler:^(NSModalResponse response) { -- if (response == NSAlertFirstButtonReturn) { -- completionHandler([textField stringValue]); -+ if(!_disablePromptDuringLoading) { -+ #if !TARGET_OS_OSX -+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"" message:prompt preferredStyle:UIAlertControllerStyleAlert]; -+ [alert addTextFieldWithConfigurationHandler:^(UITextField *textField) { -+ textField.text = defaultText; -+ }]; -+ UIAlertAction *okAction = [UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { -+ completionHandler([[alert.textFields lastObject] text]); -+ }]; -+ [alert addAction:okAction]; -+ UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { -+ completionHandler(nil); -+ }]; -+ [alert addAction:cancelAction]; -+ alert.preferredAction = okAction; -+ [[self topViewController] presentViewController:alert animated:YES completion:NULL]; -+ #else -+ NSAlert *alert = [[NSAlert alloc] init]; -+ [alert setMessageText:prompt]; -+ -+ const NSRect RCTSingleTextFieldFrame = NSMakeRect(0.0, 0.0, 275.0, 22.0); -+ NSTextField *textField = [[NSTextField alloc] initWithFrame:RCTSingleTextFieldFrame]; -+ textField.cell.scrollable = YES; -+ if (@available(macOS 10.11, *)) { -+ textField.maximumNumberOfLines = 1; -+ } -+ textField.stringValue = defaultText; -+ [alert setAccessoryView:textField]; -+ -+ [alert addButtonWithTitle:NSLocalizedString(@"OK", @"OK button")]; -+ [alert addButtonWithTitle:NSLocalizedString(@"Cancel", @"Cancel button")]; -+ [alert beginSheetModalForWindow:[NSApp keyWindow] completionHandler:^(NSModalResponse response) { -+ if (response == NSAlertFirstButtonReturn) { -+ completionHandler([textField stringValue]); -+ } else { -+ completionHandler(nil); -+ } -+ }]; -+ #endif // !TARGET_OS_OSX - } else { -- completionHandler(nil); -+ completionHandler(nil); - } -- }]; --#endif // !TARGET_OS_OSX - } - - #if !TARGET_OS_OSX -@@ -1157,6 +1213,7 @@ static NSDictionary* customCertificatesForHost; - } - - if (_onLoadingFinish) { -+ _disablePromptDuringLoading = NO; - _onLoadingFinish([self baseEvent]); - } - } -@@ -1446,3 +1503,4 @@ static NSDictionary* customCertificatesForHost; - } - - @end -+ diff --git a/scripts/build-inpage-bridge.sh b/scripts/build-inpage-bridge.sh index 7823e49dd80..52753a1589a 100755 --- a/scripts/build-inpage-bridge.sh +++ b/scripts/build-inpage-bridge.sh @@ -3,11 +3,7 @@ set -euo pipefail rm -f app/core/InpageBridgeWeb3.js mkdir -p scripts/inpage-bridge/dist && rm -rf scripts/inpage-bridge/dist/* -cd scripts/inpage-bridge/inpage -../../../node_modules/.bin/webpack --config webpack.config.js -cd .. -node content-script/build.js -cat dist/inpage-bundle.js content-script/index.js > dist/index-raw.js +cd scripts/inpage-bridge/ ../../node_modules/.bin/webpack --config webpack.config.js cd ../.. cp scripts/inpage-bridge/dist/index.js app/core/InpageBridgeWeb3.js diff --git a/scripts/inpage-bridge/content-script/build.js b/scripts/inpage-bridge/content-script/build.js deleted file mode 100644 index cd0ca95605c..00000000000 --- a/scripts/inpage-bridge/content-script/build.js +++ /dev/null @@ -1,14 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const distPath = path.join(__dirname, '..', '/', 'dist'); - -const inpageContent = fs - .readFileSync(path.join(distPath, 'inpage-content.js')) - .toString(); - -// wrap the inpage content in a variable declaration -const code = `const inpageBundle = ${JSON.stringify(inpageContent)}`; - -fs.writeFileSync(path.join(distPath, 'inpage-bundle.js'), code, 'ascii'); -console.log('content-script.js generated succesfully'); diff --git a/scripts/inpage-bridge/inpage/webpack.config.js b/scripts/inpage-bridge/inpage/webpack.config.js deleted file mode 100644 index f1d28be96f1..00000000000 --- a/scripts/inpage-bridge/inpage/webpack.config.js +++ /dev/null @@ -1,63 +0,0 @@ -const webpack = require('webpack'); -const path = require('path'); -const { readFileSync } = require('fs'); - -const SVG_LOGO_PATH = - '../../../app/images/fox.svg'; -function getBuildIcon() { - const svg = readFileSync(SVG_LOGO_PATH, 'utf8'); - return `data:image/svg+xml,${encodeURIComponent(svg)}`; -} - -const config = { - entry: './index.js', - - output: { - path: path.resolve(__dirname, '..', 'dist'), - filename: 'inpage-content.js', - }, - - mode: 'production', - module: { - rules: [ - { - test: /\.(js|jsx|mjs)$/u, - use: { - loader: 'babel-loader', - options: { - presets: ['@babel/preset-env'], - }, - }, - }, - ], - }, - resolve: { - fallback: { - buffer: require.resolve('buffer'), - stream: require.resolve('stream-browserify'), - _stream_transform: require.resolve('readable-stream/transform'), - _stream_readable: require.resolve('readable-stream/readable'), - _stream_writable: require.resolve('readable-stream/writable'), - _stream_duplex: require.resolve('readable-stream/duplex'), - _stream_passthrough: require.resolve('readable-stream/passthrough'), - }, - }, - plugins: [ - new webpack.ProvidePlugin({ - Buffer: ['buffer', 'Buffer'], - process: 'process/browser', - }), - new webpack.DefinePlugin({ - 'process.env.METAMASK_BUILD_NAME': JSON.stringify('MetaMask'), - 'process.env.METAMASK_BUILD_ICON': JSON.stringify(getBuildIcon()), - 'process.env.METAMASK_BUILD_APP_ID': JSON.stringify('io.metamask.mobile'), - }), - ], -}; - -module.exports = (_env, argv) => { - if (argv.mode === 'development') { - config.mode = 'development'; - } - return config; -}; diff --git a/scripts/inpage-bridge/inpage/MobilePortStream.js b/scripts/inpage-bridge/src/MobilePortStream.js similarity index 100% rename from scripts/inpage-bridge/inpage/MobilePortStream.js rename to scripts/inpage-bridge/src/MobilePortStream.js diff --git a/scripts/inpage-bridge/inpage/ReactNativePostMessageStream.js b/scripts/inpage-bridge/src/ReactNativePostMessageStream.js similarity index 100% rename from scripts/inpage-bridge/inpage/ReactNativePostMessageStream.js rename to scripts/inpage-bridge/src/ReactNativePostMessageStream.js diff --git a/scripts/inpage-bridge/content-script/index.js b/scripts/inpage-bridge/src/index.js similarity index 73% rename from scripts/inpage-bridge/content-script/index.js rename to scripts/inpage-bridge/src/index.js index 16052682c1f..2a4787822fc 100644 --- a/scripts/inpage-bridge/content-script/index.js +++ b/scripts/inpage-bridge/src/index.js @@ -1,7 +1,8 @@ -/* global inpageBundle */ + +import injectInpageProvider from './provider'; if (shouldInject()) { - injectScript(inpageBundle); + injectInpageProvider(); start(); } @@ -16,28 +17,6 @@ async function start() { window._metamaskSetupProvider(); } -/** - * Injects a script tag into the current document - * - * @param {string} content - Code to be executed in the current document - */ -function injectScript(content) { - try { - const container = document.head || document.documentElement; - - // synchronously execute script in page context - const scriptTag = document.createElement('script'); - scriptTag.setAttribute('async', false); - scriptTag.textContent = content; - container.insertBefore(scriptTag, container.children[0]); - - // script executed; remove script element from DOM - container.removeChild(scriptTag); - } catch (err) { - console.error('MetaMask script injection failed', err); - } -} - /** * Determines if the provider should be injected. * @@ -47,7 +26,6 @@ function shouldInject() { return ( doctypeCheck() && suffixCheck() && - documentElementCheck() && !blockedDomainCheck() ); } @@ -86,19 +64,6 @@ function suffixCheck() { return true; } -/** - * Checks the documentElement of the current document - * - * @returns {boolean} {@code true} if the documentElement is an html node or if none exists - */ -function documentElementCheck() { - const documentElement = document.documentElement.nodeName; - if (documentElement) { - return documentElement.toLowerCase() === 'html'; - } - return true; -} - /** * Checks if the current domain is blocked * diff --git a/scripts/inpage-bridge/inpage/index.js b/scripts/inpage-bridge/src/provider.js similarity index 83% rename from scripts/inpage-bridge/inpage/index.js rename to scripts/inpage-bridge/src/provider.js index b76b2ae006a..f3cdca54008 100644 --- a/scripts/inpage-bridge/inpage/index.js +++ b/scripts/inpage-bridge/src/provider.js @@ -15,28 +15,31 @@ const metamaskStream = new ReactNativePostMessageStream({ target: CONTENT_SCRIPT, }); -// Initialize provider object (window.ethereum) -initializeProvider({ - connectionStream: metamaskStream, - shouldSendMetadata: false, - providerInfo: { - uuid: uuid(), - name: process.env.METAMASK_BUILD_NAME, - icon: process.env.METAMASK_BUILD_ICON, - rdns: process.env.METAMASK_BUILD_APP_ID, - }, -}); +const init = () => { + // Initialize provider object (window.ethereum) + initializeProvider({ + connectionStream: metamaskStream, + shouldSendMetadata: false, + providerInfo: { + uuid: uuid(), + name: process.env.METAMASK_BUILD_NAME, + icon: process.env.METAMASK_BUILD_ICON, + rdns: process.env.METAMASK_BUILD_APP_ID, + }, + }); -// Set content script post-setup function -Object.defineProperty(window, '_metamaskSetupProvider', { - value: () => { - setupProviderStreams(); - delete window._metamaskSetupProvider; - }, - configurable: true, - enumerable: false, - writable: false, -}); + // Set content script post-setup function + Object.defineProperty(window, '_metamaskSetupProvider', { + value: () => { + setupProviderStreams(); + delete window._metamaskSetupProvider; + }, + configurable: true, + enumerable: false, + writable: false, + }); + +} // Functions @@ -130,3 +133,5 @@ function notifyProviderOfStreamFailure() { window.location.origin, ); } + +export default init; diff --git a/scripts/inpage-bridge/webpack.config.js b/scripts/inpage-bridge/webpack.config.js index 8c0dff54856..b8fa14e20d1 100644 --- a/scripts/inpage-bridge/webpack.config.js +++ b/scripts/inpage-bridge/webpack.config.js @@ -1,7 +1,16 @@ +const webpack = require('webpack'); const path = require('path'); +const { readFileSync } = require('fs'); + +const SVG_LOGO_PATH = + '../../app/images/fox.svg'; +function getBuildIcon() { + const svg = readFileSync(SVG_LOGO_PATH, 'utf8'); + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} const config = { - entry: './dist/index-raw.js', + entry: './src/index.js', output: { path: path.resolve(__dirname, 'dist'), @@ -9,6 +18,41 @@ const config = { }, mode: 'production', + module: { + rules: [ + { + test: /\.(js|jsx|mjs)$/u, + use: { + loader: 'babel-loader', + options: { + presets: ['@babel/preset-env'], + }, + }, + }, + ], + }, + resolve: { + fallback: { + buffer: require.resolve('buffer'), + stream: require.resolve('stream-browserify'), + _stream_transform: require.resolve('readable-stream/transform'), + _stream_readable: require.resolve('readable-stream/readable'), + _stream_writable: require.resolve('readable-stream/writable'), + _stream_duplex: require.resolve('readable-stream/duplex'), + _stream_passthrough: require.resolve('readable-stream/passthrough'), + }, + }, + plugins: [ + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + process: 'process/browser', + }), + new webpack.DefinePlugin({ + 'process.env.METAMASK_BUILD_NAME': JSON.stringify('MetaMask'), + 'process.env.METAMASK_BUILD_ICON': JSON.stringify(getBuildIcon()), + 'process.env.METAMASK_BUILD_APP_ID': JSON.stringify('io.metamask.mobile'), + }), + ], }; module.exports = (_env, argv) => { diff --git a/yarn.lock b/yarn.lock index 3e03fa82aef..30a497d0534 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4569,13 +4569,6 @@ resolved "https://registry.yarnpkg.com/@metamask/react-native-actionsheet/-/react-native-actionsheet-2.4.2.tgz#9f956fe9e784d92c8e33656877fcfaabe4a482f1" integrity sha512-oibRXUzF+7DB0Nyzp2cMGN7ztB9Sl21W1NFq1IMa00mB4/X43JY+u+LCkx625WvQUeq0GO2ZQ6hG1L5XjMumSA== -"@metamask/react-native-animated-fox@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@metamask/react-native-animated-fox/-/react-native-animated-fox-2.1.0.tgz#504e1f68e13ad273fb193c6f6a3832f3a5242518" - integrity sha512-Hc+DyaEIXYa7NjzqXfgh01bsoP9WbE/ENNKZ4A65YwSBmJk5ZDvhMgTMFz+qybkUllx4kn4ENkmr0SXERZ2wmg== - dependencies: - prop-types "^15.5.10" - "@metamask/react-native-button@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@metamask/react-native-button/-/react-native-button-3.0.0.tgz#4af8affd11e2b285cfc1b1752280797e1b33e62b" @@ -4602,6 +4595,14 @@ resolved "https://registry.yarnpkg.com/@metamask/react-native-splash-screen/-/react-native-splash-screen-3.2.0.tgz#06a6547c143b088e47af40eacea9ac6657ac937f" integrity sha512-V8Cn0MXe9jdaUli/DK3PoJ71tx7k3IW2v2slqflvNstvHiO3MpCtdylsYIyu+tiPwI2JiyLRzLK8s02/3jxk6g== +"@metamask/react-native-webview@^14.0.1": + version "14.0.1" + resolved "https://registry.yarnpkg.com/@metamask/react-native-webview/-/react-native-webview-14.0.1.tgz#db1a1f4abea077b17fcc163538db4fcd7b2890bd" + integrity sha512-euctbS170XwxCCOIP36pr3N2bW8hQdZIKzpoEpWFK+5aumQ7UrTmwswcD3OCqnlk5JvpsRvlixIje8AA+6Gmtg== + dependencies: + escape-string-regexp "2.0.0" + invariant "2.2.4" + "@metamask/rpc-errors@^6.0.0", "@metamask/rpc-errors@^6.2.1": version "6.2.1" resolved "https://registry.yarnpkg.com/@metamask/rpc-errors/-/rpc-errors-6.2.1.tgz#f5daf429ededa7cb83069dc621bd5738fe2a1d80" @@ -24850,14 +24851,6 @@ react-native-webview-invoke@^0.6.2: resolved "https://registry.yarnpkg.com/react-native-webview-invoke/-/react-native-webview-invoke-0.6.2.tgz#75cc27ef98ea1cbc9386269347d3aafe90d33aa3" integrity sha512-PCzP7Zl3XwHU10JYS8nR0gwuR8XiOO0MhC8y9ZuPPI+HeISn95GvNYhOXxeLgfbdbUcpNWh1HqxPDySlfCIqxg== -react-native-webview@11.13.0: - version "11.13.0" - resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-11.13.0.tgz#a2eca0f87b2ae9bba0dd8144594aeff9947cc5d6" - integrity sha512-jjQAKWv8JzRmcn76fMe4lXD84AAeR7kn43kAmUe1GX312BMLaP+RbKlgpYAlNuOBXL0YirItGKDrpaD0bNROOA== - dependencies: - escape-string-regexp "2.0.0" - invariant "2.2.4" - react-native@0.71.15: version "0.71.15" resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.71.15.tgz#7d99f478238c559b8b3fdaad2514f11d53ef135a" From 1f9af513bcd4a1124f83df5ec766ef73d604c581 Mon Sep 17 00:00:00 2001 From: Omri Dan <61094771+omridan159@users.noreply.github.com> Date: Tue, 2 Jul 2024 17:29:52 +0400 Subject: [PATCH 10/59] chore: reorder accounts in ETH_REQUESTACCOUNTS response to prioritize selectedAddress in the 'AndroidService' (#10213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ### Summary This PR implements logic to reorder the accounts in the ETH_REQUESTACCOUNTS response, ensuring that the selected address appears first in the list. This update is part of the `sendMessage` method in the `AndroidService` class. ### Changes - Retrieve the selected address from the `PreferencesController`. - Convert all account addresses in the response to lowercase. - Check if the selected address is part of the connected addresses. - Reorder the accounts list, placing the selected address at the beginning if it exists in the list. - Update the message data with the reordered accounts list. ### Why Reordering the accounts list enhances user experience by making the selected address more prominent and easily accessible. This change ensures that the selected address is always the first in the list returned by the `ETH_REQUESTACCOUNTS` method. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../SDKConnect/AndroidSDK/AndroidService.ts | 90 ++---------- .../AndroidService/sendMessage.test.ts | 131 ++++++++++++++++++ .../AndroidSDK/AndroidService/sendMessage.ts | 128 +++++++++++++++++ 3 files changed, 270 insertions(+), 79 deletions(-) create mode 100644 app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.test.ts create mode 100644 app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.ts diff --git a/app/core/SDKConnect/AndroidSDK/AndroidService.ts b/app/core/SDKConnect/AndroidSDK/AndroidService.ts index c53a1ab7428..8ebaa583977 100644 --- a/app/core/SDKConnect/AndroidSDK/AndroidService.ts +++ b/app/core/SDKConnect/AndroidSDK/AndroidService.ts @@ -28,27 +28,23 @@ import { PermissionController } from '@metamask/permission-controller'; import { PreferencesController } from '@metamask/preferences-controller'; import { PROTOCOLS } from '../../../constants/deeplinks'; import BatchRPCManager from '../BatchRPCManager'; -import { - DEFAULT_SESSION_TIMEOUT_MS, - METHODS_TO_DELAY, - RPC_METHODS, -} from '../SDKConnectConstants'; -import getDefaultBridgeParams from './getDefaultBridgeParams'; -import handleBatchRpcResponse from '../handlers/handleBatchRpcResponse'; +import { DEFAULT_SESSION_TIMEOUT_MS } from '../SDKConnectConstants'; import handleCustomRpcCalls from '../handlers/handleCustomRpcCalls'; import DevLogger from '../utils/DevLogger'; import AndroidSDKEventHandler from './AndroidNativeSDKEventHandler'; +import sendMessage from './AndroidService/sendMessage'; import { DappClient, DappConnections } from './dapp-sdk-types'; +import getDefaultBridgeParams from './getDefaultBridgeParams'; export default class AndroidService extends EventEmitter2 { - private communicationClient = NativeModules.CommunicationClient; - private connections: DappConnections = {}; - private rpcQueueManager = new RPCQueueManager(); - private bridgeByClientId: { [clientId: string]: BackgroundBridge } = {}; - private eventHandler: AndroidSDKEventHandler; - private batchRPCManager: BatchRPCManager = new BatchRPCManager('android'); + public communicationClient = NativeModules.CommunicationClient; + public connections: DappConnections = {}; + public rpcQueueManager = new RPCQueueManager(); + public bridgeByClientId: { [clientId: string]: BackgroundBridge } = {}; + public eventHandler: AndroidSDKEventHandler; + public batchRPCManager: BatchRPCManager = new BatchRPCManager('android'); // To keep track in order to get the associated bridge to handle batch rpc calls - private currentClientId?: string; + public currentClientId?: string; constructor() { super(); @@ -475,70 +471,6 @@ export default class AndroidService extends EventEmitter2 { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any async sendMessage(message: any, forceRedirect?: boolean) { - const id = message?.data?.id; - this.communicationClient.sendMessage(JSON.stringify(message)); - let rpcMethod = this.rpcQueueManager.getId(id); - - DevLogger.log(`AndroidService::sendMessage method=${rpcMethod}`, message); - // handle multichain rpc call responses separately - const chainRPCs = this.batchRPCManager.getById(id); - if (chainRPCs) { - const isLastRpcOrError = await handleBatchRpcResponse({ - chainRpcs: chainRPCs, - msg: message, - backgroundBridge: this.bridgeByClientId[this.currentClientId ?? ''], - batchRPCManager: this.batchRPCManager, - sendMessage: ({ msg }) => this.sendMessage(msg), - }); - DevLogger.log( - `AndroidService::sendMessage isLastRpc=${isLastRpcOrError}`, - chainRPCs, - ); - - if (!isLastRpcOrError) { - DevLogger.log( - `AndroidService::sendMessage NOT last rpc --- skip goBack()`, - chainRPCs, - ); - this.rpcQueueManager.remove(id); - // Only continue processing the message and goback if all rpcs in the batch have been handled - return; - } - - // Always set the method to metamask_batch otherwise it may not have been set correctly because of the batch rpc flow. - rpcMethod = RPC_METHODS.METAMASK_BATCH; - DevLogger.log( - `AndroidService::sendMessage chainRPCs=${chainRPCs} COMPLETED!`, - ); - } - - this.rpcQueueManager.remove(id); - - if (!rpcMethod && forceRedirect !== true) { - DevLogger.log( - `AndroidService::sendMessage no rpc method --- rpcMethod=${rpcMethod} forceRedirect=${forceRedirect} --- skip goBack()`, - ); - return; - } - - try { - if (METHODS_TO_DELAY[rpcMethod]) { - // Add delay to see the feedback modal - await wait(1000); - } - - if (!this.rpcQueueManager.isEmpty()) { - DevLogger.log( - `AndroidService::sendMessage NOT empty --- skip goBack()`, - this.rpcQueueManager.get(), - ); - return; - } - - DevLogger.log(`AndroidService::sendMessage empty --- goBack()`); - Minimizer.goBack(); - } catch (error) { - Logger.log(error, `AndroidService:: error waiting for empty rpc queue`); - } + return sendMessage(this, message, forceRedirect); } } diff --git a/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.test.ts b/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.test.ts new file mode 100644 index 00000000000..647b57884eb --- /dev/null +++ b/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.test.ts @@ -0,0 +1,131 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import Logger from '../../../../util/Logger'; +import Engine from '../../../Engine'; +import { Minimizer } from '../../../NativeModules'; +import { RPC_METHODS } from '../../SDKConnectConstants'; +import handleBatchRpcResponse from '../../handlers/handleBatchRpcResponse'; +import { wait } from '../../utils/wait.util'; +import AndroidService from '../AndroidService'; +import sendMessage from './sendMessage'; + +jest.mock('../../../Engine'); +jest.mock('../../../NativeModules', () => ({ + Minimizer: { + goBack: jest.fn(), + }, +})); +jest.mock('../../../../util/Logger'); +jest.mock('../../utils/wait.util', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('@metamask/preferences-controller'); +jest.mock('../AndroidService'); +jest.mock('../../handlers/handleBatchRpcResponse', () => jest.fn()); +jest.mock('../../utils/DevLogger'); + +describe('sendMessage', () => { + let instance: jest.Mocked; + let message: any; + + const mockGetId = jest.fn(); + const mockRemove = jest.fn(); + const mockIsEmpty = jest.fn().mockReturnValue(true); + const mockGet = jest.fn(); + const mockSendMessage = jest.fn(); + const mockGetById = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + instance = { + rpcQueueManager: { + getId: mockGetId, + remove: mockRemove, + isEmpty: mockIsEmpty, + get: mockGet, + }, + communicationClient: { + sendMessage: mockSendMessage, + }, + batchRPCManager: { + getById: mockGetById, + }, + bridgeByClientId: {}, + currentClientId: 'test-client-id', + } as unknown as jest.Mocked; + + message = { + data: { + id: 'test-id', + result: ['0x1', '0x2'], + }, + }; + + (Engine.context as any) = { + PreferencesController: { + state: { + selectedAddress: '0x1', + }, + }, + }; + }); + + it('should send message with reordered accounts if selectedAddress is in result', async () => { + mockGetId.mockReturnValue(RPC_METHODS.ETH_REQUESTACCOUNTS); + + await sendMessage(instance, message); + + expect(mockSendMessage).toHaveBeenCalledWith( + JSON.stringify({ + ...message, + data: { + ...message.data, + result: ['0x1', '0x2'], + }, + }), + ); + }); + + it('should send message without reordering if selectedAddress is not in result', async () => { + (Engine.context as any).PreferencesController.state.selectedAddress = '0x3'; + + mockGetId.mockReturnValue(RPC_METHODS.ETH_REQUESTACCOUNTS); + + await sendMessage(instance, message); + + expect(mockSendMessage).toHaveBeenCalledWith(JSON.stringify(message)); + }); + + it('should handle multichain rpc call responses separately', async () => { + mockGetId.mockReturnValue('someMethod'); + mockGetById.mockReturnValue(['rpc1', 'rpc2']); + (handleBatchRpcResponse as jest.Mock).mockResolvedValue(true); + + await sendMessage(instance, message); + + expect(handleBatchRpcResponse).toHaveBeenCalled(); + expect(mockRemove).toHaveBeenCalledWith('test-id'); + expect(mockSendMessage).toHaveBeenCalledWith(JSON.stringify(message)); + }); + + it('should not call goBack if rpcQueueManager is not empty', async () => { + mockGetId.mockReturnValue('someMethod'); + mockIsEmpty.mockReturnValue(false); + + await sendMessage(instance, message); + + expect(Minimizer.goBack).not.toHaveBeenCalled(); + }); + + it('should handle error when waiting for empty rpc queue', async () => { + mockGetId.mockReturnValue('someMethod'); + (wait as jest.Mock).mockRejectedValue(new Error('test error')); + + await sendMessage(instance, message); + + expect(Logger.log).toHaveBeenCalledWith( + expect.any(Error), + `AndroidService:: error waiting for empty rpc queue`, + ); + }); +}); diff --git a/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.ts b/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.ts new file mode 100644 index 00000000000..b795c789d0b --- /dev/null +++ b/app/core/SDKConnect/AndroidSDK/AndroidService/sendMessage.ts @@ -0,0 +1,128 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import Engine from '../../../Engine'; +import { Minimizer } from '../../../NativeModules'; +import Logger from '../../../../util/Logger'; +import { wait } from '../../utils/wait.util'; +import { PreferencesController } from '@metamask/preferences-controller'; +import AndroidService from '../AndroidService'; +import { METHODS_TO_DELAY, RPC_METHODS } from '../../SDKConnectConstants'; +import handleBatchRpcResponse from '../../handlers/handleBatchRpcResponse'; +import DevLogger from '../../utils/DevLogger'; + +async function sendMessage( + instance: AndroidService, + message: any, + forceRedirect?: boolean, +) { + const id = message?.data?.id; + let rpcMethod = instance.rpcQueueManager.getId(id); + + const isConnectionResponse = rpcMethod === RPC_METHODS.ETH_REQUESTACCOUNTS; + + if (isConnectionResponse) { + const preferencesController = ( + Engine.context as { + PreferencesController: PreferencesController; + } + ).PreferencesController; + + const selectedAddress = + preferencesController.state.selectedAddress.toLowerCase(); + + const lowercaseAccounts = (message.data.result as string[]).map( + (a: string) => a.toLowerCase(), + ); + + const isPartOfConnectedAddresses = + lowercaseAccounts.includes(selectedAddress); + + if (isPartOfConnectedAddresses) { + // Remove the selectedAddress from the lowercaseAccounts if it exists + const remainingAccounts = lowercaseAccounts.filter( + (account) => account !== selectedAddress, + ); + + // Create the reorderedAccounts array with selectedAddress as the first element + const reorderedAccounts: string[] = [ + selectedAddress, + ...remainingAccounts, + ]; + + message = { + ...message, + data: { + ...message.data, + result: reorderedAccounts, + }, + }; + } + } + + instance.communicationClient.sendMessage(JSON.stringify(message)); + + DevLogger.log(`AndroidService::sendMessage method=${rpcMethod}`, message); + + // handle multichain rpc call responses separately + const chainRPCs = instance.batchRPCManager.getById(id); + if (chainRPCs) { + const isLastRpcOrError = await handleBatchRpcResponse({ + chainRpcs: chainRPCs, + msg: message, + backgroundBridge: + instance.bridgeByClientId[instance.currentClientId ?? ''], + batchRPCManager: instance.batchRPCManager, + sendMessage: ({ msg }) => instance.sendMessage(msg), + }); + DevLogger.log( + `AndroidService::sendMessage isLastRpc=${isLastRpcOrError}`, + chainRPCs, + ); + + if (!isLastRpcOrError) { + DevLogger.log( + `AndroidService::sendMessage NOT last rpc --- skip goBack()`, + chainRPCs, + ); + instance.rpcQueueManager.remove(id); + // Only continue processing the message and goback if all rpcs in the batch have been handled + return; + } + + // Always set the method to metamask_batch otherwise it may not have been set correctly because of the batch rpc flow. + rpcMethod = RPC_METHODS.METAMASK_BATCH; + DevLogger.log( + `AndroidService::sendMessage chainRPCs=${chainRPCs} COMPLETED!`, + ); + } + + instance.rpcQueueManager.remove(id); + + if (!rpcMethod && forceRedirect !== true) { + DevLogger.log( + `AndroidService::sendMessage no rpc method --- rpcMethod=${rpcMethod} forceRedirect=${forceRedirect} --- skip goBack()`, + ); + return; + } + + try { + if (METHODS_TO_DELAY[rpcMethod]) { + // Add delay to see the feedback modal + await wait(1000); + } + + if (!instance.rpcQueueManager.isEmpty()) { + DevLogger.log( + `AndroidService::sendMessage NOT empty --- skip goBack()`, + instance.rpcQueueManager.get(), + ); + return; + } + + DevLogger.log(`AndroidService::sendMessage empty --- goBack()`); + Minimizer.goBack(); + } catch (error) { + Logger.log(error, `AndroidService:: error waiting for empty rpc queue`); + } +} + +export default sendMessage; From 53fc082117a23a25ad14020fc0267fe698b31d4d Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Tue, 2 Jul 2024 18:54:37 +0200 Subject: [PATCH 11/59] fix: add tokenList iconUrl to `IdentIcon` component (#10163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR aims to add support for `IdentIcon` component to use iconUrl for token entity from `tokenList` ## **Related issues** Fixes: ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ![Screenshot 2024-06-28 at 11 56 58](https://github.com/MetaMask/metamask-mobile/assets/7644512/c7786923-1eff-4530-a534-0a09556a68b9) ### **After** ![Screenshot 2024-06-28 at 11 41 13](https://github.com/MetaMask/metamask-mobile/assets/7644512/760dbd7e-e5ff-4a9c-b2a9-7444ebf9ee6e) ## **Pre-merge author checklist** - [X] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../UI/AddressInputs/index.test.jsx | 5 +- .../__snapshots__/index.test.tsx.snap | 20 ++++++ app/components/UI/Identicon/index.test.tsx | 29 +++++++++ app/components/UI/Identicon/index.tsx | 36 +++++++---- app/components/UI/Name/Name.test.tsx | 5 ++ .../UI/Name/__snapshots__/Name.test.tsx.snap | 61 +------------------ .../Views/confirmations/Send/index.test.tsx | 3 + .../hooks/DisplayName/useTokenList.test.ts | 30 ++++----- .../hooks/DisplayName/useTokenList.ts | 31 +++------- .../DisplayName/useTokenListEntry.test.ts | 9 +-- .../hooks/DisplayName/useTokenListEntry.ts | 7 ++- app/selectors/tokenListController.ts | 3 +- 12 files changed, 125 insertions(+), 114 deletions(-) diff --git a/app/components/UI/AddressInputs/index.test.jsx b/app/components/UI/AddressInputs/index.test.jsx index a32e4577c0d..cc3a6d2bfc5 100644 --- a/app/components/UI/AddressInputs/index.test.jsx +++ b/app/components/UI/AddressInputs/index.test.jsx @@ -23,6 +23,7 @@ const initialState = { name: 'Account 2', }, }, + useTokenDetection: false, }, AddressBookController: { addressBook: { @@ -61,7 +62,7 @@ describe('AddressInputs', () => { fromAccountBalance="0x5" fromAccountName="DUMMY_ACCOUNT" />, - {}, + { state: initialState }, ); expect(container).toMatchSnapshot(); }); @@ -74,7 +75,7 @@ describe('AddressInputs', () => { fromAccountName="DUMMY_ACCOUNT" layout="vertical" />, - {}, + { state: initialState }, ); expect(container).toMatchSnapshot(); }); diff --git a/app/components/UI/Identicon/__snapshots__/index.test.tsx.snap b/app/components/UI/Identicon/__snapshots__/index.test.tsx.snap index 18c2cca7567..9707813bf99 100644 --- a/app/components/UI/Identicon/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/Identicon/__snapshots__/index.test.tsx.snap @@ -1,5 +1,25 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Identicon should render correctly when provided address found in tokenList and iconUrl is available 1`] = ` + +`; + exports[`Identicon should render correctly when useBlockieIcon is false 1`] = ` { const mockStore = configureMockStore(); + const mockUseTokenList = jest + .mocked(useTokenList) + .mockImplementation(() => ({})); + + it('should render correctly when provided address found in tokenList and iconUrl is available', () => { + const addressMock = '0x0439e60f02a8900a951603950d8d4527f400c3f1'; + mockUseTokenList.mockImplementation(() => [ + { + address: addressMock, + iconUrl: 'https://example.com/icon.png', + }, + ]); + + const initialState = { + settings: { useBlockieIcon: true }, + }; + const store = mockStore(initialState); + + const wrapper = render( + + + , + ); + expect(wrapper).toMatchSnapshot(); + }); it('should render correctly when useBlockieIcon is true', () => { const initialState = { settings: { useBlockieIcon: true }, diff --git a/app/components/UI/Identicon/index.tsx b/app/components/UI/Identicon/index.tsx index 945419c69b5..aaac02b3ef4 100644 --- a/app/components/UI/Identicon/index.tsx +++ b/app/components/UI/Identicon/index.tsx @@ -6,6 +6,8 @@ import FadeIn from 'react-native-fade-in-image'; import Jazzicon from 'react-native-jazzicon'; import { connect } from 'react-redux'; import { useTheme } from '../../../util/theme'; +import { useTokenListEntry } from '../../../components/hooks/DisplayName/useTokenListEntry'; +import { NameType } from '../../UI/Name/Name.types'; interface IdenticonProps { /** @@ -43,23 +45,35 @@ const Identicon: React.FC = ({ useBlockieIcon = true, }) => { const { colors } = useTheme(); + const tokenListIcon = useTokenListEntry( + address as string, + NameType.EthereumAddress, + )?.iconUrl; if (!address) return null; const uri = useBlockieIcon && toDataUrl(address); + const styleForBlockieAndTokenIcon = [ + { + height: diameter, + width: diameter, + borderRadius: diameter / 2, + }, + customStyle, + ]; + + if (tokenListIcon) { + return ( + + ); + } + const image = useBlockieIcon ? ( - + ) : ( diff --git a/app/components/UI/Name/Name.test.tsx b/app/components/UI/Name/Name.test.tsx index b927c680ba8..a233e535f09 100644 --- a/app/components/UI/Name/Name.test.tsx +++ b/app/components/UI/Name/Name.test.tsx @@ -14,6 +14,11 @@ jest.mock('../../hooks/DisplayName/useDisplayName', () => ({ default: jest.fn(), })); +jest.mock('../Identicon', () => ({ + __esModule: true, + default: () => 'Identicon', +})); + const UNKNOWN_ADDRESS_CHECKSUMMED = '0x299007B3F9E23B8d432D5f545F8a4a2B3E9A5B4e'; const EXPECTED_UNKNOWN_ADDRESS_CHECKSUMMED = '0x29900...A5B4e'; diff --git a/app/components/UI/Name/__snapshots__/Name.test.tsx.snap b/app/components/UI/Name/__snapshots__/Name.test.tsx.snap index 75d9eb71ec6..2d61fb1f825 100644 --- a/app/components/UI/Name/__snapshots__/Name.test.tsx.snap +++ b/app/components/UI/Name/__snapshots__/Name.test.tsx.snap @@ -16,64 +16,7 @@ exports[`Name recognized address should return name 1`] = ` } } > - - - - - - + Identicon -`; \ No newline at end of file +`; diff --git a/app/components/Views/confirmations/Send/index.test.tsx b/app/components/Views/confirmations/Send/index.test.tsx index 6432727b813..309da7b89ec 100644 --- a/app/components/Views/confirmations/Send/index.test.tsx +++ b/app/components/Views/confirmations/Send/index.test.tsx @@ -50,6 +50,9 @@ const initialState = { TokenBalancesController: { contractBalances: {}, }, + TokenListController: { + tokenList: [], + }, PreferencesController: { featureFlags: {}, identities: { diff --git a/app/components/hooks/DisplayName/useTokenList.test.ts b/app/components/hooks/DisplayName/useTokenList.test.ts index 856ed1c042d..acd96d4e973 100644 --- a/app/components/hooks/DisplayName/useTokenList.test.ts +++ b/app/components/hooks/DisplayName/useTokenList.test.ts @@ -1,19 +1,19 @@ import React from 'react'; -import { type TokenListMap } from '@metamask/assets-controllers'; +import { type TokenListToken } from '@metamask/assets-controllers'; import { selectChainId } from '../../../selectors/networkController'; import { selectUseTokenDetection } from '../../../selectors/preferencesController'; -import { selectTokenList } from '../../../selectors/tokenListController'; +import { selectTokenListArray } from '../../../selectors/tokenListController'; import { isMainnetByChainId } from '../../../util/networks'; import useTokenList from './useTokenList'; const MAINNET_TOKEN_ADDRESS_MOCK = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; const MAINNET_TOKEN_NAME_MOCK = 'Tether USD'; -const normalizedMainnetTokenListMock = { - [MAINNET_TOKEN_ADDRESS_MOCK.toLowerCase()]: { +const normalizedMainnetTokenListMock = [ + { name: MAINNET_TOKEN_NAME_MOCK, }, -}; +]; jest.mock('@metamask/contract-metadata', () => ({ __esModule: true, default: { @@ -38,7 +38,7 @@ jest.mock('../../../selectors/preferencesController', () => ({ })); jest.mock('../../../selectors/tokenListController', () => ({ - selectTokenList: jest.fn(), + selectTokenListArray: jest.fn(), })); jest.mock('../../../util/networks', () => ({ @@ -48,27 +48,29 @@ jest.mock('../../../util/networks', () => ({ const CHAIN_ID_MOCK = '0x1'; const TOKEN_NAME_MOCK = 'MetaMask Token'; const TOKEN_ADDRESS_MOCK = '0x0439e60F02a8900a951603950d8D4527f400C3f1'; -const TOKEN_LIST_MOCK = { - [TOKEN_ADDRESS_MOCK]: { +const TOKEN_LIST_ARRAY_MOCK = [ + { name: TOKEN_NAME_MOCK, + address: TOKEN_ADDRESS_MOCK, }, -} as unknown as TokenListMap; -const normalizedTokenListMock = { - [TOKEN_ADDRESS_MOCK.toLowerCase()]: { +] as unknown as TokenListToken[]; +const normalizedTokenListMock = [ + { + address: TOKEN_ADDRESS_MOCK, name: TOKEN_NAME_MOCK, }, -}; +]; describe('useTokenList', () => { const selectChainIdMock = jest.mocked(selectChainId); const selectUseTokenDetectionMock = jest.mocked(selectUseTokenDetection); - const selectTokenListMock = jest.mocked(selectTokenList); + const selectTokenListArrayMock = jest.mocked(selectTokenListArray); const isMainnetByChainIdMock = jest.mocked(isMainnetByChainId); beforeEach(() => { jest.resetAllMocks(); selectChainIdMock.mockReturnValue(CHAIN_ID_MOCK); selectUseTokenDetectionMock.mockReturnValue(true); - selectTokenListMock.mockReturnValue(TOKEN_LIST_MOCK); + selectTokenListArrayMock.mockReturnValue(TOKEN_LIST_ARRAY_MOCK); isMainnetByChainIdMock.mockReturnValue(true); const memoizedValues = new Map(); diff --git a/app/components/hooks/DisplayName/useTokenList.ts b/app/components/hooks/DisplayName/useTokenList.ts index 6726fffbbf3..3e99976658e 100644 --- a/app/components/hooks/DisplayName/useTokenList.ts +++ b/app/components/hooks/DisplayName/useTokenList.ts @@ -1,38 +1,27 @@ import { useMemo } from 'react'; -import { type TokenListMap } from '@metamask/assets-controllers'; import contractMap from '@metamask/contract-metadata'; - +import { TokenListToken } from '@metamask/assets-controllers'; import { useSelector } from 'react-redux'; import { selectChainId } from '../../../selectors/networkController'; import { selectUseTokenDetection } from '../../../selectors/preferencesController'; -import { selectTokenList } from '../../../selectors/tokenListController'; +import { selectTokenListArray } from '../../../selectors/tokenListController'; import { isMainnetByChainId } from '../../../util/networks'; -function normalizeTokenAddresses(tokenMap: TokenListMap) { - return Object.keys(tokenMap).reduce((acc, address) => { - const tokenMetadata = tokenMap[address]; - return { - ...acc, - [address.toLowerCase()]: { - ...tokenMetadata, - }, - }; - }, {}); -} - -const NORMALIZED_MAINNET_TOKEN_LIST = normalizeTokenAddresses(contractMap); +const NORMALIZED_MAINNET_TOKEN_ARRAY = Object.values( + contractMap, +) as TokenListToken[]; -export default function useTokenList(): TokenListMap { +export default function useTokenList(): TokenListToken[] { const chainId = useSelector(selectChainId); const isMainnet = isMainnetByChainId(chainId); const isTokenDetectionEnabled = useSelector(selectUseTokenDetection); - const tokenList = useSelector(selectTokenList); + const tokenListArray = useSelector(selectTokenListArray); const shouldUseStaticList = !isTokenDetectionEnabled && isMainnet; return useMemo(() => { if (shouldUseStaticList) { - return NORMALIZED_MAINNET_TOKEN_LIST; + return NORMALIZED_MAINNET_TOKEN_ARRAY; } - return normalizeTokenAddresses(tokenList); - }, [shouldUseStaticList, tokenList]); + return tokenListArray; + }, [shouldUseStaticList, tokenListArray]); } diff --git a/app/components/hooks/DisplayName/useTokenListEntry.test.ts b/app/components/hooks/DisplayName/useTokenListEntry.test.ts index 7aeb4144129..bd576c0bb28 100644 --- a/app/components/hooks/DisplayName/useTokenListEntry.test.ts +++ b/app/components/hooks/DisplayName/useTokenListEntry.test.ts @@ -1,4 +1,4 @@ -import { TokenListMap } from '@metamask/assets-controllers'; +import { TokenListToken } from '@metamask/assets-controllers'; import { NameType } from '../../UI/Name/Name.types'; import { useTokenListEntry } from './useTokenListEntry'; import useTokenList from './useTokenList'; @@ -18,12 +18,13 @@ describe('useTokenListEntry', () => { beforeEach(() => { jest.resetAllMocks(); - useTokenListMock.mockReturnValue({ - [TOKEN_ADDRESS_MOCK.toLowerCase()]: { + useTokenListMock.mockReturnValue([ + { + address: TOKEN_ADDRESS_MOCK.toLowerCase(), name: TOKEN_NAME_MOCK, symbol: TOKEN_SYMBOL_MOCK, }, - } as TokenListMap); + ] as unknown as TokenListToken[]); }); it('returns undefined if no token found', () => { diff --git a/app/components/hooks/DisplayName/useTokenListEntry.ts b/app/components/hooks/DisplayName/useTokenListEntry.ts index 6fecba6303f..6cfe75a87d1 100644 --- a/app/components/hooks/DisplayName/useTokenListEntry.ts +++ b/app/components/hooks/DisplayName/useTokenListEntry.ts @@ -1,3 +1,4 @@ +import { TokenListToken } from '@metamask/assets-controllers'; import { NameType } from '../../UI/Name/Name.types'; import useTokenList from './useTokenList'; @@ -7,7 +8,7 @@ export interface UseTokenListEntriesRequest { } export function useTokenListEntries(requests: UseTokenListEntriesRequest[]) { - const tokenList = useTokenList(); + const tokenListArray = useTokenList(); return requests.map(({ value, type }) => { if (type !== NameType.EthereumAddress) { @@ -16,7 +17,9 @@ export function useTokenListEntries(requests: UseTokenListEntriesRequest[]) { const normalizedValue = value.toLowerCase(); - return tokenList[normalizedValue]; + return tokenListArray.find( + (token: TokenListToken) => token.address === normalizedValue, + ); }); } diff --git a/app/selectors/tokenListController.ts b/app/selectors/tokenListController.ts index 4cdee2c6586..294c3a6ed3e 100644 --- a/app/selectors/tokenListController.ts +++ b/app/selectors/tokenListController.ts @@ -2,6 +2,7 @@ import { createSelector } from 'reselect'; import { TokenListState } from '@metamask/assets-controllers'; import { RootState } from '../reducers'; import { tokenListToArray } from '../util/tokens'; +import { createDeepEqualSelector } from '../selectors/util'; const selectTokenLIstConstrollerState = (state: RootState) => state.engine.backgroundState.TokenListController; @@ -20,7 +21,7 @@ export const selectTokenList = createSelector( * Return token list array from TokenListController. * Can pass directly into useSelector. */ -export const selectTokenListArray = createSelector( +export const selectTokenListArray = createDeepEqualSelector( selectTokenList, tokenListToArray, ); From b3ea18bea00d6c2594920bf7b984009faaef7bfb Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Wed, 3 Jul 2024 02:52:57 +0200 Subject: [PATCH 12/59] fix: add edge case of having falsy address for `useTokenListEntries` (#10227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds the support of having falsy address for `useTokenListEntries` hook. ## **Related issues** N/A ## **Manual testing steps** N/A ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/UI/Identicon/index.tsx | 2 +- app/components/hooks/DisplayName/useTokenListEntry.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Identicon/index.tsx b/app/components/UI/Identicon/index.tsx index aaac02b3ef4..8daa81dbd6d 100644 --- a/app/components/UI/Identicon/index.tsx +++ b/app/components/UI/Identicon/index.tsx @@ -46,7 +46,7 @@ const Identicon: React.FC = ({ }) => { const { colors } = useTheme(); const tokenListIcon = useTokenListEntry( - address as string, + address || '', NameType.EthereumAddress, )?.iconUrl; diff --git a/app/components/hooks/DisplayName/useTokenListEntry.ts b/app/components/hooks/DisplayName/useTokenListEntry.ts index 6cfe75a87d1..607ceab0eda 100644 --- a/app/components/hooks/DisplayName/useTokenListEntry.ts +++ b/app/components/hooks/DisplayName/useTokenListEntry.ts @@ -11,7 +11,7 @@ export function useTokenListEntries(requests: UseTokenListEntriesRequest[]) { const tokenListArray = useTokenList(); return requests.map(({ value, type }) => { - if (type !== NameType.EthereumAddress) { + if (type !== NameType.EthereumAddress || !value) { return null; } From 62ed9fecc5a60197c82f97a22d06a104648e34e1 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Wed, 3 Jul 2024 10:01:12 +0100 Subject: [PATCH 13/59] fix: untranslated error when speed up transaction (#10190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes an issue with an untranslated error and validates the `totalMaxHex` if it is a valid hex string. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/10183 ## **Manual testing steps** 1. GIVEN I sent assets 2. WHEN I speed up transaction on Transaction screen 3. THEN speed up transaction is successfully submitted ## **Screenshots/Recordings** [Screencast from 01-07-2024 13:18:52.webm](https://github.com/MetaMask/metamask-mobile/assets/45455812/02136c74-8315-4c81-86d9-d24d56d53739) ### **Before** When validation fails before ![image](https://github.com/MetaMask/metamask-mobile/assets/45455812/9753be7c-0792-45b6-847e-9a3848df16ce) ### **After** When validation fails after fix ![Screenshot from 2024-07-01 12-53-25](https://github.com/MetaMask/metamask-mobile/assets/45455812/18215ca9-fc62-4ef5-ab5f-0fab32090055) ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../components/UpdateEIP1559Tx/index.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/components/Views/confirmations/components/UpdateEIP1559Tx/index.tsx b/app/components/Views/confirmations/components/UpdateEIP1559Tx/index.tsx index d105475bf96..794a37e81df 100644 --- a/app/components/Views/confirmations/components/UpdateEIP1559Tx/index.tsx +++ b/app/components/Views/confirmations/components/UpdateEIP1559Tx/index.tsx @@ -1,10 +1,14 @@ -/* eslint-disable no-mixed-spaces-and-tabs */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import EditGasFee1559Update from '../EditGasFee1559Update'; import { connect } from 'react-redux'; import { CANCEL_RATE, SPEED_UP_RATE } from '@metamask/transaction-controller'; import { GAS_ESTIMATE_TYPES } from '@metamask/gas-fee-controller'; -import { hexToBN, fromWei, renderFromWei } from '../../../../../util/number'; +import { + hexToBN, + fromWei, + renderFromWei, + addHexPrefix, +} from '../../../../../util/number'; import BigNumber from 'bignumber.js'; import { getTicker } from '../../../../../util/transactions'; import AppConstants from '../../../../../core/AppConstants'; @@ -24,6 +28,7 @@ import { selectSelectedInternalAccountChecksummedAddress } from '../../../../../ import { getDecimalChainId } from '../../../../../util/networks'; import { selectGasFeeEstimates } from '../../../../../selectors/confirmTransaction'; import { selectGasFeeControllerEstimateType } from '../../../../../selectors/gasFeeController'; +import { isHexString } from '@metamask/utils'; const UpdateEIP1559Tx = ({ gas, @@ -101,13 +106,14 @@ const UpdateEIP1559Tx = ({ const validateAmount = useCallback( (updateTx) => { let error; + const totalMaxHexPrefixed = addHexPrefix(updateTx.totalMaxHex); - if (isNaN(updateTx.totalMaxHex)) { - return strings('invalid_amount'); + if (!isHexString(totalMaxHexPrefixed)) { + return strings('transaction.invalid_amount'); } // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - const updateTxCost: any = hexToBN(`0x${updateTx.totalMaxHex}`); + const updateTxCost: any = hexToBN(totalMaxHexPrefixed); // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const accountBalance: any = hexToBN(accounts[selectedAddress].balance); From 0f40b8d57f9766c6f80ec42c396367ba924a1c33 Mon Sep 17 00:00:00 2001 From: salimtb Date: Wed, 3 Jul 2024 11:16:46 +0200 Subject: [PATCH 14/59] feat: add increase decrease token percentage (#10144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This pull request introduces a new feature to the MetaMask mobile that enhances user experience by displaying the percentage increase or decrease for each token directly within the UI. This update aims to provide users with immediate visual feedback on the performance of their tokens, helping them make more informed decisions based on recent market trends. core PR: https://github.com/MetaMask/core/pull/4206 figma: https://www.figma.com/design/aMYisczaJyEsYl1TYdcPUL/Wallet-Assets?node-id=1620-23897&t=EJSzfKoFvTJ5LuK0-0 ## **Related issues** Fixes: #9635 ## **Manual testing steps** 1. Go to the wallet view 2. You should see the percentage of increase/decrease ## **Screenshots/Recordings** ### **Before** Screenshot 2024-05-15 at 16 54 25 ### **After** ![Screenshot 2024-05-15 at 16 39 31](https://github.com/MetaMask/metamask-mobile/assets/26223211/3792f1d2-6300-4cad-ad5a-6c49e1697773) ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .storybook/storybook.requires.js | 2 + .../AggregatedPercentage.stories.tsx | 63 ++ .../AggregatedPercentage.styles.ts | 20 + .../AggregatedPercentage.test.tsx | 68 ++ .../AggregatedPercentage.tsx | 76 ++ .../AggregatedPercentage.test.tsx.snap | 43 + .../Price/AggregatedPercentage/index.ts | 1 + .../PercentageChange.stories.tsx | 56 ++ .../PercentageChange.test.tsx | 40 + .../PercentageChange/PercentageChange.tsx | 28 + .../PercentageChange.test.tsx.snap | 21 + .../Price/PercentageChange/index.ts | 1 + .../__snapshots__/index.test.tsx.snap | 12 +- app/components/UI/AssetElement/index.tsx | 61 +- .../Tokens/__snapshots__/index.test.tsx.snap | 820 ++++++++++++------ app/components/UI/Tokens/index.tsx | 44 +- app/core/Engine.ts | 31 +- storybook/storyLoader.js | 4 + 18 files changed, 1099 insertions(+), 292 deletions(-) create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories.tsx create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.styles.ts create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.test.tsx create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.tsx create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/__snapshots__/AggregatedPercentage.test.tsx.snap create mode 100644 app/component-library/components-temp/Price/AggregatedPercentage/index.ts create mode 100644 app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories.tsx create mode 100644 app/component-library/components-temp/Price/PercentageChange/PercentageChange.test.tsx create mode 100644 app/component-library/components-temp/Price/PercentageChange/PercentageChange.tsx create mode 100644 app/component-library/components-temp/Price/PercentageChange/__snapshots__/PercentageChange.test.tsx.snap create mode 100644 app/component-library/components-temp/Price/PercentageChange/index.ts diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index 8c929c9fe7d..4c8963175cc 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -117,6 +117,8 @@ const getStories = () => { './app/components/UI/Name/Name.stories.tsx': require('../app/components/UI/Name/Name.stories.tsx'), "./app/components/UI/SimulationDetails/SimulationDetails.stories.tsx": require("../app/components/UI/SimulationDetails/SimulationDetails.stories.tsx"), "./app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx": require("../app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx"), + './app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories': require('../app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories.tsx'), + './app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories': require('../app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories.tsx'), }; }; diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories.tsx b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories.tsx new file mode 100644 index 00000000000..d9706d5e2e6 --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.stories.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import AggregatedPercentage from './AggregatedPercentage'; +import { createStore } from 'redux'; +import initialBackgroundState from '../../../../util/test/initial-background-state.json'; + +const mockInitialState = { + wizard: { + step: 1, + }, + engine: { + backgroundState: initialBackgroundState, + }, +}; + +const rootReducer = (state = mockInitialState) => state; +const store = createStore(rootReducer); + +export default { + title: 'Component Library / AggregatedPercentage', + component: AggregatedPercentage, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +const Template = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + ethFiat: 1000, + tokenFiat: 500, + tokenFiat1dAgo: 950, + ethFiat1dAgo: 450, +}; + +export const NegativePercentageChange = Template.bind({}); +NegativePercentageChange.args = { + ethFiat: 900, + tokenFiat: 400, + tokenFiat1dAgo: 950, + ethFiat1dAgo: 1000, +}; + +export const PositivePercentageChange = Template.bind({}); +PositivePercentageChange.args = { + ethFiat: 1100, + tokenFiat: 600, + tokenFiat1dAgo: 500, + ethFiat1dAgo: 1000, +}; + +export const MixedPercentageChange = Template.bind({}); +MixedPercentageChange.args = { + ethFiat: 1050, + tokenFiat: 450, + tokenFiat1dAgo: 500, + ethFiat1dAgo: 1000, +}; diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.styles.ts b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.styles.ts new file mode 100644 index 00000000000..4852ce1d684 --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.styles.ts @@ -0,0 +1,20 @@ +// Third party dependencies. +import { StyleSheet } from 'react-native'; + +/** + * Style sheet function for AggregatedPercentage component. + * + * @param params Style sheet params. + * @param params.theme App theme from ThemeContext. + * @param params.vars Inputs that the style sheet depends on. + * @returns StyleSheet object. + */ +const styleSheet = () => + StyleSheet.create({ + wrapper: { + flexDirection: 'row', + alignItems: 'center', + }, + }); + +export default styleSheet; diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.test.tsx b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.test.tsx new file mode 100644 index 00000000000..affec361321 --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import AggregatedPercentage from './AggregatedPercentage'; +import { mockTheme } from '../../../../util/theme'; +import { useSelector } from 'react-redux'; +import { selectCurrentCurrency } from '../../../../selectors/currencyRateController'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +describe('AggregatedPercentage', () => { + beforeEach(() => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectCurrentCurrency) return 'USD'; + }); + }); + afterEach(() => { + (useSelector as jest.Mock).mockClear(); + }); + it('should render correctly', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders positive percentage change correctly', () => { + const { getByText } = render( + , + ); + + expect(getByText('(+25.00%)')).toBeTruthy(); + expect(getByText('+100 USD')).toBeTruthy(); + + expect(getByText('(+25.00%)').props.style).toMatchObject({ + color: mockTheme.colors.success.default, + }); + }); + + it('renders negative percentage change correctly', () => { + const { getByText } = render( + , + ); + + expect(getByText('(-30.00%)')).toBeTruthy(); + expect(getByText('-150 USD')).toBeTruthy(); + + expect(getByText('(-30.00%)').props.style).toMatchObject({ + color: mockTheme.colors.error.default, + }); + }); +}); diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.tsx b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.tsx new file mode 100644 index 00000000000..3e0907f937b --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import Text, { + TextColor, + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import { View } from 'react-native'; +import { renderFiat } from '../../../../util/number'; +import { useSelector } from 'react-redux'; +import { selectCurrentCurrency } from '../../../../selectors/currencyRateController'; +import styleSheet from './AggregatedPercentage.styles'; +import { useStyles } from '../../../hooks'; + +const isValidAmount = (amount: number | null | undefined): boolean => + amount !== null && amount !== undefined && !Number.isNaN(amount); + +const AggregatedPercentage = ({ + ethFiat, + tokenFiat, + tokenFiat1dAgo, + ethFiat1dAgo, +}: { + ethFiat: number; + tokenFiat: number; + tokenFiat1dAgo: number; + ethFiat1dAgo: number; +}) => { + const { styles } = useStyles(styleSheet, {}); + + const currentCurrency = useSelector(selectCurrentCurrency); + const DECIMALS_TO_SHOW = 2; + + const totalBalance = ethFiat + tokenFiat; + const totalBalance1dAgo = ethFiat1dAgo + tokenFiat1dAgo; + + const amountChange = totalBalance - totalBalance1dAgo; + + const percentageChange = + ((totalBalance - totalBalance1dAgo) / totalBalance1dAgo) * 100 || 0; + + let percentageTextColor = TextColor.Default; + + if (percentageChange === 0) { + percentageTextColor = TextColor.Default; + } else if (percentageChange > 0) { + percentageTextColor = TextColor.Success; + } else { + percentageTextColor = TextColor.Error; + } + + const formattedPercentage = isValidAmount(percentageChange) + ? `(${(percentageChange as number) >= 0 ? '+' : ''}${( + percentageChange as number + ).toFixed(2)}%)` + : ''; + + const formattedValuePrice = isValidAmount(amountChange) + ? `${(amountChange as number) >= 0 ? '+' : ''}${renderFiat( + amountChange, + currentCurrency, + DECIMALS_TO_SHOW, + )} ` + : ''; + + return ( + + + {formattedValuePrice} + + + {formattedPercentage} + + + ); +}; + +export default AggregatedPercentage; diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/__snapshots__/AggregatedPercentage.test.tsx.snap b/app/component-library/components-temp/Price/AggregatedPercentage/__snapshots__/AggregatedPercentage.test.tsx.snap new file mode 100644 index 00000000000..7eea6a3bd9f --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/__snapshots__/AggregatedPercentage.test.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AggregatedPercentage should render correctly 1`] = ` + + + +20 USD + + + (+11.11%) + + +`; diff --git a/app/component-library/components-temp/Price/AggregatedPercentage/index.ts b/app/component-library/components-temp/Price/AggregatedPercentage/index.ts new file mode 100644 index 00000000000..3e7965d02fa --- /dev/null +++ b/app/component-library/components-temp/Price/AggregatedPercentage/index.ts @@ -0,0 +1 @@ +export { default } from './AggregatedPercentage'; diff --git a/app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories.tsx b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories.tsx new file mode 100644 index 00000000000..59612dcf9ba --- /dev/null +++ b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.stories.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PercentageChange from './PercentageChange'; +import { createStore } from 'redux'; +import initialBackgroundState from '../../../../util/test/initial-background-state.json'; + +const mockInitialState = { + wizard: { + step: 1, + }, + engine: { + backgroundState: initialBackgroundState, + }, +}; + +const rootReducer = (state = mockInitialState) => state; +const store = createStore(rootReducer); + +export default { + title: 'Component Library / PercentageChange', + component: PercentageChange, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +const Template = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + value: 0, +}; + +export const PositiveChange = Template.bind({}); +PositiveChange.args = { + value: 5.5, +}; + +export const NegativeChange = Template.bind({}); +NegativeChange.args = { + value: -3.75, +}; + +export const NoChange = Template.bind({}); +NoChange.args = { + value: 0, +}; + +export const InvalidValue = Template.bind({}); +InvalidValue.args = { + value: null, +}; diff --git a/app/component-library/components-temp/Price/PercentageChange/PercentageChange.test.tsx b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.test.tsx new file mode 100644 index 00000000000..f01d3e7606f --- /dev/null +++ b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import PercentageChange from './PercentageChange'; +import { mockTheme } from '../../../../util/theme'; + +describe('PercentageChange', () => { + it('should render correctly', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + it('displays a positive value correctly', () => { + const { getByText } = render(); + const positiveText = getByText('+5.50%'); + expect(positiveText).toBeTruthy(); + expect(positiveText.props.style).toMatchObject({ + color: mockTheme.colors.success.default, + }); + }); + + it('displays a negative value correctly', () => { + const { getByText } = render(); + const negativeText = getByText('-3.25%'); + expect(negativeText).toBeTruthy(); + expect(negativeText.props.style).toMatchObject({ + color: mockTheme.colors.error.default, + }); + }); + + it('handles null value correctly', () => { + const { queryByText } = render(); + expect(queryByText(/\+/)).toBeNull(); + expect(queryByText(/-/)).toBeNull(); + }); + + it('handles undefined value correctly', () => { + const { queryByText } = render(); + expect(queryByText(/\+/)).toBeNull(); + expect(queryByText(/-/)).toBeNull(); + }); +}); diff --git a/app/component-library/components-temp/Price/PercentageChange/PercentageChange.tsx b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.tsx new file mode 100644 index 00000000000..206b905fc92 --- /dev/null +++ b/app/component-library/components-temp/Price/PercentageChange/PercentageChange.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Text, { + TextColor, + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import { View } from 'react-native'; + +const PercentageChange = ({ value }: { value: number | null | undefined }) => { + const percentageColorText = + value && value >= 0 ? TextColor.Success : TextColor.Error; + + const isValidAmount = (amount: number | null | undefined): boolean => + amount !== null && amount !== undefined && !Number.isNaN(amount); + + const formattedValue = isValidAmount(value) + ? `${(value as number) >= 0 ? '+' : ''}${(value as number).toFixed(2)}%` + : ''; + + return ( + + + {formattedValue} + + + ); +}; + +export default PercentageChange; diff --git a/app/component-library/components-temp/Price/PercentageChange/__snapshots__/PercentageChange.test.tsx.snap b/app/component-library/components-temp/Price/PercentageChange/__snapshots__/PercentageChange.test.tsx.snap new file mode 100644 index 00000000000..571dec70ebc --- /dev/null +++ b/app/component-library/components-temp/Price/PercentageChange/__snapshots__/PercentageChange.test.tsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PercentageChange should render correctly 1`] = ` + + + +5.50% + + +`; diff --git a/app/component-library/components-temp/Price/PercentageChange/index.ts b/app/component-library/components-temp/Price/PercentageChange/index.ts new file mode 100644 index 00000000000..6a3f076bb2a --- /dev/null +++ b/app/component-library/components-temp/Price/PercentageChange/index.ts @@ -0,0 +1 @@ +export { default } from './PercentageChange'; diff --git a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap index 5704e5960e9..99fc71bcbfc 100644 --- a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap @@ -14,5 +14,15 @@ exports[`AssetElement should render correctly 1`] = ` } } testID="asset-DAI" -/> +> + + `; diff --git a/app/components/UI/AssetElement/index.tsx b/app/components/UI/AssetElement/index.tsx index e95c3a3a1f6..fdf638bb125 100644 --- a/app/components/UI/AssetElement/index.tsx +++ b/app/components/UI/AssetElement/index.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/prop-types */ import React from 'react'; -import { TouchableOpacity, StyleSheet, Platform } from 'react-native'; +import { TouchableOpacity, StyleSheet, Platform, View } from 'react-native'; import Text, { TextVariant, } from '../../../component-library/components/Texts/Text'; @@ -12,15 +12,20 @@ import { TOKEN_BALANCE_LOADING, TOKEN_RATE_UNDEFINED, } from '../Tokens/constants'; +import { Colors } from '../../../util/theme/models'; +import { fontStyles } from '../../../styles/common'; +import { useTheme } from '../../../util/theme'; + interface AssetElementProps { children?: React.ReactNode; asset: TokenI; onPress?: (asset: TokenI) => void; onLongPress?: ((asset: TokenI) => void) | null; balance?: string; + mainBalance?: string | null; } -const createStyles = () => +const createStyles = (colors: Colors) => StyleSheet.create({ itemWrapper: { flex: 1, @@ -32,6 +37,7 @@ const createStyles = () => arrow: { flex: 1, alignSelf: 'flex-end', + alignItems: 'flex-end', }, arrowIcon: { marginTop: 16, @@ -39,6 +45,12 @@ const createStyles = () => skeleton: { width: 50, }, + balanceFiat: { + color: colors.text.alternative, + paddingHorizontal: 0, + ...fontStyles.normal, + textTransform: 'uppercase', + }, }); /** @@ -48,10 +60,12 @@ const AssetElement: React.FC = ({ children, balance, asset, + mainBalance = null, onPress, onLongPress, }) => { - const styles = createStyles(); + const { colors } = useTheme(); + const styles = createStyles(colors); const handleOnPress = () => { onPress?.(asset); @@ -70,21 +84,32 @@ const AssetElement: React.FC = ({ > {children} - {balance && ( - - {balance === TOKEN_BALANCE_LOADING ? ( - - ) : ( - balance - )} - - )} + + {balance && ( + + {balance === TOKEN_BALANCE_LOADING ? ( + + ) : ( + balance + )} + + )} + {mainBalance ? ( + + {mainBalance === TOKEN_BALANCE_LOADING ? ( + + ) : ( + mainBalance + )} + + ) : null} + ); }; diff --git a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap index 4aaccc2fc58..5e8f8125c9b 100644 --- a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap @@ -327,19 +327,22 @@ exports[`Tokens should hide zero balance tokens when setting is on 1`] = ` } } > - + - 0 USD - + testID="total-balance-text" + > + 0 USD + + +