diff --git a/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch b/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch new file mode 100644 index 00000000000..94024b5585b --- /dev/null +++ b/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch @@ -0,0 +1,50 @@ +diff --git a/ios/WebAuthSession.swift b/ios/WebAuthSession.swift +index 0d8101b01d7c6cd803acf6a359ceaa026993bdd0..c1beeabd962e561bf48392d58c084272247a95cc 100644 +--- a/ios/WebAuthSession.swift ++++ b/ios/WebAuthSession.swift +@@ -20,17 +20,34 @@ final internal class WebAuthSession { + private var presentationContextProvider = PresentationContextProvider() + + init(authUrl: URL, redirectUrl: URL?, options: AuthSessionOptions) { +- self.authSession = ASWebAuthenticationSession( +- url: authUrl, +- callbackURLScheme: redirectUrl?.scheme, +- completionHandler: { callbackUrl, error in +- self.finish(with: [ +- "type": callbackUrl != nil ? "success" : "cancel", +- "url": callbackUrl?.absoluteString, +- "error": error?.localizedDescription +- ]) +- } +- ) ++ let completionHandler: (URL?, Error?) -> Void = { callbackUrl, error in ++ self.finish(with: [ ++ "type": callbackUrl != nil ? "success" : "cancel", ++ "url": callbackUrl?.absoluteString, ++ "error": error?.localizedDescription ++ ]) ++ } ++ ++ // iOS 17.4+/macOS 14.4+ supports HTTPS callbacks with host/path matching ++ if #available(iOS 17.4, macOS 14.4, *), ++ let redirectUrl, ++ redirectUrl.scheme?.lowercased() == "https", ++ let host = redirectUrl.host(percentEncoded: false), ++ !host.isEmpty { ++ let rawPath = redirectUrl.path ++ let path = (rawPath.isEmpty || rawPath == "/") ? "" : rawPath ++ self.authSession = ASWebAuthenticationSession( ++ url: authUrl, ++ callback: .https(host: host, path: path), ++ completionHandler: completionHandler ++ ) ++ } else { ++ self.authSession = ASWebAuthenticationSession( ++ url: authUrl, ++ callbackURLScheme: redirectUrl?.scheme, ++ completionHandler: completionHandler ++ ) ++ } + self.authSession?.prefersEphemeralWebBrowserSession = options.preferEphemeralSession + } + diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 4608707035d..54fac48da3b 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'; @@ -132,6 +133,7 @@ jest.mock('../../../util/device', () => ({ isAndroid: jest.fn(), isIos: jest.fn(), isMediumDevice: jest.fn(), + comparePlatformVersionTo: jest.fn().mockReturnValue(1), })); // expo library are not supported in jest ( unless using jest-expo as preset ), so we need to mock them @@ -269,19 +271,24 @@ jest.mock( ); const mockSeedlessOnboardingEnabled = jest.fn(); +const mockShouldUseLegacyIosGoogleConfig = jest.fn(); jest.mock('../../../core/OAuthService/OAuthLoginHandlers/constants', () => ({ get SEEDLESS_ONBOARDING_ENABLED() { return mockSeedlessOnboardingEnabled(); }, + shouldUseLegacyIosGoogleConfig: (...args: unknown[]) => + mockShouldUseLegacyIosGoogleConfig(...args), })); const mockNavigate = jest.fn(); const mockReplace = jest.fn(); +const mockGoBack = 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); @@ -954,12 +961,17 @@ describe('Onboarding', () => { beforeEach(() => { mockSeedlessOnboardingEnabled.mockReturnValue(true); + mockShouldUseLegacyIosGoogleConfig.mockReturnValue(false); (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null); + // Default to iOS >= 17.4 so the proactive version check is skipped + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(1); }); afterEach(() => { jest.clearAllMocks(); + mockNavigate.mockReset(); mockSeedlessOnboardingEnabled.mockReset(); + mockShouldUseLegacyIosGoogleConfig.mockReset(); }); it('calls Google OAuth login for create wallet flow on iOS and navigates to SocialLoginSuccessNewUser', async () => { @@ -1262,6 +1274,254 @@ describe('Onboarding', () => { ); }); + it('shows iOS version warning sheet before Google login on iOS < 17.4', async () => { + Platform.OS = 'ios'; + // Simulate iOS version below 17.4 + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + mockShouldUseLegacyIosGoogleConfig.mockReturnValue(false); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + // Simulate user pressing the Continue button — the only dismissal path since + // isInteractable: false prevents closing by tapping outside the sheet. + mockNavigate.mockImplementation( + (route: string, params: Record) => { + const screenParams = params?.params as Record; + if ( + route === Routes.MODAL.ROOT_MODAL_FLOW && + params?.screen === Routes.SHEET.SUCCESS_ERROR_SHEET && + typeof screenParams?.onPrimaryButtonPress === 'function' + ) { + (screenParams.onPrimaryButtonPress as () => void)(); + } + }, + ); + + 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 + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'error', + icon: IconName.Warning, + isInteractable: false, + title: strings('error_sheet.ios_google_login_not_supported_title'), + description: strings( + 'error_sheet.ios_google_login_not_supported_description', + ), + descriptionAlign: 'center', + primaryButtonLabel: strings( + 'error_sheet.ios_google_login_not_supported_button', + ), + }), + }), + ); + + // Verify the login flow continued after the sheet was dismissed + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + false, + ); + }); + + it('does not show iOS version warning for Google login on iOS < 17.4 when the legacy config flag is enabled', async () => { + Platform.OS = 'ios'; + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + mockShouldUseLegacyIosGoogleConfig.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(); + }); + + const warningSheetCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.SUCCESS_ERROR_SHEET && + call[1]?.params?.title === + strings('error_sheet.ios_google_login_not_supported_title'), + ); + + expect(warningSheetCall).toBeUndefined(); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + false, + ); + }); + + it('does not show iOS version warning for Google login on iOS 17.4 or later when the legacy config flag is disabled', async () => { + Platform.OS = 'ios'; + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(0); + mockShouldUseLegacyIosGoogleConfig.mockReturnValue(false); + 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(); + }); + + const warningSheetCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.SUCCESS_ERROR_SHEET && + call[1]?.params?.title === + strings('error_sheet.ios_google_login_not_supported_title'), + ); + + expect(warningSheetCall).toBeUndefined(); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + false, + ); + }); + + it('does not show iOS version warning for Apple login on iOS < 17.4', async () => { + Platform.OS = 'ios'; + // Simulate iOS version below 17.4 + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + mockCreateLoginHandler.mockReturnValue('mockAppleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@icloud.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 appleOAuthFunction = navCall[1].params.onPressContinueWithApple; + + await act(async () => { + await appleOAuthFunction(true); + await flushPromises(); + }); + + // The Google-specific iOS warning sheet should NOT be shown for Apple login + const warningSheetCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.SUCCESS_ERROR_SHEET && + call[1]?.params?.title === + strings('error_sheet.ios_google_login_not_supported_title'), + ); + expect(warningSheetCall).toBeUndefined(); + + // Apple login should proceed normally + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'apple'); + }); + 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 353227bd74e..dd49994890c 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -80,7 +80,10 @@ import { ITrackingEvent, } from '../../../core/Analytics/MetaMetrics.types'; import { JsonMap } from '@segment/analytics-react-native'; -import { SEEDLESS_ONBOARDING_ENABLED } from '../../../core/OAuthService/OAuthLoginHandlers/constants'; +import { + SEEDLESS_ONBOARDING_ENABLED, + shouldUseLegacyIosGoogleConfig, +} from '../../../core/OAuthService/OAuthLoginHandlers/constants'; import OAuthLoginService from '../../../core/OAuthService/OAuthService'; import { OAuthError, OAuthErrorType } from '../../../core/OAuthService/error'; import { createLoginHandler } from '../../../core/OAuthService/OAuthLoginHandlers'; @@ -110,6 +113,8 @@ import { } from '@metamask/design-system-twrnc-preset'; import { getBuildNumber, getVersion } from 'react-native-device-info'; +import { navigateToSuccessErrorSheetPromise } from '../SuccessErrorSheet/utils'; +import { IconName } from '../../../component-library/components/Icons/Icon'; interface OnboardingState { warningModalVisible: boolean; loading: boolean; @@ -759,6 +764,30 @@ const Onboarding = () => { }); const action = async () => { + // prompt for ios google login not supported below iOS 17.4 + if ( + !shouldUseLegacyIosGoogleConfig() && + Platform.OS === 'ios' && + provider === AuthConnection.Google && + Device.comparePlatformVersionTo('17.4') < 0 + ) { + await navigateToSuccessErrorSheetPromise(navigation, { + type: 'error', + icon: IconName.Warning, + title: strings(`error_sheet.ios_google_login_not_supported_title`), + description: strings( + `error_sheet.ios_google_login_not_supported_description`, + ), + descriptionAlign: 'center', + primaryButtonLabel: strings( + `error_sheet.ios_google_login_not_supported_button`, + ), + onPrimaryButtonPress: () => { + navigation.goBack(); + }, + isInteractable: false, + }); + } setLoading(); const loginHandler = createLoginHandler(Platform.OS, provider); try { diff --git a/app/components/Views/SuccessErrorSheet/utils.test.ts b/app/components/Views/SuccessErrorSheet/utils.test.ts new file mode 100644 index 00000000000..60054007fa0 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.test.ts @@ -0,0 +1,181 @@ +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('calls navigation.navigate with ROOT_MODAL_FLOW route and SUCCESS_ERROR_SHEET screen', () => { + navigateToSuccessErrorSheet(mockNavigation, baseParams); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: baseParams, + }); + }); + + it('passes all params including optional callbacks to navigation', () => { + 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).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + params: expect.objectContaining({ + type: 'success', + onClose, + onPrimaryButtonPress, + descriptionAlign: 'center', + primaryButtonLabel: 'OK', + }), + }), + ); + }); +}); + +describe('navigateToSuccessErrorSheetPromise', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls navigation.navigate with SUCCESS_ERROR_SHEET screen', async () => { + mockNavigate.mockImplementation((_route, params) => { + params.params.onClose(); + }); + + await navigateToSuccessErrorSheetPromise(mockNavigation, baseParams); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + }), + ); + }); + + it('resolves with true when onPrimaryButtonPress is called', async () => { + mockNavigate.mockImplementation((_route, params) => { + params.params.onPrimaryButtonPress(); + }); + + const result = await navigateToSuccessErrorSheetPromise( + mockNavigation, + baseParams, + ); + + expect(result).toBe(true); + }); + + it('resolves with false when onSecondaryButtonPress is called', async () => { + mockNavigate.mockImplementation((_route, params) => { + params.params.onSecondaryButtonPress(); + }); + + const result = await navigateToSuccessErrorSheetPromise( + mockNavigation, + baseParams, + ); + + expect(result).toBe(false); + }); + + it('resolves with false when onClose is called', async () => { + mockNavigate.mockImplementation((_route, params) => { + params.params.onClose(); + }); + + const result = await navigateToSuccessErrorSheetPromise( + mockNavigation, + baseParams, + ); + + expect(result).toBe(false); + }); + + it('invokes the original onPrimaryButtonPress callback when provided', async () => { + const originalCallback = jest.fn(); + mockNavigate.mockImplementation((_route, params) => { + params.params.onPrimaryButtonPress(); + }); + + await navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onPrimaryButtonPress: originalCallback, + }); + + expect(originalCallback).toHaveBeenCalledTimes(1); + }); + + it('invokes the original onSecondaryButtonPress callback when provided', async () => { + const originalCallback = jest.fn(); + mockNavigate.mockImplementation((_route, params) => { + params.params.onSecondaryButtonPress(); + }); + + await navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onSecondaryButtonPress: originalCallback, + }); + + expect(originalCallback).toHaveBeenCalledTimes(1); + }); + + it('invokes the original onClose callback when provided', async () => { + const originalCallback = jest.fn(); + mockNavigate.mockImplementation((_route, params) => { + params.params.onClose(); + }); + + await navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onClose: originalCallback, + }); + + expect(originalCallback).toHaveBeenCalledTimes(1); + }); + + it('does not invoke other callbacks when onPrimaryButtonPress resolves', async () => { + const onSecondaryButtonPress = jest.fn(); + const onClose = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onPrimaryButtonPress(); + }); + + await navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onSecondaryButtonPress, + onClose, + }); + + expect(onSecondaryButtonPress).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/SuccessErrorSheet/utils.ts b/app/components/Views/SuccessErrorSheet/utils.ts new file mode 100644 index 00000000000..0e1ecd145eb --- /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(true); + }, + onSecondaryButtonPress: () => { + params.onSecondaryButtonPress?.(); + resolve(false); + }, + onClose: () => { + params.onClose?.(); + resolve(false); + }, + }); + }); diff --git a/app/constants/featureFlags.ts b/app/constants/featureFlags.ts index c443b3f9fd6..a439f406657 100644 --- a/app/constants/featureFlags.ts +++ b/app/constants/featureFlags.ts @@ -15,6 +15,7 @@ export enum FeatureFlagNames { tokenDetailsV2Buttons = 'tokenDetailsV2Buttons', tokenDetailsV2ButtonLayout = 'tokenDetailsV2ButtonLayout', complianceEnabled = 'complianceEnabled', + legacyIosGoogleConfigEnabled = 'legacyIosGoogleConfigEnabled', } export const DEFAULT_FEATURE_FLAG_VALUES: Partial< diff --git a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/googleFallback.test.ts b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/googleFallback.test.ts index 1dd74e4a936..dc5ad2e981b 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/googleFallback.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/googleFallback.test.ts @@ -12,6 +12,9 @@ jest.mock('expo-auth-session', () => ({ CodeChallengeMethod: { S256: 'S256', }, + Prompt: { + SelectAccount: 'select_account', + }, })); const mockPromptAsync = jest.fn(); diff --git a/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts b/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts index c07ba95d636..e70fba87b4a 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts @@ -3,14 +3,49 @@ import { AppRedirectUri, web3AuthNetwork, AuthServerUrl, - IosGID, - IosGoogleRedirectUri, AndroidGoogleWebGID, AppleWebClientId, AppleServerRedirectUri, AuthConnectionConfig, + getIosGoogleConfig, + shouldUseLegacyIosGoogleConfig, } from './constants'; +const mockDeviceIsIos = jest.fn(); +const mockComparePlatformVersionTo = jest.fn(); +const mockSelectLegacyIosGoogleConfigEnabled = jest.fn(); +const mockGetState = jest.fn(); + +jest.mock('../../../util/device', () => ({ + __esModule: true, + default: { + isIos: (...args: unknown[]) => mockDeviceIsIos(...args), + isAndroid: jest.fn().mockReturnValue(false), + comparePlatformVersionTo: (...args: unknown[]) => + mockComparePlatformVersionTo(...args), + }, +})); + +jest.mock('../../redux', () => ({ + __esModule: true, + default: { + get store() { + return { + getState: (...args: unknown[]) => mockGetState(...args), + }; + }, + }, +})); + +jest.mock( + '../../../selectors/featureFlagController/legacyIosGoogleConfig', + () => ({ + DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED: true, + selectLegacyIosGoogleConfigEnabled: (...args: unknown[]) => + mockSelectLegacyIosGoogleConfigEnabled(...args), + }), +); + const mockAppRedirectUri = 'metamask://oauth-redirect'; describe('OAuth Constants', () => { describe('AppRedirectUri', () => { @@ -30,11 +65,6 @@ describe('OAuth Constants', () => { expect(AuthServerUrl).toBe(CURRENT_OAUTH_CONFIG.AUTH_SERVER_URL); }); - it('should have IOS configuration from jest config', () => { - expect(IosGID).toBe('iosGoogleClientId'); - expect(IosGoogleRedirectUri).toBe('iosGoogleRedirectUri'); - }); - it('should have Android configuration from jest config', () => { expect(AndroidGoogleWebGID).toBe('androidGoogleWebClientId'); expect(AppleWebClientId).toBe('AppleClientId'); @@ -51,8 +81,6 @@ describe('OAuth Constants', () => { it('should have all required constants defined and non-empty', () => { expect(web3AuthNetwork).toBeTruthy(); expect(AuthServerUrl).toBeTruthy(); - expect(IosGID).toBeTruthy(); - expect(IosGoogleRedirectUri).toBeTruthy(); expect(AndroidGoogleWebGID).toBeTruthy(); expect(AppleWebClientId).toBeTruthy(); expect(AuthConnectionConfig).toBeTruthy(); @@ -67,8 +95,6 @@ describe('Error handling with missing environment variables', () => { const requiredVars = { WEB3AUTH_NETWORK: '', AUTH_SERVER_URL: '', - IOS_GOOGLE_CLIENT_ID: 'test-ios-google-client-id', - IOS_GOOGLE_REDIRECT_URI: 'https://test-ios-redirect.example.com', IOS_APPLE_CLIENT_ID: 'test-ios-apple-client-id', ANDROID_WEB_GOOGLE_CLIENT_ID: 'test-android-google-client-id', ANDROID_WEB_APPLE_CLIENT_ID: 'test-android-apple-client-id', @@ -98,3 +124,125 @@ describe('Error handling with missing environment variables', () => { expect(() => validateWithMissingVars()).toThrow(/AUTH_SERVER_URL/); }); }); + +describe('getIosGoogleConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDeviceIsIos.mockReturnValue(false); + mockComparePlatformVersionTo.mockReturnValue(0); + mockGetState.mockReturnValue({}); + mockSelectLegacyIosGoogleConfigEnabled.mockReturnValue(true); + }); + + it('returns iOS-specific config when the legacy flag is enabled on iOS < 17.4', () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(-1); + mockSelectLegacyIosGoogleConfigEnabled.mockReturnValue(true); + + const config = getIosGoogleConfig(); + + expect(config).toEqual({ + clientId: 'iosGoogleClientId', + redirectUri: 'iosGoogleRedirectUri', + }); + }); + + it('returns iOS-specific config when the legacy flag is enabled on iOS 17.4 or later', () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(0); + mockSelectLegacyIosGoogleConfigEnabled.mockReturnValue(true); + + const config = getIosGoogleConfig(); + + expect(config).toEqual({ + clientId: 'iosGoogleClientId', + redirectUri: 'iosGoogleRedirectUri', + }); + }); + + it('returns Android web config when on Android', () => { + mockDeviceIsIos.mockReturnValue(false); + mockComparePlatformVersionTo.mockReturnValue(0); + + const config = getIosGoogleConfig(); + + expect(config.clientId).toBe('androidGoogleWebClientId'); + expect(config.redirectUri).toContain('link.metamask.io'); + }); + + it('calls Device.isIos and Device.comparePlatformVersionTo when evaluating the non-legacy path', () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(0); + mockSelectLegacyIosGoogleConfigEnabled.mockReturnValue(false); + + getIosGoogleConfig(); + + expect(mockDeviceIsIos).toHaveBeenCalled(); + expect(mockComparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + }); + + it('returns Android web config when the legacy iOS flag is disabled on iOS 17.4 or later', () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(0); + mockSelectLegacyIosGoogleConfigEnabled.mockReturnValue(false); + + const config = getIosGoogleConfig(); + + expect(config.clientId).toBe('androidGoogleWebClientId'); + expect(config.redirectUri).toContain('link.metamask.io'); + }); + + it('returns iOS-specific config when on iOS below 17.4 even if the legacy flag is disabled', () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(-1); + mockSelectLegacyIosGoogleConfigEnabled.mockReturnValue(false); + + const config = getIosGoogleConfig(); + + expect(config).toEqual({ + clientId: 'iosGoogleClientId', + redirectUri: 'iosGoogleRedirectUri', + }); + }); +}); + +describe('shouldUseLegacyIosGoogleConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDeviceIsIos.mockReturnValue(false); + mockComparePlatformVersionTo.mockReturnValue(0); + mockGetState.mockReturnValue({}); + mockSelectLegacyIosGoogleConfigEnabled.mockReturnValue(true); + }); + + it('returns false when the device is not iOS', () => { + expect(shouldUseLegacyIosGoogleConfig()).toBe(false); + }); + + it('returns the feature flag value on iOS 17.4 or newer', () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(0); + mockSelectLegacyIosGoogleConfigEnabled.mockReturnValue(true); + + expect(shouldUseLegacyIosGoogleConfig()).toBe(true); + }); + + it('returns the feature flag value on iOS below 17.4', () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(-1); + mockSelectLegacyIosGoogleConfigEnabled.mockReturnValue(false); + + expect(shouldUseLegacyIosGoogleConfig()).toBe(false); + expect(mockGetState).toHaveBeenCalledTimes(1); + }); + + it('falls back to the default when Redux is unavailable', () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(-1); + mockGetState.mockImplementation(() => { + throw new Error('store unavailable'); + }); + + expect(shouldUseLegacyIosGoogleConfig()).toBe(true); + }); +}); diff --git a/app/core/OAuthService/OAuthLoginHandlers/constants.ts b/app/core/OAuthService/OAuthLoginHandlers/constants.ts index 38e7f96da2c..e4743d6cbde 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/constants.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/constants.ts @@ -1,8 +1,14 @@ import { ACTIONS, PREFIXES, PROTOCOLS } from '../../../constants/deeplinks'; +import Device from '../../../util/device'; +import ReduxService from '../../redux'; import { isQa } from '../../../util/test/utils'; import AppConstants from '../../AppConstants'; import { AuthConnection } from '../OAuthInterface'; import { OAUTH_CONFIG } from './config'; +import { + DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED, + selectLegacyIosGoogleConfigEnabled, +} from '../../../selectors/featureFlagController/legacyIosGoogleConfig'; export const SEEDLESS_ONBOARDING_ENABLED = process.env.SEEDLESS_ONBOARDING_ENABLED === 'true'; @@ -63,6 +69,41 @@ export const AndroidGoogleRedirectUri = `${PROTOCOLS.HTTPS}://${AppConstants.MM_ export const AppRedirectUri = `${PREFIXES.METAMASK}${ACTIONS.OAUTH_REDIRECT}`; export const AppleServerRedirectUri = `${CURRENT_OAUTH_CONFIG.AUTH_SERVER_URL}/api/v1/oauth/callback`; +export const shouldUseLegacyIosGoogleConfig = () => { + if (!Device.isIos()) { + return false; + } + + try { + return selectLegacyIosGoogleConfigEnabled(ReduxService.store.getState()); + } catch { + return DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + } +}; + +export const getIosGoogleConfig = () => { + if ( + shouldUseLegacyIosGoogleConfig() || + (Device.isIos() && Device.comparePlatformVersionTo('17.4') < 0) + ) { + if (!IosGoogleRedirectUri || !IosGID) { + throw new Error('IosGoogleConfig is not set'); + } + return { + clientId: IosGID, + redirectUri: IosGoogleRedirectUri, + }; + } + + if (!AndroidGoogleWebGID) { + throw new Error('AndroidGoogleWebGID is not set'); + } + return { + clientId: AndroidGoogleWebGID, + redirectUri: AndroidGoogleRedirectUri, + }; +}; + export enum SupportedPlatforms { Android = 'android', IOS = 'ios', diff --git a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts index d2f2b67467e..a45c726c0c5 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts @@ -1,5 +1,9 @@ import { Platform } from 'react-native'; -import { AuthConnection, HandleFlowParams } from '../OAuthInterface'; +import { + AuthConnection, + HandleFlowParams, + LoginHandlerCodeResult, +} from '../OAuthInterface'; import { createLoginHandler } from './index'; import { OAuthError, OAuthErrorType } from '../error'; import { Web3AuthNetwork } from '@metamask/seedless-onboarding-controller'; @@ -10,15 +14,18 @@ const mockExpoAuthSessionPromptAsync = jest.fn().mockResolvedValue({ code: 'googleCode', }, }); +const mockDeviceIsIos = jest.fn(); +const mockComparePlatformVersionTo = jest.fn(); +const mockGetIosGoogleConfig = jest.fn(); jest.mock('./constants', () => ({ AuthServerUrl: 'https://auth.example.com', AppRedirectUri: 'https://app.example.com', - IosGID: 'mock-ios-google-client-id', - IosGoogleRedirectUri: 'mock-ios-google-redirect-uri', AndroidGoogleWebGID: 'mock-android-google-client-id', + AndroidGoogleRedirectUri: 'https://link.metamask.io/oauth-redirect', AppleWebClientId: 'mock-android-apple-client-id', AppleServerRedirectUri: 'https://auth.example.com/api/v1/oauth/callback', + getIosGoogleConfig: (...args: unknown[]) => mockGetIosGoogleConfig(...args), })); jest.mock('expo-auth-session', () => ({ @@ -30,6 +37,12 @@ jest.mock('expo-auth-session', () => ({ }), CodeChallengeMethod: jest.fn(), ResponseType: jest.fn(), + Prompt: { + SelectAccount: 'select_account', + Login: 'login', + Consent: 'consent', + None: 'none', + }, })); const mockSignInAsync = jest.fn().mockResolvedValue({ @@ -62,9 +75,24 @@ jest.mock('@metamask/react-native-acm', () => ({ signInWithGoogle: () => mockSignInWithGoogle(), })); +jest.mock('../../../util/device', () => ({ + __esModule: true, + default: { + isIos: (...args: unknown[]) => mockDeviceIsIos(...args), + comparePlatformVersionTo: (...args: unknown[]) => + mockComparePlatformVersionTo(...args), + }, +})); + describe('OAuth login handlers', () => { beforeEach(() => { jest.clearAllMocks(); + mockDeviceIsIos.mockReturnValue(false); + mockComparePlatformVersionTo.mockReturnValue(0); + mockGetIosGoogleConfig.mockReturnValue({ + clientId: 'mock-android-google-client-id', + redirectUri: 'https://link.metamask.io/oauth-redirect', + }); }); for (const os of ['ios', 'android']) { @@ -292,6 +320,12 @@ describe('OAuth login handlers', () => { describe('iOS Google handler', () => { beforeEach(() => { jest.clearAllMocks(); + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(0); + mockGetIosGoogleConfig.mockReturnValue({ + clientId: 'mock-ios-google-client-id', + redirectUri: 'mock-ios-google-redirect-uri', + }); }); it('throw UserCancelled error when user cancels', async () => { @@ -354,6 +388,57 @@ describe('OAuth login handlers', () => { await expect(handler.login()).rejects.toThrow('Network error'); }); + + it('uses the legacy iOS Google config returned by the shared config helper', async () => { + mockGetIosGoogleConfig.mockReturnValue({ + clientId: 'mock-ios-google-client-id', + redirectUri: 'mock-ios-google-redirect-uri', + }); + mockExpoAuthSessionPromptAsync.mockResolvedValue({ + type: 'success', + params: { + code: 'test-auth-code', + }, + }); + + const handler = createLoginHandler('ios', AuthConnection.Google); + const result = await handler.login(); + + expect(result?.authConnection).toBe(AuthConnection.Google); + expect((result as LoginHandlerCodeResult)?.code).toBe('test-auth-code'); + expect((result as LoginHandlerCodeResult)?.clientId).toBe( + 'mock-ios-google-client-id', + ); + expect((result as LoginHandlerCodeResult)?.redirectUri).toBe( + 'mock-ios-google-redirect-uri', + ); + expect(mockGetIosGoogleConfig).toHaveBeenCalledTimes(1); + expect(mockExpoAuthSessionPromptAsync).toHaveBeenCalledTimes(1); + }); + + it('uses the web Google config returned by the shared config helper', async () => { + mockGetIosGoogleConfig.mockReturnValue({ + clientId: 'mock-android-google-client-id', + redirectUri: 'https://link.metamask.io/oauth-redirect', + }); + mockExpoAuthSessionPromptAsync.mockResolvedValue({ + type: 'success', + params: { + code: 'test-auth-code', + }, + }); + + const handler = createLoginHandler('ios', AuthConnection.Google); + + await expect(handler.login()).resolves.toMatchObject({ + authConnection: AuthConnection.Google, + code: 'test-auth-code', + clientId: 'mock-android-google-client-id', + redirectUri: 'https://link.metamask.io/oauth-redirect', + }); + expect(mockGetIosGoogleConfig).toHaveBeenCalledTimes(1); + expect(mockExpoAuthSessionPromptAsync).toHaveBeenCalledTimes(1); + }); }); describe('Android Apple handler', () => { diff --git a/app/core/OAuthService/OAuthLoginHandlers/index.ts b/app/core/OAuthService/OAuthLoginHandlers/index.ts index ced6cf44dd2..8d8fe015e31 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.ts @@ -7,12 +7,11 @@ import { AndroidGoogleFallbackLoginHandler } from './androidHandlers/googleFallb import { AndroidAppleLoginHandler } from './androidHandlers/apple'; import { AuthServerUrl, - IosGID, - IosGoogleRedirectUri, AndroidGoogleWebGID, AndroidGoogleRedirectUri, AppleWebClientId, web3AuthNetwork, + getIosGoogleConfig, } from './constants'; import { OAuthErrorType, OAuthError } from '../error'; import { BaseLoginHandler } from './baseHandler'; @@ -32,23 +31,24 @@ export function createLoginHandler( ): BaseLoginHandler { if ( !AuthServerUrl || - !IosGID || - !IosGoogleRedirectUri || !AndroidGoogleWebGID || - !AppleWebClientId + !AppleWebClientId || + !AndroidGoogleRedirectUri ) { throw new Error('Missing environment variables'); } switch (platformOS) { case 'ios': switch (provider) { - case AuthConnection.Google: + case AuthConnection.Google: { + const { clientId, redirectUri } = getIosGoogleConfig(); return new IosGoogleLoginHandler({ - clientId: IosGID, - redirectUri: IosGoogleRedirectUri, + clientId, + redirectUri, authServerUrl: AuthServerUrl, web3AuthNetwork, }); + } case AuthConnection.Apple: return new IosAppleLoginHandler({ clientId: AppleWebClientId, diff --git a/app/core/OAuthService/OAuthLoginHandlers/shared/GoogleLoginHandler.ts b/app/core/OAuthService/OAuthLoginHandlers/shared/GoogleLoginHandler.ts index ea07683e4d4..89153732e20 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/shared/GoogleLoginHandler.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/shared/GoogleLoginHandler.ts @@ -7,6 +7,7 @@ import { import { AuthRequest, CodeChallengeMethod, + Prompt, ResponseType, } from 'expo-auth-session'; import { BaseHandlerOptions, BaseLoginHandler } from '../baseHandler'; @@ -84,6 +85,7 @@ export abstract class BaseGoogleLoginHandler extends BaseLoginHandler { codeChallengeMethod: CodeChallengeMethod.S256, usePKCE: true, state, + prompt: [Prompt.SelectAccount], }); const result = await authRequest.promptAsync({ diff --git a/app/core/OAuthService/error.ts b/app/core/OAuthService/error.ts index 7fa4ab812f7..39f47541232 100644 --- a/app/core/OAuthService/error.ts +++ b/app/core/OAuthService/error.ts @@ -16,6 +16,7 @@ export enum OAuthErrorType { GoogleLoginUserDisabledOneTapFeature = 10015, GoogleLoginOneTapFailure = 10016, GoogleLoginNoProviderDependencies = 10017, + IosGoogleLoginNotSupported = 10018, } export const OAuthErrorMessages: Record = { @@ -39,6 +40,8 @@ export const OAuthErrorMessages: Record = { [OAuthErrorType.GoogleLoginOneTapFailure]: 'Google login one tap failure', [OAuthErrorType.GoogleLoginNoProviderDependencies]: 'Google login credential provider not available', + [OAuthErrorType.IosGoogleLoginNotSupported]: + 'iOS Google login requires iOS 17.4 or later', } as const; export class OAuthError extends Error { diff --git a/app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts new file mode 100644 index 00000000000..3a507ff875a --- /dev/null +++ b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts @@ -0,0 +1,56 @@ +import { + DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED, + selectLegacyIosGoogleConfigEnabled, +} from '.'; +import { FeatureFlagNames } from '../../../constants/featureFlags'; + +describe('Legacy iOS Google Config Feature Flag Selector', () => { + const originalEnv = process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + + beforeEach(() => { + delete process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + }); + + afterAll(() => { + if (originalEnv === undefined) { + delete process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + return; + } + + process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = originalEnv; + }); + + it('returns the default value when the remote flag is missing', () => { + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({}); + + expect(result).toBe(DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED); + }); + + it('returns the remote flag value when present', () => { + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({ + [FeatureFlagNames.legacyIosGoogleConfigEnabled]: false, + }); + + expect(result).toBe(false); + }); + + it('allows the local env var to force enable the legacy config', () => { + process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = 'true'; + + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({ + [FeatureFlagNames.legacyIosGoogleConfigEnabled]: false, + }); + + expect(result).toBe(true); + }); + + it('allows the local env var to force disable the legacy config', () => { + process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = 'false'; + + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({ + [FeatureFlagNames.legacyIosGoogleConfigEnabled]: true, + }); + + expect(result).toBe(false); + }); +}); diff --git a/app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts new file mode 100644 index 00000000000..b7c854ccc5d --- /dev/null +++ b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts @@ -0,0 +1,28 @@ +import { hasProperty } from '@metamask/utils'; +import { createSelector } from 'reselect'; +import { FeatureFlagNames } from '../../../constants/featureFlags'; +import { getFeatureFlagValue } from '../env'; +import { selectRemoteFeatureFlags } from '..'; + +export const DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = true; +export const LEGACY_IOS_GOOGLE_CONFIG_ENV_VAR = + 'MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED'; + +export const selectLegacyIosGoogleConfigEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteValue = hasProperty( + remoteFeatureFlags, + FeatureFlagNames.legacyIosGoogleConfigEnabled, + ) + ? Boolean( + remoteFeatureFlags[FeatureFlagNames.legacyIosGoogleConfigEnabled], + ) + : DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + + return getFeatureFlagValue( + process.env[LEGACY_IOS_GOOGLE_CONFIG_ENV_VAR], + remoteValue, + ); + }, +); diff --git a/app/util/device/index.js b/app/util/device/index.js index df04b68a24d..315fb41d77b 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 { compareSemver } from './utils'; export default class Device { static getDeviceWidth() { @@ -12,6 +13,19 @@ export default class Device { return Dimensions.get('window').height; } + /** + * Compares this device's React Native {@link Platform.Version} to `referenceVersion` + * using dotted semver segments (see {@link compareSemver}). + * + * @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. This uses component-wise numeric comparison, not `Number()`. + */ + static comparePlatformVersionTo(referenceVersion) { + return compareSemver(Platform.Version, referenceVersion); + } + static isIos() { return Platform.OS === 'ios'; } diff --git a/app/util/device/utils.test.ts b/app/util/device/utils.test.ts new file mode 100644 index 00000000000..4607ee1dfd3 --- /dev/null +++ b/app/util/device/utils.test.ts @@ -0,0 +1,36 @@ +import { compareSemver } from './utils'; + +describe('compareSemver', () => { + it('returns 1 when v1 is greater than v2', () => { + expect(compareSemver('2.0.0', '1.9.9')).toBe(1); + expect(compareSemver('1.1', '1.0.9')).toBe(1); + }); + + it('returns -1 when v1 is less than v2', () => { + expect(compareSemver('1.0.0', '2.0.0')).toBe(-1); + expect(compareSemver('17.3.1', '17.4')).toBe(-1); + }); + + it('returns 0 when versions are equal', () => { + expect(compareSemver('1.2.3', '1.2.3')).toBe(0); + expect(compareSemver('17.4', '17.4')).toBe(0); + }); + + it('compares numeric inputs using dotted segments', () => { + expect(compareSemver(17, 16)).toBe(1); + expect(compareSemver(17, 18)).toBe(-1); + expect(compareSemver(17, 17)).toBe(0); + }); + + it('treats missing trailing segments as zero', () => { + expect(compareSemver('1.0', '1.0.0')).toBe(0); + expect(compareSemver('1.0.1', '1.0')).toBe(1); + expect(compareSemver('1.0', '1.0.1')).toBe(-1); + }); + + it('handles mixed string and number arguments', () => { + expect(compareSemver(17, '17.4')).toBe(-1); + expect(compareSemver('18', 17)).toBe(1); + expect(compareSemver('17', 17)).toBe(0); + }); +}); diff --git a/app/util/device/utils.ts b/app/util/device/utils.ts new file mode 100644 index 00000000000..b810c6ccc45 --- /dev/null +++ b/app/util/device/utils.ts @@ -0,0 +1,27 @@ +/** + * Compares two versions (string or number). + * Returns `1` if `v1 > v2`, `-1` if `v1 < v2`, and `0` if `v1 == v2`. + */ +export function compareSemver( + v1: string | number, + v2: string | number, +): number { + const normalize = (v: string | number): number[] => + v.toString().split('.').map(Number); + + const p1 = normalize(v1); + const p2 = normalize(v2); + + // Compare up to the longest version length + const length = Math.max(p1.length, p2.length); + + for (let i = 0; i < length; i++) { + const num1 = p1[i] || 0; // Default to 0 if part is missing + const num2 = p2[i] || 0; + + if (num1 > num2) return 1; + if (num1 < num2) return -1; + } + + return 0; +} diff --git a/ios/MetaMask/MetaMask.entitlements b/ios/MetaMask/MetaMask.entitlements index 8a7c420fb63..5b41008a05e 100644 --- a/ios/MetaMask/MetaMask.entitlements +++ b/ios/MetaMask/MetaMask.entitlements @@ -15,6 +15,7 @@ applinks:metamask-alternate.app.link applinks:link.metamask.io applinks:link-test.metamask.io + webcredentials:link.metamask.io com.apple.developer.in-app-payments diff --git a/ios/MetaMask/MetaMaskDebug.entitlements b/ios/MetaMask/MetaMaskDebug.entitlements index bb932ad1889..e4cafc45491 100644 --- a/ios/MetaMask/MetaMaskDebug.entitlements +++ b/ios/MetaMask/MetaMaskDebug.entitlements @@ -15,6 +15,7 @@ applinks:metamask-alternate.app.link applinks:link.metamask.io applinks:link-test.metamask.io + webcredentials:link.metamask.io com.apple.developer.in-app-payments diff --git a/locales/languages/en.json b/locales/languages/en.json index 9074d3e1b3e..dc161136bfd 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6749,7 +6749,10 @@ "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_google_login_not_supported_title": "Login not supported", + "ios_google_login_not_supported_description": "Google login below iOS 17.4 will not be supported soon. Please update your iOS version or login with Android/Extension for rehydration. You are advice to backup your SRP if proceed with iOS google login.", + "ios_google_login_not_supported_button": "Continue" }, "password_hint": { "title": "Password hint", diff --git a/package.json b/package.json index 3b088af877e..b491c7dcf94 100644 --- a/package.json +++ b/package.json @@ -185,7 +185,8 @@ "@metamask/accounts-controller": "37.0.0", "@metamask/core-backend": "^5.0.0", "bn.js@npm:4.11.6": "4.12.3", - "bn.js@npm:5.2.1": "5.2.3" + "bn.js@npm:5.2.1": "5.2.3", + "expo-web-browser@npm:~14.0.2": "patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", diff --git a/yarn.lock b/yarn.lock index c37bb543ce0..bb6d21fd18f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29212,7 +29212,7 @@ __metadata: languageName: node linkType: hard -"expo-web-browser@npm:~14.0.2": +"expo-web-browser@npm:14.0.2": version: 14.0.2 resolution: "expo-web-browser@npm:14.0.2" peerDependencies: @@ -29222,6 +29222,16 @@ __metadata: languageName: node linkType: hard +"expo-web-browser@patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch": + version: 14.0.2 + resolution: "expo-web-browser@patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch::version=14.0.2&hash=158d79" + peerDependencies: + expo: "*" + react-native: "*" + checksum: 10/68989f3d82afed74782e67aa9106df73c76a817cea8f7dbee54206177efb7176962f050b421699cebeb87a0cf2acad501e2dcf9d1e94d487b3fde07c8c20dc99 + languageName: node + linkType: hard + "expo@npm:~52.0.47": version: 52.0.47 resolution: "expo@npm:52.0.47"