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",