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