From fda3585ce67ce792615f1545659838aa78cca873 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:49:07 +0000 Subject: [PATCH] chore(runway): cherry-pick feat: show legacy ios login warning prompt cp-7.71.0 (#27875) ## **Description** Add warning prompt for ios <17.4 for google login Supports the fix for: https://github.com/MetaMask/MetaMask-planning/issues/7148 Part 1/ 4 - #27741 Part 2/ 4 - #27848 Part 3/ 4 - #27850 (deferred to 7.72.0) Part 4/ 4 - #27875 ## **Changelog** CHANGELOG entry: Add warning prompt for ios <17.4 for google login ## **Related issues** Fixes: ## **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** ### **Before** ### **After** For < iOS 17.4 https://github.com/user-attachments/assets/f6f3a031-82cc-486d-af5f-e6e1bbc7ed10 For >= iOS 17.4 https://github.com/user-attachments/assets/2cdc0bf3-d59b-4858-be81-baae5e0a4dd2 ## **Pre-merge author checklist** - [x] 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). - [x] I've completed the PR template to the best of my ability - [x] 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. --- > [!NOTE] > **Medium Risk** > Modifies the onboarding social login path by inserting a conditional pre-login warning and new navigation helper, which could affect Google login flow timing/navigation on iOS devices. Changes are localized but touch user authentication entrypoints and analytics tracking. > > **Overview** > Adds an **iOS < 17.4 warning gate** before starting Google OAuth during onboarding (both create and import flows), showing a non-interactable `SuccessErrorSheet` that must be acknowledged before proceeding. > > Introduces `Device.comparePlatformVersionTo()` (using `compare-versions`) and a reusable `navigateToSuccessErrorSheetPromise()` helper to await sheet dismissal, plus a new MetaMetrics event (`WALLET_GOOGLE_IOS_WARNING_VIEWED`) and localized warning copy. > > Updates onboarding tests to mock the new device helper/navigation and to assert the warning sheet + tracking fire before continuing with Google login. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3b43b83cf7c608c88da4b01bfb67603d840d1582. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/Onboarding/index.test.tsx | 211 +++++++++++++++++- app/components/Views/Onboarding/index.tsx | 41 ++++ .../Views/SuccessErrorSheet/utils.test.ts | 113 ++++++++++ .../Views/SuccessErrorSheet/utils.ts | 37 +++ app/core/Analytics/MetaMetrics.events.ts | 4 + app/util/device/index.js | 16 ++ locales/languages/en.json | 8 +- 7 files changed, 420 insertions(+), 10 deletions(-) create mode 100644 app/components/Views/SuccessErrorSheet/utils.test.ts create mode 100644 app/components/Views/SuccessErrorSheet/utils.ts diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 771c308e15b..8f59eef7433 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -71,6 +71,7 @@ import Routes from '../../../constants/navigation/Routes'; import { ONBOARDING, PREVIOUS_SCREEN } from '../../../constants/navigation'; import { strings } from '../../../../locales/i18n'; import { OAuthError, OAuthErrorType } from '../../../core/OAuthService/error'; +import { IconName } from '../../../component-library/components/Icons/Icon'; import { captureException } from '@sentry/react-native'; import Logger from '../../../util/Logger'; import { MIGRATION_ERROR_HAPPENED } from '../../../constants/storage'; @@ -92,9 +93,22 @@ jest.mock('../../../util/test/utils', () => ({ import { fetch as netInfoFetch } from '@react-native-community/netinfo'; const mockNetInfoFetch = netInfoFetch as jest.Mock; +const mockNavigate = jest.fn(); +const mockReplace = jest.fn(); +const mockGoBack = jest.fn(); // Helper to flush all pending promises const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); +const IOS_GOOGLE_WARNING_TITLE = strings('error_sheet.ios_need_update_title'); +const IOS_GOOGLE_WARNING_BUTTON = strings('error_sheet.ios_need_update_button'); + +const getIosGoogleWarningSheetCall = () => + mockNavigate.mock.calls.find( + ([route, params]) => + route === Routes.MODAL.ROOT_MODAL_FLOW && + params?.screen === Routes.SHEET.SUCCESS_ERROR_SHEET && + params?.params?.title === IOS_GOOGLE_WARNING_TITLE, + ); const mockInitialState = { engine: { @@ -127,13 +141,22 @@ const mockInitialStateWithExistingUserAndPassword = { }, }; -jest.mock('../../../util/device', () => ({ - isLargeDevice: jest.fn(), - isIphoneX: jest.fn(), - isAndroid: jest.fn(), - isIos: jest.fn(), - isMediumDevice: jest.fn(), -})); +jest.mock('../../../util/device', () => { + const mockDevice = { + isLargeDevice: jest.fn(), + isIphoneX: jest.fn(), + isAndroid: jest.fn(), + isIos: jest.fn(), + isMediumDevice: jest.fn(), + comparePlatformVersionTo: jest.fn().mockReturnValue(1), + }; + + return { + __esModule: true, + default: mockDevice, + ...mockDevice, + }; +}); // expo library are not supported in jest ( unless using jest-expo as preset ), so we need to mock them jest.mock('../../../core/OAuthService/OAuthLoginHandlers', () => ({ @@ -276,13 +299,12 @@ jest.mock('../../../core/OAuthService/OAuthLoginHandlers/constants', () => ({ }, })); -const mockNavigate = jest.fn(); -const mockReplace = jest.fn(); const mockNav = { navigate: mockNavigate, replace: mockReplace, reset: jest.fn(), setOptions: jest.fn(), + goBack: mockGoBack, dispatch: jest.fn((action) => { if (action.type === 'REPLACE') { mockReplace(action.payload.name, action.payload.params); @@ -956,10 +978,13 @@ describe('Onboarding', () => { beforeEach(() => { mockSeedlessOnboardingEnabled.mockReturnValue(true); (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null); + (Device.isIos as jest.Mock).mockReturnValue(false); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(1); }); afterEach(() => { jest.clearAllMocks(); + mockNavigate.mockReset(); mockSeedlessOnboardingEnabled.mockReset(); }); @@ -1263,6 +1288,174 @@ describe('Onboarding', () => { ); }); + it('shows iOS version warning sheet before Google login on iOS < 17.4', async () => { + Platform.OS = 'ios'; + (Device.isIos as jest.Mock).mockReturnValue(true); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const createWalletButton = getByTestId( + OnboardingSelectorIDs.NEW_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(createWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + await act(async () => { + await googleOAuthFunction(true); + await flushPromises(); + await flushPromises(); + }); + + // Verify the warning sheet was shown with the iOS not-supported message. + const warningSheetCall = getIosGoogleWarningSheetCall(); + + expect(warningSheetCall).toEqual([ + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'error', + icon: IconName.Warning, + isInteractable: false, + title: IOS_GOOGLE_WARNING_TITLE, + description: expect.anything(), + primaryButtonLabel: IOS_GOOGLE_WARNING_BUTTON, + onPrimaryButtonPress: expect.any(Function), + closeOnPrimaryButtonPress: true, + }), + }), + ]); + expect(warningSheetCall?.[1].params.onPrimaryButtonPress).toEqual( + expect.any(Function), + ); + expect(Device.comparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + + await act(async () => { + await warningSheetCall?.[1].params.onPrimaryButtonPress?.(); + await flushPromises(); + await flushPromises(); + }); + + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wallet Google Ios Warning Viewed', + properties: expect.objectContaining({ + account_type: AccountType.MetamaskGoogle, + }), + }), + ); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + false, + ); + }); + + it('shows iOS version warning for Google login on iOS < 17.4 during import wallet flow', async () => { + Platform.OS = 'ios'; + (Device.isIos as jest.Mock).mockReturnValue(true); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const importWalletButton = getByTestId( + OnboardingSelectorIDs.EXISTING_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(importWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + await act(async () => { + await googleOAuthFunction(false); + await flushPromises(); + await flushPromises(); + }); + + const warningSheetCall = getIosGoogleWarningSheetCall(); + + expect(warningSheetCall).toBeDefined(); + expect(warningSheetCall?.[1].params).toEqual( + expect.objectContaining({ + type: 'error', + icon: IconName.Warning, + title: IOS_GOOGLE_WARNING_TITLE, + description: expect.anything(), + primaryButtonLabel: IOS_GOOGLE_WARNING_BUTTON, + onPrimaryButtonPress: expect.any(Function), + closeOnPrimaryButtonPress: true, + isInteractable: false, + }), + ); + expect(warningSheetCall?.[1].params.onPrimaryButtonPress).toEqual( + expect.any(Function), + ); + expect(Device.comparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + + await act(async () => { + await warningSheetCall?.[1].params.onPrimaryButtonPress?.(); + await flushPromises(); + await flushPromises(); + }); + + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wallet Google Ios Warning Viewed', + properties: expect.objectContaining({ + account_type: AccountType.ImportedGoogle, + }), + }), + ); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + true, + ); + }); + it('navigates to AccountAlreadyExists for existing user in create wallet flow', async () => { mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); mockOAuthService.handleOAuthLogin.mockResolvedValue({ diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index f2d715f0a34..3c105f2a087 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -110,6 +110,11 @@ import { } from '@metamask/design-system-twrnc-preset'; import { getBuildNumber, getVersion } from 'react-native-device-info'; +import { navigateToSuccessErrorSheetPromise } from '../SuccessErrorSheet/utils'; +import { + IconColor, + IconName, +} from '../../../component-library/components/Icons/Icon'; interface OnboardingState { warningModalVisible: boolean; loading: boolean; @@ -770,6 +775,41 @@ const Onboarding = () => { }); const action = async () => { + // prompt for ios google login not supported below iOS 17.4 + if ( + provider === AuthConnection.Google && + Device.isIos() && + Device.comparePlatformVersionTo('17.4') < 0 + ) { + const description = () => ( + <> + + {strings(`error_sheet.ios_need_update_description`)} + + {strings(`error_sheet.ios_need_update_description_version`)} + + {strings(`error_sheet.ios_need_update_description_end`)} + + + {strings(`error_sheet.ios_need_update_description2`)} + + + ); + + await navigateToSuccessErrorSheetPromise(navigation, { + type: 'error', + icon: IconName.Warning, + iconColor: IconColor.Warning, + title: strings(`error_sheet.ios_need_update_title`), + description: description(), + primaryButtonLabel: strings(`error_sheet.ios_need_update_button`), + closeOnPrimaryButtonPress: true, + isInteractable: false, + }); + track(MetaMetricsEvents.WALLET_GOOGLE_IOS_WARNING_VIEWED, { + account_type: accountType, + }); + } setLoading(); const loginHandler = createLoginHandler(Platform.OS, provider); try { @@ -799,6 +839,7 @@ const Onboarding = () => { handleExistingUser(action); }, [ + tw, navigation, metrics, track, diff --git a/app/components/Views/SuccessErrorSheet/utils.test.ts b/app/components/Views/SuccessErrorSheet/utils.test.ts new file mode 100644 index 00000000000..a0b29825c74 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.test.ts @@ -0,0 +1,113 @@ +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import Routes from '../../../constants/navigation/Routes'; +import { + navigateToSuccessErrorSheet, + navigateToSuccessErrorSheetPromise, +} from './utils'; + +const mockNavigate = jest.fn(); +const mockNavigation = { + navigate: mockNavigate, +} as unknown as NavigationProp; + +const baseParams = { + type: 'error' as const, + title: 'Error Title', + description: 'Error description', +}; + +describe('navigateToSuccessErrorSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards params to the success error sheet route', () => { + const onClose = jest.fn(); + const onPrimaryButtonPress = jest.fn(); + const params = { + ...baseParams, + type: 'success' as const, + onClose, + onPrimaryButtonPress, + descriptionAlign: 'center' as const, + primaryButtonLabel: 'OK', + }; + + navigateToSuccessErrorSheet(mockNavigation, params); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'success', + onClose, + onPrimaryButtonPress, + descriptionAlign: 'center', + primaryButtonLabel: 'OK', + }), + }); + }); +}); + +describe('navigateToSuccessErrorSheetPromise', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves and invokes the original onPrimaryButtonPress callback', async () => { + const onPrimaryButtonPress = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onPrimaryButtonPress(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onPrimaryButtonPress, + }), + ).resolves.toBeUndefined(); + + expect(onPrimaryButtonPress).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + }), + ); + }); + + it('resolves and invokes the original onSecondaryButtonPress callback', async () => { + const onSecondaryButtonPress = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onSecondaryButtonPress(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onSecondaryButtonPress, + }), + ).resolves.toBeUndefined(); + + expect(onSecondaryButtonPress).toHaveBeenCalledTimes(1); + }); + + it('resolves and invokes the original onClose callback', async () => { + const onClose = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onClose(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onClose, + }), + ).resolves.toBeUndefined(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/SuccessErrorSheet/utils.ts b/app/components/Views/SuccessErrorSheet/utils.ts new file mode 100644 index 00000000000..e9c655fa924 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.ts @@ -0,0 +1,37 @@ +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import { SuccessErrorSheetParams } from './interface'; +import Routes from '../../../constants/navigation/Routes'; + +export const navigateToSuccessErrorSheet = ( + navigation: NavigationProp, + params: SuccessErrorSheetParams, +) => { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: { + ...params, + }, + }); +}; + +export const navigateToSuccessErrorSheetPromise = async ( + navigation: NavigationProp, + params: SuccessErrorSheetParams, +) => + new Promise((resolve) => { + navigateToSuccessErrorSheet(navigation, { + ...params, + onPrimaryButtonPress: () => { + params.onPrimaryButtonPress?.(); + resolve(); + }, + onSecondaryButtonPress: () => { + params.onSecondaryButtonPress?.(); + resolve(); + }, + onClose: () => { + params.onClose?.(); + resolve(); + }, + }); + }); diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 3e0d1d23446..745311015fd 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -147,6 +147,7 @@ enum EVENT_NAME { WALLET_CREATION_ATTEMPTED = 'Wallet Creation Attempted', WALLET_CREATED = 'Wallet Created', WALLET_SETUP_FAILURE = 'Wallet Setup Failure', + WALLET_GOOGLE_IOS_WARNING_VIEWED = 'Wallet Google Ios Warning Viewed', WALLET_CREATION_ERROR_SCREEN_VIEWED = 'Wallet Creation Error Screen Viewed', WALLET_CREATION_ERROR_RETRY_CLICKED = 'Wallet Creation Error Retry Clicked', WALLET_CREATION_ERROR_REPORT_SENT = 'Wallet Creation Error Report Sent', @@ -859,6 +860,9 @@ const events = { WALLET_CREATION_ATTEMPTED: generateOpt(EVENT_NAME.WALLET_CREATION_ATTEMPTED), WALLET_CREATED: generateOpt(EVENT_NAME.WALLET_CREATED), WALLET_SETUP_FAILURE: generateOpt(EVENT_NAME.WALLET_SETUP_FAILURE), + WALLET_GOOGLE_IOS_WARNING_VIEWED: generateOpt( + EVENT_NAME.WALLET_GOOGLE_IOS_WARNING_VIEWED, + ), WALLET_CREATION_ERROR_SCREEN_VIEWED: generateOpt( EVENT_NAME.WALLET_CREATION_ERROR_SCREEN_VIEWED, ), diff --git a/app/util/device/index.js b/app/util/device/index.js index df04b68a24d..c3329ec2161 100644 --- a/app/util/device/index.js +++ b/app/util/device/index.js @@ -2,6 +2,7 @@ import { Dimensions, Platform } from 'react-native'; import { hasNotch, getApiLevel } from 'react-native-device-info'; +import compareVersions from 'compare-versions'; export default class Device { static getDeviceWidth() { @@ -12,6 +13,21 @@ export default class Device { return Dimensions.get('window').height; } + /** + * Compares this device's React Native {@link Platform.Version} to `referenceVersion` + * using the shared `compare-versions` package after normalizing both values to strings. + * + * @param {string|number} referenceVersion - Version to compare against (e.g. `"17.4"`). + * @returns {number} `1` if current > reference, `-1` if current < reference, `0` if equal. + * @remarks On iOS, `Platform.Version` is usually a string (`"17.3.1"`). On Android it is + * typically the API level as a number (for example `34`). Both values are coerced to strings + * before comparison so the helper remains safe across platforms while preserving component-wise + * numeric comparison semantics. + */ + static comparePlatformVersionTo(referenceVersion) { + return compareVersions(String(Platform.Version), String(referenceVersion)); + } + static isIos() { return Platform.OS === 'ios'; } diff --git a/locales/languages/en.json b/locales/languages/en.json index a30519785e2..c4c7879604d 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6751,7 +6751,13 @@ "oauth_error_button": "Try again", "no_internet_connection_title": "Unable to connect", "no_internet_connection_description": "Your internet connection is unstable. Check your connection and try again.", - "no_internet_connection_button": "Try again" + "no_internet_connection_button": "Try again", + "ios_need_update_title": "iOS update required", + "ios_need_update_description": "MetaMask Google Sign-In will soon require ", + "ios_need_update_description_version": "iOS 17.4 or later", + "ios_need_update_description_end": ". You can continue using Google Sign-In on this device for now, but it will no longer be supported in an upcoming update.", + "ios_need_update_description2": "You can still access your wallet using the same Google account on a supported device or the MetaMask extension. We strongly recommend backing up your Secret Recovery Phrase to ensure uninterrupted access.", + "ios_need_update_button": "Continue" }, "password_hint": { "title": "Password hint",