Skip to content

Commit 994a0de

Browse files
authored
fix(rewards): share modal frozen on Android (#32183)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** On Android, tapping **Refer a friend** on the Rewards referral screen opened the native share sheet, but dismissing it could leave the app unresponsive or crash entirely. **Root cause:** `react-native-share`'s `Share.open()` uses `startActivityForResult` on Android. When the share sheet closes, every registered `ActivityEventListener` receives the result — including `@metamask/react-native-payments`. That module forwards unknown request codes to a deprecated `onActivityResult` overload that expects a non-null `Intent`, which throws: `NullPointerException: Parameter specified as non-null is null: method BaseActivityEventListener.onActivityResult, parameter data` **Fix:** Switch the referral share button to React Native's built-in `Share.share()` (same pattern as `TokenDetails` and Predict share). RN's share module uses `startActivity()` only, so it does not trigger the payments module callback. The share call is also deferred with `InteractionManager.runAfterInteractions()` so the button press gesture completes before the native sheet opens. - **Android:** subject + referral URL combined in `message` (Android ignores the `url` field) - **iOS:** separate `message` and `url` for rich link preview - Errors are caught and logged via `Logger` No dependency or native package patches required. ## **Changelog** CHANGELOG entry: Fixed an Android crash and frozen UI after dismissing the share sheet on the Rewards referral screen. ## **Related issues** Refs: Internal QA — Android Rewards referral share sheet crash/freeze ## **Manual testing steps** ```gherkin Feature: Rewards referral share on Android Scenario: user shares a referral link without crashing Given the user is subscribed to Rewards and on the Referrals screen And a referral code has loaded When the user taps "Refer a friend" And the Android share sheet appears And the user dismisses the share sheet without sharing Then the app remains responsive (back navigation, scroll, and other taps work) And the app does not crash Scenario: user completes a share action Given the user is on the Referrals screen with a referral code When the user taps "Refer a friend" And selects a share target (e.g. Messages or Copy) Then the share completes without crashing ``` Also verified on iOS that the share sheet opens with message + URL and dismisses normally. ## **Screenshots/Recordings** N/A — crash/freeze repro is behavioral; manual QA on Android device/emulator recommended. ### **Before** App could freeze or crash after dismissing the share sheet. ### **After** Share sheet dismisses cleanly; app stays interactive. ## **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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **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. ## Test plan - [x] `yarn jest RewardsReferralView.test.tsx --coverage=false` - [ ] Android: Referrals → Refer a friend → dismiss share sheet → confirm no crash/freeze - [ ] Android: Referrals → Refer a friend → share to an app → confirm no crash - [ ] iOS: Referrals → Refer a friend → dismiss and complete share → confirm expected payloads <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Scoped Rewards referral UI change with no dependency or native patches; it reduces Android activity-result side effects rather than touching auth or payments code. > > **Overview** > Fixes Android freeze/crash when users dismiss the Rewards **Refer a friend** share sheet by stopping use of `react-native-share`'s `Share.open()` (which routes through `startActivityForResult` and can trip `@metamask/react-native-payments` with a null intent). > > Referral sharing now uses React Native's built-in **`Share.share()`**, deferred with **`InteractionManager.runAfterInteractions()`** so the tap gesture finishes before the sheet opens. **Android** gets subject + URL in `message`; **iOS** keeps separate `message` and `url`. Failures are logged via **`Logger`**. > > Tests mock `Share.share` and `InteractionManager`, assert platform-specific payloads, and drop the `react-native-share` mock. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 5232d6e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c83f25f commit 994a0de

2 files changed

Lines changed: 61 additions & 16 deletions

File tree

app/components/UI/Rewards/Views/RewardsReferralView.test.tsx

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import { render, fireEvent, waitFor } from '@testing-library/react-native';
3+
import { InteractionManager, Platform, Share } from 'react-native';
34
import { useSelector } from 'react-redux';
45
import RewardsReferralView from './RewardsReferralView';
56

@@ -12,8 +13,8 @@ jest.mock('react-redux', () => ({
1213
useSelector: jest.fn(),
1314
}));
1415

15-
jest.mock('react-native-share', () => ({
16-
open: jest.fn().mockResolvedValue(undefined),
16+
jest.mock('../../../../util/Logger', () => ({
17+
log: jest.fn(),
1718
}));
1819

1920
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;
@@ -84,11 +85,24 @@ jest.mock('../components/ReferralDetails/ReferralDetails', () => {
8485
};
8586
});
8687

87-
import Share from 'react-native-share';
88-
8988
describe('RewardsReferralView', () => {
9089
beforeEach(() => {
9190
jest.clearAllMocks();
91+
jest
92+
.spyOn(Share, 'share')
93+
.mockResolvedValue({ action: Share.sharedAction });
94+
jest
95+
.spyOn(InteractionManager, 'runAfterInteractions')
96+
.mockImplementation((task) => {
97+
if (task && typeof task === 'function') {
98+
task();
99+
}
100+
return {
101+
then: jest.fn(),
102+
done: jest.fn(),
103+
cancel: jest.fn(),
104+
};
105+
});
92106
jest.mocked(useAnalytics).mockReturnValue(
93107
createMockUseAnalyticsHook({
94108
trackEvent: mockTrackEvent,
@@ -104,6 +118,10 @@ describe('RewardsReferralView', () => {
104118
});
105119
});
106120

121+
afterEach(() => {
122+
jest.restoreAllMocks();
123+
});
124+
107125
describe('rendering', () => {
108126
it('renders without crashing', () => {
109127
expect(() => render(<RewardsReferralView />)).not.toThrow();
@@ -207,17 +225,31 @@ describe('RewardsReferralView', () => {
207225
expect(button).toBeDisabled();
208226
});
209227

210-
it('calls Share.open when the share button is pressed', async () => {
228+
it('calls Share.share when the share button is pressed on Android', async () => {
229+
Platform.OS = 'android';
211230
const { getByTestId } = render(<RewardsReferralView />);
212231

213232
fireEvent.press(getByTestId('referral-share-button'));
214233

215234
await waitFor(() => {
216-
expect(Share.open).toHaveBeenCalledWith(
217-
expect.objectContaining({
218-
url: 'https://link.metamask.io/rewards?referral=TESTCODE',
219-
}),
220-
);
235+
expect(Share.share).toHaveBeenCalledWith({
236+
message:
237+
'Join MetaMask Rewards\nhttps://link.metamask.io/rewards?referral=TESTCODE',
238+
});
239+
});
240+
});
241+
242+
it('calls Share.share when the share button is pressed on iOS', async () => {
243+
Platform.OS = 'ios';
244+
const { getByTestId } = render(<RewardsReferralView />);
245+
246+
fireEvent.press(getByTestId('referral-share-button'));
247+
248+
await waitFor(() => {
249+
expect(Share.share).toHaveBeenCalledWith({
250+
message: 'Join MetaMask Rewards',
251+
url: 'https://link.metamask.io/rewards?referral=TESTCODE',
252+
});
221253
});
222254
});
223255
});

app/components/UI/Rewards/Views/RewardsReferralView.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import React, { useEffect, useRef } from 'react';
22
import { useNavigation } from '@react-navigation/native';
33
import { useTailwind } from '@metamask/design-system-twrnc-preset';
44
import { SafeAreaView } from 'react-native-safe-area-context';
5-
import { ScrollView } from 'react-native';
5+
import { InteractionManager, Platform, ScrollView, Share } from 'react-native';
66
import { useSelector } from 'react-redux';
7-
import Share from 'react-native-share';
87
import {
98
Box,
109
Button,
@@ -23,6 +22,7 @@ import {
2322
} from '../../../../reducers/rewards/selectors';
2423
import { buildReferralUrl, RewardsMetricsButtons } from '../utils';
2524
import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView';
25+
import Logger from '../../../../util/Logger';
2626

2727
const ReferralRewardsView: React.FC = () => {
2828
const tw = useTailwind();
@@ -44,7 +44,7 @@ const ReferralRewardsView: React.FC = () => {
4444
}
4545
}, [trackEvent, createEventBuilder]);
4646

47-
const handleShareLink = async () => {
47+
const handleShareLink = () => {
4848
if (!referralCode) return;
4949
const link = buildReferralUrl(referralCode);
5050
trackEvent(
@@ -54,9 +54,22 @@ const ReferralRewardsView: React.FC = () => {
5454
})
5555
.build(),
5656
);
57-
await Share.open({
58-
message: strings('rewards.referral.actions.share_referral_subject'),
59-
url: link,
57+
// Use RN's built-in Share API instead of react-native-share. On Android,
58+
// react-native-share uses startActivityForResult, which notifies every
59+
// ActivityEventListener (including ReactNativePayments) and can crash when
60+
// the sheet is dismissed with a null intent.
61+
InteractionManager.runAfterInteractions(() => {
62+
const subject = strings(
63+
'rewards.referral.actions.share_referral_subject',
64+
);
65+
const shareContent =
66+
Platform.OS === 'ios'
67+
? { message: subject, url: link }
68+
: { message: `${subject}\n${link}` };
69+
70+
Share.share(shareContent).catch((error) => {
71+
Logger.log('Error while trying to share referral link', error);
72+
});
6073
});
6174
};
6275

0 commit comments

Comments
 (0)