Skip to content

Commit 0696104

Browse files
fix: try fixing X deeplinks by adding [GE-139] cp-7.69.0 (MetaMask#27139)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> Fix branch deeplinks not working with the X (Twitter) app by adding a `$deeplink_path` param in branch.io and consuming it in MetaMask Mobile. ### Context - Platform: happens only on iOS. Android behaves differently (no Deepview in our flow). - User taps a t.co link in X (Twitter) → redirects to https://metamask.app.link/1WkF6GmE40b (which is a link generated by X/Branch.io on Tweet posting) → Branch Deepview is shown → user taps “Get the app” → opens MetaMask via Universal Link: https://metamask-alternate.app.link/1WkF6GmE40b?__branch_flow_type=viewapp&__branch_flow_id=...&__branch_mobile_deepview_type=1. - Issue: The app opens correctly, but in-app we show a “page not found” (404). So the OS and Branch open the app, but we don’t know which in-app route corresponds to link ID 1WkF6GmE40b. ### What we found 1. No resolved URI from the SDK - The Branch iOS SDK (and react-native-branch) give us: - The raw URL that opened the app (e.g. https://metamask-alternate.app.link/1WkF6GmE40b?...), and - The params from the session (e.g. from getLatestReferringParams() / subscribe callback). - The SDK does not build a custom deeplink URI (e.g. metamask://swap) from link data; it only returns the params. So we have to do routing ourselves. 2. Implemented fix: - Added a $deeplink_path in Branch deeplinks (this must be added to every existing and future deeplinks that we want to post on X) - Read it from the params and build our deeplink ourselves (e.g. metamask://${params.$deeplink_path}) and then route inside the app. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Fixed a bug that was causing deeplinks opened from X app to fail ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/GE-139 Fixes: MetaMask#27140 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches deeplink routing/host allowlists and Branch handling on app start, which can affect navigation from external links, but the change is narrowly scoped and covered by new unit tests. > > **Overview** > Fixes Branch short-link universal links (notably from X on iOS) by introducing `rewriteBranchUri`, which converts `metamask-alternate.app.link/<id>` URLs into `https://link.metamask.io/<$deeplink_path>` while preserving query params. > > `DeeplinkManager.start()` now applies this rewrite for both cold-start Branch params (`~referring_link`) and `branch.subscribe` events, replacing the prior `getLatestReferringParams` fallback logic. > > Adds `MM_UNIVERSAL_LINK_HOST_ALTERNATE` (`metamask-alternate.app.link`) and includes it in universal-link host validation (`handleUniversalLink`) and MetaMask-host detection (`util/deeplinks`), with new tests covering rewrite and routing behavior. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit de4e3dc. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 63bd6d1 commit 0696104

5 files changed

Lines changed: 170 additions & 7 deletions

File tree

app/core/AppConstants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export default {
9595
},
9696
},
9797
MM_UNIVERSAL_LINK_HOST: 'metamask.app.link',
98+
MM_UNIVERSAL_LINK_HOST_ALTERNATE: 'metamask-alternate.app.link',
9899
MM_IO_UNIVERSAL_LINK_HOST: 'link.metamask.io',
99100
MM_IO_UNIVERSAL_LINK_TEST_HOST: 'link-test.metamask.io',
100101
MM_DEEP_ITMS_APP_LINK: 'https://metamask.app.link/skAH3BaF99',

app/core/DeeplinkManager/DeeplinkManager.test.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { NavigationProp, ParamListBase } from '@react-navigation/native';
22
import { waitFor } from '@testing-library/react-native';
33
import FCMService from '../../util/notifications/services/FCMService';
44
import NavigationService from '../NavigationService';
5-
import SharedDeeplinkManager, { DeeplinkManager } from './DeeplinkManager';
5+
import SharedDeeplinkManager, {
6+
DeeplinkManager,
7+
rewriteBranchUri,
8+
} from './DeeplinkManager';
9+
import type { BranchParams } from './types/deepLinkAnalytics.types';
610
import { handleDeeplink } from './handlers/legacy/handleDeeplink';
711
import switchNetwork from '../../util/networks/switchNetwork';
812
import parseDeeplink from './utils/parseDeeplink';
@@ -282,6 +286,34 @@ describe('SharedDeeplinkManager', () => {
282286
});
283287
});
284288

289+
describe('rewriteBranchUri', () => {
290+
it('rewrites host and path to link.metamask.io and preserves query when +clicked_branch_link and $deeplink_path are set', () => {
291+
const uri =
292+
'https://metamask-alternate.app.link/1WkF6GmE40b?amount=100&from=0x';
293+
const params: BranchParams = {
294+
'+clicked_branch_link': true,
295+
$deeplink_path: 'swap',
296+
};
297+
expect(rewriteBranchUri(uri, params)).toBe(
298+
'https://link.metamask.io/swap?amount=100&from=0x',
299+
);
300+
});
301+
302+
it('returns uri unchanged when +clicked_branch_link is false', () => {
303+
const uri = 'https://metamask.app.link/swap';
304+
expect(
305+
rewriteBranchUri(uri, { '+clicked_branch_link': false } as BranchParams),
306+
).toBe(uri);
307+
});
308+
309+
it('returns uri unchanged when $deeplink_path is missing', () => {
310+
const uri = 'https://metamask.app.link/swap';
311+
expect(
312+
rewriteBranchUri(uri, { '+clicked_branch_link': true } as BranchParams),
313+
).toBe(uri);
314+
});
315+
});
316+
285317
describe('DeeplinkManager.start Branch deeplink handling', () => {
286318
beforeEach(() => {
287319
jest.clearAllMocks();
@@ -304,6 +336,31 @@ describe('DeeplinkManager.start Branch deeplink handling', () => {
304336
expect(handleDeeplink).toHaveBeenCalledWith({ uri: mockDeeplink });
305337
});
306338

339+
it('rewrites cold start Branch link using $deeplink_path from getLatestReferringParams', async () => {
340+
(branch.getLatestReferringParams as jest.Mock).mockResolvedValue({
341+
'+clicked_branch_link': true,
342+
$deeplink_path: 'swap',
343+
'~referring_link':
344+
'https://metamask-alternate.app.link/1WkF6GmE40b?amount=500',
345+
});
346+
DeeplinkManager.start();
347+
await new Promise((resolve) => setImmediate(resolve));
348+
expect(handleDeeplink).toHaveBeenCalledWith({
349+
uri: 'https://link.metamask.io/swap?amount=500',
350+
});
351+
});
352+
353+
it('falls back to +non_branch_link on cold start when +clicked_branch_link is false', async () => {
354+
const mockDeeplink = 'https://link.metamask.io/home';
355+
(branch.getLatestReferringParams as jest.Mock).mockResolvedValue({
356+
'+clicked_branch_link': false,
357+
'+non_branch_link': mockDeeplink,
358+
});
359+
DeeplinkManager.start();
360+
await new Promise((resolve) => setImmediate(resolve));
361+
expect(handleDeeplink).toHaveBeenCalledWith({ uri: mockDeeplink });
362+
});
363+
307364
it('subscribes to Branch deeplink events', async () => {
308365
DeeplinkManager.start();
309366
expect(branch.subscribe).toHaveBeenCalled();
@@ -318,4 +375,68 @@ describe('DeeplinkManager.start Branch deeplink handling', () => {
318375
await new Promise((resolve) => setImmediate(resolve));
319376
expect(handleDeeplink).toHaveBeenCalledWith({ uri: mockUri });
320377
});
378+
379+
it('rewrites Branch short link to link.metamask.io when +clicked_branch_link and $deeplink_path are present', async () => {
380+
DeeplinkManager.start();
381+
const callback = (branch.subscribe as jest.Mock).mock.calls[0][0];
382+
383+
callback({
384+
uri: 'https://metamask-alternate.app.link/1WkF6GmE40b?amount=1000000&from=eip155%3A1%2Ferc20%3A0xabc',
385+
params: {
386+
'+clicked_branch_link': true,
387+
$deeplink_path: 'swap',
388+
},
389+
});
390+
391+
await new Promise((resolve) => setImmediate(resolve));
392+
expect(handleDeeplink).toHaveBeenCalledWith({
393+
uri: 'https://link.metamask.io/swap?amount=1000000&from=eip155%3A1%2Ferc20%3A0xabc',
394+
});
395+
});
396+
397+
it('passes URI through unchanged when +clicked_branch_link is false', async () => {
398+
DeeplinkManager.start();
399+
const callback = (branch.subscribe as jest.Mock).mock.calls[0][0];
400+
const mockUri = 'https://metamask.app.link/swap?amount=100';
401+
402+
callback({
403+
uri: mockUri,
404+
params: { '+clicked_branch_link': false },
405+
});
406+
407+
await new Promise((resolve) => setImmediate(resolve));
408+
expect(handleDeeplink).toHaveBeenCalledWith({ uri: mockUri });
409+
});
410+
411+
it('passes URI through unchanged when $deeplink_path is missing', async () => {
412+
DeeplinkManager.start();
413+
const callback = (branch.subscribe as jest.Mock).mock.calls[0][0];
414+
const mockUri = 'https://metamask.app.link/swap?amount=100';
415+
416+
callback({
417+
uri: mockUri,
418+
params: { '+clicked_branch_link': true },
419+
});
420+
421+
await new Promise((resolve) => setImmediate(resolve));
422+
expect(handleDeeplink).toHaveBeenCalledWith({ uri: mockUri });
423+
});
424+
425+
it('strips leading slash from $deeplink_path when rewriting', async () => {
426+
DeeplinkManager.start();
427+
const callback = (branch.subscribe as jest.Mock).mock.calls[0][0];
428+
429+
callback({
430+
uri: 'https://metamask-alternate.app.link/ABC123',
431+
params: {
432+
'+clicked_branch_link': true,
433+
$deeplink_path: '/swap/token',
434+
},
435+
});
436+
437+
await new Promise((resolve) => setImmediate(resolve));
438+
expect(handleDeeplink).toHaveBeenCalledWith({
439+
uri: 'https://link.metamask.io/swap/token',
440+
});
441+
});
321442
});

app/core/DeeplinkManager/DeeplinkManager.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,33 @@ import Logger from '../../util/Logger';
77
import { handleDeeplink } from './handlers/legacy/handleDeeplink';
88
import FCMService from '../../util/notifications/services/FCMService';
99
import AppConstants from '../AppConstants';
10+
import { BranchParams } from './types/deepLinkAnalytics.types';
11+
12+
/**
13+
* When Branch resolves a short link (e.g. metamask-alternate.app.link/1WkF6GmE40b),
14+
* the URI path may be link ID, not an in-app route. If the resolved params indicate
15+
* a clicked Branch link with a $deeplink_path, replace the host and path segment
16+
* with link.metamask.io/$deeplink_path while preserving the original query string.
17+
*/
18+
export function rewriteBranchUri(
19+
uri: string | undefined,
20+
params: BranchParams | undefined,
21+
): string | undefined {
22+
try {
23+
if (!uri || !params?.['+clicked_branch_link']) return uri;
24+
const rawPath = params.$deeplink_path;
25+
if (typeof rawPath !== 'string') return uri;
26+
27+
const parsed = new URL(uri);
28+
parsed.host = AppConstants.MM_IO_UNIVERSAL_LINK_HOST;
29+
// Set the pathname to the sanitized $deeplink_path
30+
parsed.pathname = `/${rawPath.replace(/^\//, '')}`;
31+
return parsed.toString();
32+
} catch (error) {
33+
Logger.error(error as Error, `Error rewriting Branch URI: ${uri}`);
34+
return uri;
35+
}
36+
}
1037

1138
export class DeeplinkManager {
1239
// singleton instance
@@ -66,6 +93,17 @@ export class DeeplinkManager {
6693

6794
try {
6895
const latestParams = await branch.getLatestReferringParams();
96+
97+
// Cold start: params may contain a resolved Branch link with $deeplink_path.
98+
const rewritten = rewriteBranchUri(
99+
latestParams?.['~referring_link'] as string | undefined,
100+
latestParams as Record<string, unknown> | undefined,
101+
);
102+
if (rewritten) {
103+
handleDeeplink({ uri: rewritten });
104+
return;
105+
}
106+
69107
const deeplink = latestParams?.['+non_branch_link'] as string;
70108
if (deeplink) {
71109
handleDeeplink({ uri: deeplink });
@@ -117,12 +155,11 @@ export class DeeplinkManager {
117155
const branchError = new Error(error);
118156
Logger.error(branchError, 'Error subscribing to branch.');
119157
}
120-
getBranchDeeplink(opts.uri);
121-
//TODO: that async call in the subscribe doesn't look good to me
122-
branch.getLatestReferringParams().then((val) => {
123-
const deeplink = opts.uri || (val['+non_branch_link'] as string);
124-
handleDeeplink({ uri: deeplink });
125-
});
158+
const rewritten = rewriteBranchUri(
159+
opts.uri,
160+
opts.params as Record<string, unknown> | undefined,
161+
);
162+
getBranchDeeplink(rewritten ?? opts.uri);
126163
});
127164
}
128165
}

app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import Logger from '../../../../util/Logger';
5757

5858
const {
5959
MM_UNIVERSAL_LINK_HOST,
60+
MM_UNIVERSAL_LINK_HOST_ALTERNATE,
6061
MM_IO_UNIVERSAL_LINK_HOST,
6162
MM_IO_UNIVERSAL_LINK_TEST_HOST,
6263
} = AppConstants;
@@ -216,6 +217,7 @@ async function handleUniversalLink({
216217

217218
const isSupportedDomain =
218219
urlObj.hostname === MM_UNIVERSAL_LINK_HOST ||
220+
urlObj.hostname === MM_UNIVERSAL_LINK_HOST_ALTERNATE ||
219221
urlObj.hostname === MM_IO_UNIVERSAL_LINK_HOST ||
220222
urlObj.hostname === MM_IO_UNIVERSAL_LINK_TEST_HOST;
221223

app/core/DeeplinkManager/util/deeplinks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import AppConstants from '../../../AppConstants';
22

33
const {
44
MM_UNIVERSAL_LINK_HOST,
5+
MM_UNIVERSAL_LINK_HOST_ALTERNATE,
56
MM_IO_UNIVERSAL_LINK_HOST,
67
MM_IO_UNIVERSAL_LINK_TEST_HOST,
78
} = AppConstants;
@@ -10,6 +11,7 @@ const METAMASK_HOSTS = [
1011
...new Set(
1112
[
1213
MM_UNIVERSAL_LINK_HOST || 'link.metamask.io',
14+
MM_UNIVERSAL_LINK_HOST_ALTERNATE || 'metamask-alternate.app.link',
1315
MM_IO_UNIVERSAL_LINK_HOST || 'link.metamask.io',
1416
MM_IO_UNIVERSAL_LINK_TEST_HOST || 'link-test.metamask.io',
1517
'metamask.app.link',

0 commit comments

Comments
 (0)