diff --git a/app/components/UI/Carousel/index.test.tsx b/app/components/UI/Carousel/index.test.tsx index ede360145c16..cce8a06e8e60 100644 --- a/app/components/UI/Carousel/index.test.tsx +++ b/app/components/UI/Carousel/index.test.tsx @@ -6,6 +6,7 @@ import { renderHook, } from '@testing-library/react-native'; import { useSelector, useDispatch } from 'react-redux'; +import { Linking } from 'react-native'; import SharedDeeplinkManager from '../../../core/DeeplinkManager/SharedDeeplinkManager'; import AppConstants from '../../../core/AppConstants'; import Carousel, { useFetchCarouselSlides } from './'; @@ -70,6 +71,10 @@ jest.mock('../../../core/DeeplinkManager/SharedDeeplinkManager', () => ({ parse: jest.fn(() => Promise.resolve()), })); +jest.mock('react-native/Libraries/Linking/Linking', () => ({ + openURL: jest.fn(() => Promise.resolve()), +})); + jest.mock('./fetchCarouselSlidesFromContentful', () => ({ ...jest.requireActual('./fetchCarouselSlidesFromContentful'), fetchCarouselSlidesFromContentful: jest.fn(), @@ -200,8 +205,10 @@ describe('Carousel Slide Filtering', () => { describe('Carousel Navigation', () => { it('opens external URLs when slide is clicked', async () => { + const slideID = 'deeplink-slide'; + const slideTestID = `carousel-slide-${slideID}`; const urlSlide = createMockSlide({ - id: 'url-slide', + id: slideID, navigation: { type: 'url', href: 'https://metamask.io' }, }); mockFetchCarouselSlides.mockResolvedValue({ @@ -210,14 +217,36 @@ describe('Carousel Navigation', () => { }); const { findByTestId } = render(); - const slide = await findByTestId('carousel-slide-url-slide'); + const slide = await findByTestId(slideTestID); fireEvent.press(slide); + + expect(Linking.openURL).toHaveBeenCalledWith('https://metamask.io'); + expect(SharedDeeplinkManager.parse).not.toHaveBeenCalled(); + }); + + it('handles internal deeplinks through SharedDeeplinkManager', async () => { + const slideID = 'deeplink-slide'; + const slideTestID = `carousel-slide-${slideID}`; + const deeplinkSlide = createMockSlide({ + id: slideID, + navigation: { type: 'url', href: 'https://link.metamask.io/swap' }, + }); + mockFetchCarouselSlides.mockResolvedValue({ + prioritySlides: [], + regularSlides: [deeplinkSlide], + }); + + const { findByTestId } = render(); + const slide = await findByTestId(slideTestID); + fireEvent.press(slide); + expect(SharedDeeplinkManager.parse).toHaveBeenCalledWith( - 'https://metamask.io', + 'https://link.metamask.io/swap', { - origin: AppConstants.DEEPLINKS.ORIGIN_DEEPLINK, + origin: AppConstants.DEEPLINKS.ORIGIN_CAROUSEL, }, ); + expect(Linking.openURL).not.toHaveBeenCalled(); }); it('navigates to buy flow for fund slides', async () => { diff --git a/app/components/UI/Carousel/index.tsx b/app/components/UI/Carousel/index.tsx index 003a78e33541..41717e3fab47 100644 --- a/app/components/UI/Carousel/index.tsx +++ b/app/components/UI/Carousel/index.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useRef, } from 'react'; -import { Dimensions, Animated } from 'react-native'; +import { Dimensions, Animated, Linking } from 'react-native'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import { CarouselProps, CarouselSlide, NavigationAction } from './types'; @@ -42,8 +42,9 @@ import { selectContentfulCarouselEnabledFlag } from './selectors/featureFlags'; import { createBuyNavigationDetails } from '../Ramp/Aggregator/routes/utils'; import Routes from '../../../constants/navigation/Routes'; import { subscribeToContentPreviewToken } from '../../../actions/notification/helpers'; -import AppConstants from '../../../core/AppConstants'; import SharedDeeplinkManager from '../../../core/DeeplinkManager/SharedDeeplinkManager'; +import { isInternalDeepLink } from '../../../util/deeplinks'; +import AppConstants from '../../../core/AppConstants'; const MAX_CAROUSEL_SLIDES = 8; @@ -342,13 +343,26 @@ const CarouselComponent: FC = ({ style, onEmptyState }) => { const openUrl = (href: string): (() => Promise) => - () => - SharedDeeplinkManager.parse(href, { - origin: AppConstants.DEEPLINKS.ORIGIN_DEEPLINK, - }).catch((error) => { - console.error('Failed to open URL:', error); - return false; - }); + () => { + // Check if this is an internal MetaMask deeplink + if (isInternalDeepLink(href)) { + // Handle internal deeplinks through SharedDeeplinkManager + return SharedDeeplinkManager.parse(href, { + origin: AppConstants.DEEPLINKS.ORIGIN_CAROUSEL, + }).catch((error) => { + console.error('Failed to handle internal deeplink:', error); + return false; + }); + } + + // For external URLs, use the OS linking system + return Linking.openURL(href) + .then(() => true) + .catch((error) => { + console.error('Failed to open external URL:', error); + return false; + }); + }; const handleSlideClick = useCallback( (slideId: string, navigation: NavigationAction) => { diff --git a/app/components/Views/BrowserTab/BrowserTab.tsx b/app/components/Views/BrowserTab/BrowserTab.tsx index 6ffaebd11ecd..969b9a397383 100644 --- a/app/components/Views/BrowserTab/BrowserTab.tsx +++ b/app/components/Views/BrowserTab/BrowserTab.tsx @@ -54,6 +54,8 @@ import { sortMultichainAccountsByLastSelected, } from '../../../core/Permissions'; import Routes from '../../../constants/navigation/Routes'; +import { isInternalDeepLink } from '../../../util/deeplinks'; +import SharedDeeplinkManager from '../../../core/DeeplinkManager/SharedDeeplinkManager'; import { selectIpfsGateway, selectIsIpfsGatewayEnabled, @@ -813,6 +815,29 @@ export const BrowserTab: React.FC = React.memo( } } + // Check if this is an internal MetaMask deeplink that should be handled within the app + if (isInternalDeepLink(urlToLoad)) { + // Handle the deeplink internally instead of passing to OS + SharedDeeplinkManager.parse(urlToLoad, { + origin: AppConstants.DEEPLINKS.ORIGIN_IN_APP_BROWSER, + browserCallBack: (url: string) => { + // If the deeplink handler wants to navigate to a different URL in the browser + if (url && webviewRef.current) { + webviewRef.current.injectJavaScript(` + window.location.href = '${sanitizeUrlInput(url)}'; + true; // Required for iOS + `); + } + }, + }).catch((error) => { + Logger.error( + error, + 'BrowserTab: Failed to handle internal deeplink in browser', + ); + }); + return false; // Stop the webview from loading this URL + } + const { protocol } = new URLParse(urlToLoad); if (trustedProtocolToDeeplink.includes(protocol)) { diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index baf47b475771..522a406b5684 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -73,9 +73,11 @@ export default { POLLING_FREQUENCY: 10000, }, DEEPLINKS: { + ORIGIN_CAROUSEL: 'carousel', ORIGIN_DEEPLINK: 'deeplink', ORIGIN_QR_CODE: 'qr-code', ORIGIN_NOTIFICATION: 'notifications', + ORIGIN_IN_APP_BROWSER: 'in-app-browser', }, WALLET_CONNECT: { //One day in hours diff --git a/app/util/deeplinks/index.test.ts b/app/util/deeplinks/index.test.ts new file mode 100644 index 000000000000..fe46622c6f58 --- /dev/null +++ b/app/util/deeplinks/index.test.ts @@ -0,0 +1,83 @@ +import { isInternalDeepLink } from './index'; + +describe('deeplinks utils', () => { + describe('isInternalDeepLink', () => { + it('identifies MetaMask custom scheme deeplinks', () => { + expect(isInternalDeepLink('metamask://connect')).toBe(true); + expect(isInternalDeepLink('metamask://wc?uri=...')).toBe(true); + expect(isInternalDeepLink('metamask://dapp/uniswap.org')).toBe(true); + }); + + it('identifies Ethereum scheme deeplinks', () => { + expect(isInternalDeepLink('ethereum://pay-0x1234')).toBe(true); + expect(isInternalDeepLink('ethereum://0x1234?value=1e18')).toBe(true); + }); + + it('identifies dapp scheme deeplinks', () => { + expect(isInternalDeepLink('dapp://app.uniswap.org')).toBe(true); + expect(isInternalDeepLink('dapp://portfolio.metamask.io')).toBe(true); + }); + + it('identifies MetaMask universal links', () => { + expect(isInternalDeepLink('https://link.metamask.io/swap')).toBe(true); + expect(isInternalDeepLink('https://link.metamask.io/buy-crypto')).toBe( + true, + ); + expect( + isInternalDeepLink('https://link.metamask.io/dapp/uniswap.org'), + ).toBe(true); + }); + + it('identifies MetaMask test universal links', () => { + expect(isInternalDeepLink('https://link-test.metamask.io/swap')).toBe( + true, + ); + expect(isInternalDeepLink('https://link-test.metamask.io/send')).toBe( + true, + ); + }); + + it('identifies MetaMask branch links', () => { + expect(isInternalDeepLink('https://metamask.app.link/swap')).toBe(true); + expect(isInternalDeepLink('https://metamask.test-app.link/home')).toBe( + true, + ); + }); + + it('does not identify external URLs as internal', () => { + expect(isInternalDeepLink('https://google.com')).toBe(false); + expect(isInternalDeepLink('https://uniswap.org')).toBe(false); + expect(isInternalDeepLink('https://portfolio.metamask.io')).toBe(false); + expect(isInternalDeepLink('http://example.com')).toBe(false); + }); + + it('does not identify other protocols as internal', () => { + expect(isInternalDeepLink('mailto:test@example.com')).toBe(false); + expect(isInternalDeepLink('tel:+1234567890')).toBe(false); + expect(isInternalDeepLink('wc://session')).toBe(false); + expect(isInternalDeepLink('https://wc.example.com')).toBe(false); + }); + + it('handles edge cases gracefully', () => { + expect(isInternalDeepLink('')).toBe(false); + expect(isInternalDeepLink(null)).toBe(false); + expect(isInternalDeepLink(undefined)).toBe(false); + expect(isInternalDeepLink('not-a-valid-url')).toBe(false); + expect(isInternalDeepLink('metamask://')).toBe(true); // Still a valid MetaMask scheme + }); + + it('handlesURLs with query parameters and fragments', () => { + expect( + isInternalDeepLink( + 'https://link.metamask.io/swap?chainId=1&token=0x...', + ), + ).toBe(true); + expect( + isInternalDeepLink('metamask://connect?channelId=123#fragment'), + ).toBe(true); + expect(isInternalDeepLink('https://google.com?metamask=true')).toBe( + false, + ); + }); + }); +}); diff --git a/app/util/deeplinks/index.ts b/app/util/deeplinks/index.ts new file mode 100644 index 000000000000..c491d253b99a --- /dev/null +++ b/app/util/deeplinks/index.ts @@ -0,0 +1,55 @@ +import AppConstants from '../../core/AppConstants'; + +const { + MM_UNIVERSAL_LINK_HOST, + MM_IO_UNIVERSAL_LINK_HOST, + MM_IO_UNIVERSAL_LINK_TEST_HOST, +} = AppConstants; + +/** + * Checks if a URL is an internal MetaMask deeplink that should be handled + * within the app rather than passed to the OS + * + * @param url - The URL to check + * @returns true if the URL is a MetaMask internal deeplink + */ +export const isInternalDeepLink = (url: string | null | undefined): boolean => { + if (!url) return false; + + const metamaskHosts = [ + MM_UNIVERSAL_LINK_HOST || 'link.metamask.io', + MM_IO_UNIVERSAL_LINK_HOST || 'link.metamask.io', + MM_IO_UNIVERSAL_LINK_TEST_HOST || 'link-test.metamask.io', + 'metamask.app.link', // todo double-check if we can remove + 'metamask.test-app.link', // todo double-check if we can remove + ].filter(Boolean); + + try { + // Check custom schemes first (more efficient for these cases) + const internalSchemes = ['metamask:', 'ethereum:', 'dapp:']; + if (internalSchemes.some((scheme) => url.startsWith(scheme))) { + return true; + } + + // Parse URL for host checking + const urlObj = new URL(url); + + // Check if it's a MetaMask universal link + return metamaskHosts.includes(urlObj.hostname); + } catch { + // If URL parsing fails, check if it's a simple scheme match + return ['metamask:', 'ethereum:', 'dapp:'].some((scheme) => + url.startsWith(scheme), + ); + } +}; + +/** + * Determines if a URL should be opened externally (Linking.openURL()) + * This is the inverse of isInternalDeepLink but kept separate for clarity + * + * @param url - The URL to check + * @returns true if the URL should be opened externally + */ +export const shouldOpenExternally = (url: string): boolean => + !isInternalDeepLink(url);