Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions app/components/UI/Carousel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -42,8 +42,8 @@ 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';

const MAX_CAROUSEL_SLIDES = 8;

Expand Down Expand Up @@ -342,13 +342,26 @@ const CarouselComponent: FC<CarouselProps> = ({ style, onEmptyState }) => {

const openUrl =
(href: string): (() => Promise<boolean>) =>
() =>
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: '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) => {
Expand Down
20 changes: 20 additions & 0 deletions app/components/Views/BrowserTab/BrowserTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -813,6 +815,24 @@ export const BrowserTab: React.FC<BrowserTabProps> = 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: '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
`);
}
},
});
return false; // Stop the webview from loading this URL
}

const { protocol } = new URLParse(urlToLoad);

if (trustedProtocolToDeeplink.includes(protocol)) {
Expand Down
95 changes: 95 additions & 0 deletions app/util/deeplinks/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { isInternalDeepLink } from './index';
import AppConstants from '../../core/AppConstants';

describe('deeplinks utils', () => {
describe('isInternalDeepLink', () => {
it('should identify 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('should identify Ethereum scheme deeplinks', () => {
expect(isInternalDeepLink('ethereum://pay-0x1234')).toBe(true);
expect(isInternalDeepLink('ethereum://0x1234?value=1e18')).toBe(true);
});

it('should identify dapp scheme deeplinks', () => {
expect(isInternalDeepLink('dapp://app.uniswap.org')).toBe(true);
expect(isInternalDeepLink('dapp://portfolio.metamask.io')).toBe(true);
});

it('should identify 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('should identify 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('should identify MetaMask branch links', () => {
expect(isInternalDeepLink('https://metamask.app.link/swap')).toBe(true);
expect(isInternalDeepLink('https://metamask.test-app.link/home')).toBe(
true,
);
expect(
isInternalDeepLink('https://metamask-alternate.app.link/buy'),
).toBe(true);
});

it('should 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('should NOT identify other protocols as internal', () => {
expect(isInternalDeepLink('mailto:[email protected]')).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('should handle URLs 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,
);
});

it('should respect AppConstants configuration', () => {
// Test with custom constants if they're defined
const customHost = AppConstants.MM_UNIVERSAL_LINK_HOST;
if (customHost) {
expect(isInternalDeepLink(`https://${customHost}/action`)).toBe(true);
}
});
});
});
51 changes: 51 additions & 0 deletions app/util/deeplinks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import AppConstants from '../../core/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 = [
AppConstants.MM_UNIVERSAL_LINK_HOST || 'link.metamask.io',
AppConstants.MM_IO_UNIVERSAL_LINK_HOST || 'link.metamask.io',
AppConstants.MM_IO_UNIVERSAL_LINK_TEST_HOST || 'link-test.metamask.io',
'metamask.app.link',
'metamask.test-app.link',
'metamask-alternate.app.link',
'metamask-alternate.test-app.link',
].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 (outside the app)
* 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);
Loading