From 2a680d4d8061ef79776918618942da0b15ecf48f Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:42:56 +0800 Subject: [PATCH 01/15] fix: support webcredentials --- ...po-web-browser-npm-14.0.2-98d00ce880.patch | 50 +++++++++++++++++++ .../OAuthService/OAuthLoginHandlers/index.ts | 4 +- ios/MetaMask/MetaMask.entitlements | 1 + ios/MetaMask/MetaMaskDebug.entitlements | 1 + package.json | 3 +- yarn.lock | 12 ++++- 6 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 .yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch 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/core/OAuthService/OAuthLoginHandlers/index.ts b/app/core/OAuthService/OAuthLoginHandlers/index.ts index ced6cf44dd2..7d2e8b48fb3 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.ts @@ -44,8 +44,8 @@ export function createLoginHandler( switch (provider) { case AuthConnection.Google: return new IosGoogleLoginHandler({ - clientId: IosGID, - redirectUri: IosGoogleRedirectUri, + clientId: AndroidGoogleWebGID, + redirectUri: AndroidGoogleRedirectUri, authServerUrl: AuthServerUrl, web3AuthNetwork, }); 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/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" From a6372e181b5ba2df4a8673d2970ea7d01f1b56bb Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:12:46 +0800 Subject: [PATCH 02/15] fix: prompt login not supported --- .../Views/Onboarding/index.test.tsx | 54 +++++++++++++++++++ app/components/Views/Onboarding/index.tsx | 15 ++++++ .../OAuthLoginHandlers/index.test.ts | 49 +++++++++++++++++ .../OAuthLoginHandlers/iosHandlers/google.ts | 14 +++++ app/core/OAuthService/error.ts | 3 ++ app/util/device/index.js | 14 +++++ app/util/device/utils.test.ts | 36 +++++++++++++ app/util/device/utils.ts | 27 ++++++++++ locales/languages/en.json | 4 +- 9 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 app/util/device/utils.test.ts create mode 100644 app/util/device/utils.ts diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 4608707035d..3abab9367de 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -1262,6 +1262,60 @@ describe('Onboarding', () => { ); }); + it('navigates to error sheet when iOS Google login is not supported', async () => { + Platform.OS = 'ios'; + const notSupportedError = new OAuthError( + '', + OAuthErrorType.IosGoogleLoginNotSupported, + ); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockRejectedValue(notSupportedError); + + 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(); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'error', + title: strings('error_sheet.ios_google_login_not_supported_title'), + description: strings( + 'error_sheet.ios_google_login_not_supported_description', + ), + descriptionAlign: 'center', + }), + }), + ); + }); + 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..d744df88668 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -575,6 +575,21 @@ const Onboarding = () => { ) { // QA: do not show error sheet if user cancelled return; + } else if (error.code === OAuthErrorType.IosGoogleLoginNotSupported) { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: { + type: 'error', + title: strings( + `error_sheet.ios_google_login_not_supported_title`, + ), + description: strings( + `error_sheet.ios_google_login_not_supported_description`, + ), + descriptionAlign: 'center', + }, + }); + return; } else if ( error.code === OAuthErrorType.GoogleLoginNoCredential || error.code === OAuthErrorType.GoogleLoginNoMatchingCredential || diff --git a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts index d2f2b67467e..370446dde6e 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts @@ -10,6 +10,8 @@ const mockExpoAuthSessionPromptAsync = jest.fn().mockResolvedValue({ code: 'googleCode', }, }); +const mockDeviceIsIos = jest.fn(); +const mockComparePlatformVersionTo = jest.fn(); jest.mock('./constants', () => ({ AuthServerUrl: 'https://auth.example.com', @@ -62,9 +64,20 @@ 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); }); for (const os of ['ios', 'android']) { @@ -292,6 +305,8 @@ describe('OAuth login handlers', () => { describe('iOS Google handler', () => { beforeEach(() => { jest.clearAllMocks(); + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(0); }); it('throw UserCancelled error when user cancels', async () => { @@ -354,6 +369,40 @@ describe('OAuth login handlers', () => { await expect(handler.login()).rejects.toThrow('Network error'); }); + + it('throws unsupported error on iOS versions below 17.4', async () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(-1); + + const handler = createLoginHandler('ios', AuthConnection.Google); + + await expect(handler.login()).rejects.toMatchObject({ + code: OAuthErrorType.IosGoogleLoginNotSupported, + message: expect.stringContaining( + 'iOS Google login requires iOS 17.4 or later', + ), + }); + expect(mockExpoAuthSessionPromptAsync).not.toHaveBeenCalled(); + }); + + it('allows iOS version 17.4', async () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(0); + 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', + }); + expect(mockExpoAuthSessionPromptAsync).toHaveBeenCalledTimes(1); + }); }); describe('Android Apple handler', () => { diff --git a/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/google.ts b/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/google.ts index 9f6023e6899..3e89641a166 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/google.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/google.ts @@ -1,4 +1,7 @@ +import { LoginHandlerCodeResult } from '../../OAuthInterface'; +import { OAuthError, OAuthErrorType } from '../../error'; import { BaseGoogleLoginHandler } from '../shared/GoogleLoginHandler'; +import Device from '../../../../util/device'; /** * IosGoogleLoginHandler is the Google login handler for iOS. @@ -8,4 +11,15 @@ import { BaseGoogleLoginHandler } from '../shared/GoogleLoginHandler'; */ export class IosGoogleLoginHandler extends BaseGoogleLoginHandler { protected handlerName = 'IosGoogleLoginHandler'; + + async login(): Promise { + if (Device.isIos() && Device.comparePlatformVersionTo('17.4') < 0) { + throw new OAuthError( + 'IosGoogleLoginHandler: Google login requires iOS 17.4 or later', + OAuthErrorType.IosGoogleLoginNotSupported, + ); + } + + return super.login(); + } } 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/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/locales/languages/en.json b/locales/languages/en.json index 9074d3e1b3e..21f693d917c 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6749,7 +6749,9 @@ "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 requires iOS 17.4 or later. Please update your iOS version to continue." }, "password_hint": { "title": "Password hint", From d8134766a0c23de98329847dde3a27d5f9e878d4 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:16:14 +0800 Subject: [PATCH 03/15] fix: remove unused variable --- .../OAuthService/OAuthLoginHandlers/constants.test.ts | 11 ----------- app/core/OAuthService/OAuthLoginHandlers/constants.ts | 2 -- .../OAuthService/OAuthLoginHandlers/index.test.ts | 3 +-- app/core/OAuthService/OAuthLoginHandlers/index.ts | 7 ++----- 4 files changed, 3 insertions(+), 20 deletions(-) diff --git a/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts b/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts index c07ba95d636..4b34fe01547 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts @@ -3,8 +3,6 @@ import { AppRedirectUri, web3AuthNetwork, AuthServerUrl, - IosGID, - IosGoogleRedirectUri, AndroidGoogleWebGID, AppleWebClientId, AppleServerRedirectUri, @@ -30,11 +28,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 +44,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 +58,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', diff --git a/app/core/OAuthService/OAuthLoginHandlers/constants.ts b/app/core/OAuthService/OAuthLoginHandlers/constants.ts index 38e7f96da2c..977b30fe40a 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/constants.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/constants.ts @@ -53,8 +53,6 @@ export const AuthServerUrl = CURRENT_OAUTH_CONFIG.AUTH_SERVER_URL; export const AUTH_SERVER_MARKETING_OPT_IN_PATH = '/api/v1/oauth/marketing_opt_in_status'; -export const IosGID = process.env.IOS_GOOGLE_CLIENT_ID; -export const IosGoogleRedirectUri = process.env.IOS_GOOGLE_REDIRECT_URI; export const AndroidGoogleWebGID = process.env.ANDROID_GOOGLE_SERVER_CLIENT_ID; export const AppleWebClientId = process.env.ANDROID_APPLE_CLIENT_ID; diff --git a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts index 370446dde6e..43f89804e95 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts @@ -16,9 +16,8 @@ const mockComparePlatformVersionTo = 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', })); diff --git a/app/core/OAuthService/OAuthLoginHandlers/index.ts b/app/core/OAuthService/OAuthLoginHandlers/index.ts index 7d2e8b48fb3..d0c4f7c2adb 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.ts @@ -7,8 +7,6 @@ import { AndroidGoogleFallbackLoginHandler } from './androidHandlers/googleFallb import { AndroidAppleLoginHandler } from './androidHandlers/apple'; import { AuthServerUrl, - IosGID, - IosGoogleRedirectUri, AndroidGoogleWebGID, AndroidGoogleRedirectUri, AppleWebClientId, @@ -32,10 +30,9 @@ export function createLoginHandler( ): BaseLoginHandler { if ( !AuthServerUrl || - !IosGID || - !IosGoogleRedirectUri || !AndroidGoogleWebGID || - !AppleWebClientId + !AppleWebClientId || + !AndroidGoogleRedirectUri ) { throw new Error('Missing environment variables'); } From b0bfa567e3b1475e6473ccee7292219bef14d427 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:43:58 +0800 Subject: [PATCH 04/15] fix: support ios google login for 17.4 with ios schema --- app/components/Views/Onboarding/index.tsx | 32 +++++++++-------- .../Views/SuccessErrorSheet/utils.ts | 36 +++++++++++++++++++ .../OAuthLoginHandlers/constants.ts | 23 ++++++++++++ .../OAuthService/OAuthLoginHandlers/index.ts | 6 ++-- .../OAuthLoginHandlers/iosHandlers/google.ts | 14 -------- .../shared/GoogleLoginHandler.ts | 2 ++ locales/languages/en.json | 2 +- 7 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 app/components/Views/SuccessErrorSheet/utils.ts diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index d744df88668..76d82ce1113 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -110,6 +110,10 @@ import { } from '@metamask/design-system-twrnc-preset'; import { getBuildNumber, getVersion } from 'react-native-device-info'; +import { + navigateToSuccessErrorSheet, + navigateToSuccessErrorSheetPromise, +} from '../SuccessErrorSheet/utils'; interface OnboardingState { warningModalVisible: boolean; loading: boolean; @@ -575,21 +579,6 @@ const Onboarding = () => { ) { // QA: do not show error sheet if user cancelled return; - } else if (error.code === OAuthErrorType.IosGoogleLoginNotSupported) { - navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.SUCCESS_ERROR_SHEET, - params: { - type: 'error', - title: strings( - `error_sheet.ios_google_login_not_supported_title`, - ), - description: strings( - `error_sheet.ios_google_login_not_supported_description`, - ), - descriptionAlign: 'center', - }, - }); - return; } else if ( error.code === OAuthErrorType.GoogleLoginNoCredential || error.code === OAuthErrorType.GoogleLoginNoMatchingCredential || @@ -774,6 +763,19 @@ const Onboarding = () => { }); const action = async () => { + if ( + Platform.OS === 'ios' && + Device.comparePlatformVersionTo('17.4') < 0 + ) { + await navigateToSuccessErrorSheetPromise(navigation, { + type: 'error', + title: strings(`error_sheet.ios_google_login_not_supported_title`), + description: strings( + `error_sheet.ios_google_login_not_supported_description`, + ), + descriptionAlign: 'center', + }); + } setLoading(); const loginHandler = createLoginHandler(Platform.OS, provider); try { diff --git a/app/components/Views/SuccessErrorSheet/utils.ts b/app/components/Views/SuccessErrorSheet/utils.ts new file mode 100644 index 00000000000..c8298849bd9 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.ts @@ -0,0 +1,36 @@ +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/core/OAuthService/OAuthLoginHandlers/constants.ts b/app/core/OAuthService/OAuthLoginHandlers/constants.ts index 977b30fe40a..5b3b0d7bac9 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/constants.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/constants.ts @@ -1,4 +1,5 @@ import { ACTIONS, PREFIXES, PROTOCOLS } from '../../../constants/deeplinks'; +import Device from '../../../util/device'; import { isQa } from '../../../util/test/utils'; import AppConstants from '../../AppConstants'; import { AuthConnection } from '../OAuthInterface'; @@ -53,6 +54,8 @@ export const AuthServerUrl = CURRENT_OAUTH_CONFIG.AUTH_SERVER_URL; export const AUTH_SERVER_MARKETING_OPT_IN_PATH = '/api/v1/oauth/marketing_opt_in_status'; +export const IosGID = process.env.IOS_GOOGLE_CLIENT_ID; +export const IosGoogleRedirectUri = process.env.IOS_GOOGLE_REDIRECT_URI; export const AndroidGoogleWebGID = process.env.ANDROID_GOOGLE_SERVER_CLIENT_ID; export const AppleWebClientId = process.env.ANDROID_APPLE_CLIENT_ID; @@ -61,6 +64,26 @@ 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 getIosGoogleConfig = () => { + if (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.ts b/app/core/OAuthService/OAuthLoginHandlers/index.ts index d0c4f7c2adb..d702c5cbb97 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.ts @@ -11,6 +11,7 @@ import { AndroidGoogleRedirectUri, AppleWebClientId, web3AuthNetwork, + getIosGoogleConfig, } from './constants'; import { OAuthErrorType, OAuthError } from '../error'; import { BaseLoginHandler } from './baseHandler'; @@ -36,13 +37,14 @@ export function createLoginHandler( ) { throw new Error('Missing environment variables'); } + const iosGoogleConfig = getIosGoogleConfig(); switch (platformOS) { case 'ios': switch (provider) { case AuthConnection.Google: return new IosGoogleLoginHandler({ - clientId: AndroidGoogleWebGID, - redirectUri: AndroidGoogleRedirectUri, + clientId: iosGoogleConfig.clientId, + redirectUri: iosGoogleConfig.redirectUri, authServerUrl: AuthServerUrl, web3AuthNetwork, }); diff --git a/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/google.ts b/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/google.ts index 3e89641a166..9f6023e6899 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/google.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/google.ts @@ -1,7 +1,4 @@ -import { LoginHandlerCodeResult } from '../../OAuthInterface'; -import { OAuthError, OAuthErrorType } from '../../error'; import { BaseGoogleLoginHandler } from '../shared/GoogleLoginHandler'; -import Device from '../../../../util/device'; /** * IosGoogleLoginHandler is the Google login handler for iOS. @@ -11,15 +8,4 @@ import Device from '../../../../util/device'; */ export class IosGoogleLoginHandler extends BaseGoogleLoginHandler { protected handlerName = 'IosGoogleLoginHandler'; - - async login(): Promise { - if (Device.isIos() && Device.comparePlatformVersionTo('17.4') < 0) { - throw new OAuthError( - 'IosGoogleLoginHandler: Google login requires iOS 17.4 or later', - OAuthErrorType.IosGoogleLoginNotSupported, - ); - } - - return super.login(); - } } 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/locales/languages/en.json b/locales/languages/en.json index 21f693d917c..4d2a3a09039 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6751,7 +6751,7 @@ "no_internet_connection_description": "Your internet connection is unstable. Check your connection and 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 requires iOS 17.4 or later. Please update your iOS version to continue." + "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." }, "password_hint": { "title": "Password hint", From db127271795506477c8543a905da23e7bb97478a Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:12:56 +0800 Subject: [PATCH 05/15] fix: update tests, update to warning --- .../Views/Onboarding/index.test.tsx | 97 +++++++++- app/components/Views/Onboarding/index.tsx | 15 +- .../Views/SuccessErrorSheet/index.test.tsx | 47 +++++ .../Views/SuccessErrorSheet/index.tsx | 14 +- .../Views/SuccessErrorSheet/interface.ts | 2 +- .../Views/SuccessErrorSheet/utils.test.ts | 181 ++++++++++++++++++ .../OAuthLoginHandlers/constants.test.ts | 64 +++++++ .../OAuthLoginHandlers/index.test.ts | 47 ++++- locales/languages/en.json | 3 +- 9 files changed, 444 insertions(+), 26 deletions(-) create mode 100644 app/components/Views/SuccessErrorSheet/utils.test.ts diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 3abab9367de..ab11b3dfeb8 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -132,6 +132,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 @@ -955,10 +956,13 @@ describe('Onboarding', () => { beforeEach(() => { mockSeedlessOnboardingEnabled.mockReturnValue(true); (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(); }); @@ -1262,14 +1266,30 @@ describe('Onboarding', () => { ); }); - it('navigates to error sheet when iOS Google login is not supported', async () => { + it('shows iOS version warning sheet before Google login on iOS < 17.4', async () => { Platform.OS = 'ios'; - const notSupportedError = new OAuthError( - '', - OAuthErrorType.IosGoogleLoginNotSupported, - ); + // Simulate iOS version below 17.4 + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); - mockOAuthService.handleOAuthLogin.mockRejectedValue(notSupportedError); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + // Auto-close the error sheet so the promise resolves and the login continues + 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?.onClose === 'function' + ) { + (screenParams.onClose as () => void)(); + } + }, + ); const { getByTestId } = renderScreen( Onboarding, @@ -1300,20 +1320,83 @@ describe('Onboarding', () => { 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', + type: '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', + ), }), }), ); + + // 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 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 iOS version 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?.type === 'warning', + ); + 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 () => { diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index 76d82ce1113..52265b8ded9 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -110,10 +110,7 @@ import { } from '@metamask/design-system-twrnc-preset'; import { getBuildNumber, getVersion } from 'react-native-device-info'; -import { - navigateToSuccessErrorSheet, - navigateToSuccessErrorSheetPromise, -} from '../SuccessErrorSheet/utils'; +import { navigateToSuccessErrorSheetPromise } from '../SuccessErrorSheet/utils'; interface OnboardingState { warningModalVisible: boolean; loading: boolean; @@ -763,17 +760,25 @@ const Onboarding = () => { }); const action = async () => { + // prompt for ios google login not supported below iOS 17.4 if ( Platform.OS === 'ios' && + provider === AuthConnection.Google && Device.comparePlatformVersionTo('17.4') < 0 ) { await navigateToSuccessErrorSheetPromise(navigation, { - type: 'error', + type: '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(); + }, }); } setLoading(); diff --git a/app/components/Views/SuccessErrorSheet/index.test.tsx b/app/components/Views/SuccessErrorSheet/index.test.tsx index ed83169eafb..0f841cc3bcf 100644 --- a/app/components/Views/SuccessErrorSheet/index.test.tsx +++ b/app/components/Views/SuccessErrorSheet/index.test.tsx @@ -98,4 +98,51 @@ describe('SuccessErrorSheet', () => { expect(getByText('Test Description')).toBeTruthy(); expect(getByText('Custom Button')).toBeTruthy(); }); + + it('renders correctly with warning type and shows warning icon', () => { + const mockWarningRoute = { + params: { + title: 'Warning Title', + description: 'Warning description', + type: 'warning' as const, + primaryButtonLabel: 'Continue', + onPrimaryButtonPress: jest.fn(), + onClose: jest.fn(), + descriptionAlign: 'center' as const, + }, + }; + + const { getByText, toJSON } = renderWithProvider( + , + ); + + expect(getByText('Warning Title')).toBeTruthy(); + expect(getByText('Warning description')).toBeTruthy(); + expect(getByText('Continue')).toBeTruthy(); + + // Warning icon should be rendered + const tree = JSON.stringify(toJSON()); + expect(tree).toContain('Warning'); + }); + + it('calls onPrimaryButtonPress when primary button is pressed', () => { + const onPrimaryButtonPress = jest.fn(); + const mockWarningRoute = { + params: { + title: 'Warning', + description: 'Description', + type: 'warning' as const, + primaryButtonLabel: 'Continue', + onPrimaryButtonPress, + onClose: jest.fn(), + }, + }; + + const { getByRole } = renderWithProvider( + , + ); + + fireEvent.press(getByRole('button', { name: 'Continue' })); + expect(onPrimaryButtonPress).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/components/Views/SuccessErrorSheet/index.tsx b/app/components/Views/SuccessErrorSheet/index.tsx index 0f606daf115..737c51da683 100644 --- a/app/components/Views/SuccessErrorSheet/index.tsx +++ b/app/components/Views/SuccessErrorSheet/index.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import { View } from 'react-native'; import Text from '../../../component-library/components/Texts/Text'; import { @@ -70,8 +70,16 @@ const SuccessErrorSheet = ({ route }: SuccessErrorSheetProps) => { } }; - const getIcon = - icon || (type === 'success' ? IconName.Confirmation : IconName.CircleX); + const getIcon = useMemo(() => { + switch (type) { + case 'success': + return IconName.Confirmation; + case 'error': + return IconName.CircleX; + case 'warning': + return IconName.Warning; + } + }, [type]); const getIconColor = iconColor || diff --git a/app/components/Views/SuccessErrorSheet/interface.ts b/app/components/Views/SuccessErrorSheet/interface.ts index c0487cd5606..a8dacb61f0c 100644 --- a/app/components/Views/SuccessErrorSheet/interface.ts +++ b/app/components/Views/SuccessErrorSheet/interface.ts @@ -9,7 +9,7 @@ export interface SuccessErrorSheetParams { title: string | React.ReactNode; description: string | React.ReactNode; customButton?: React.ReactNode; - type: 'success' | 'error'; + type: 'success' | 'error' | 'warning'; icon?: IconName; secondaryButtonLabel?: string; onSecondaryButtonPress?: () => void; 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/core/OAuthService/OAuthLoginHandlers/constants.test.ts b/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts index 4b34fe01547..f964098da77 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts @@ -7,8 +7,22 @@ import { AppleWebClientId, AppleServerRedirectUri, AuthConnectionConfig, + getIosGoogleConfig, } from './constants'; +const mockDeviceIsIos = jest.fn(); +const mockComparePlatformVersionTo = jest.fn(); + +jest.mock('../../../util/device', () => ({ + __esModule: true, + default: { + isIos: (...args: unknown[]) => mockDeviceIsIos(...args), + isAndroid: jest.fn().mockReturnValue(false), + comparePlatformVersionTo: (...args: unknown[]) => + mockComparePlatformVersionTo(...args), + }, +})); + const mockAppRedirectUri = 'metamask://oauth-redirect'; describe('OAuth Constants', () => { describe('AppRedirectUri', () => { @@ -87,3 +101,53 @@ describe('Error handling with missing environment variables', () => { expect(() => validateWithMissingVars()).toThrow(/AUTH_SERVER_URL/); }); }); + +describe('getIosGoogleConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDeviceIsIos.mockReturnValue(false); + mockComparePlatformVersionTo.mockReturnValue(0); + }); + + it('returns iOS-specific config when on iOS < 17.4', () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(-1); + + const config = getIosGoogleConfig(); + + expect(config).toEqual({ + clientId: 'iosGoogleClientId', + redirectUri: 'iosGoogleRedirectUri', + }); + }); + + it('returns Android web config when on iOS >= 17.4', () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(0); + + const config = getIosGoogleConfig(); + + expect(config.clientId).toBe('androidGoogleWebClientId'); + expect(config.redirectUri).toContain('link.metamask.io'); + }); + + 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 to determine config', () => { + mockDeviceIsIos.mockReturnValue(true); + mockComparePlatformVersionTo.mockReturnValue(0); + + getIosGoogleConfig(); + + expect(mockDeviceIsIos).toHaveBeenCalledTimes(1); + expect(mockComparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + }); +}); diff --git a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts index 43f89804e95..7c3780f7527 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts @@ -12,6 +12,7 @@ const mockExpoAuthSessionPromptAsync = jest.fn().mockResolvedValue({ }); const mockDeviceIsIos = jest.fn(); const mockComparePlatformVersionTo = jest.fn(); +const mockGetIosGoogleConfig = jest.fn(); jest.mock('./constants', () => ({ AuthServerUrl: 'https://auth.example.com', @@ -20,6 +21,7 @@ jest.mock('./constants', () => ({ 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', () => ({ @@ -31,6 +33,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({ @@ -77,6 +85,10 @@ describe('OAuth login handlers', () => { 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']) { @@ -306,6 +318,10 @@ describe('OAuth login handlers', () => { 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 () => { @@ -369,24 +385,36 @@ describe('OAuth login handlers', () => { await expect(handler.login()).rejects.toThrow('Network error'); }); - it('throws unsupported error on iOS versions below 17.4', async () => { + it('uses iOS-specific config when iOS version is below 17.4', async () => { mockDeviceIsIos.mockReturnValue(true); mockComparePlatformVersionTo.mockReturnValue(-1); + 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(); - await expect(handler.login()).rejects.toMatchObject({ - code: OAuthErrorType.IosGoogleLoginNotSupported, - message: expect.stringContaining( - 'iOS Google login requires iOS 17.4 or later', - ), - }); - expect(mockExpoAuthSessionPromptAsync).not.toHaveBeenCalled(); + expect(result?.authConnection).toBe(AuthConnection.Google); + expect(result?.code).toBe('test-auth-code'); + expect(mockGetIosGoogleConfig).toHaveBeenCalledTimes(1); + expect(mockExpoAuthSessionPromptAsync).toHaveBeenCalledTimes(1); }); - it('allows iOS version 17.4', async () => { + it('uses web config when iOS version is 17.4 or later', async () => { mockDeviceIsIos.mockReturnValue(true); mockComparePlatformVersionTo.mockReturnValue(0); + mockGetIosGoogleConfig.mockReturnValue({ + clientId: 'mock-ios-google-client-id', + redirectUri: 'mock-ios-google-redirect-uri', + }); mockExpoAuthSessionPromptAsync.mockResolvedValue({ type: 'success', params: { @@ -400,6 +428,7 @@ describe('OAuth login handlers', () => { authConnection: AuthConnection.Google, code: 'test-auth-code', }); + expect(mockGetIosGoogleConfig).toHaveBeenCalledTimes(1); expect(mockExpoAuthSessionPromptAsync).toHaveBeenCalledTimes(1); }); }); diff --git a/locales/languages/en.json b/locales/languages/en.json index 4d2a3a09039..dc161136bfd 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6751,7 +6751,8 @@ "no_internet_connection_description": "Your internet connection is unstable. Check your connection and 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_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", From 05d49bd65b4db9e80e30f87ff7bde5503ec1fbf9 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:35:27 +0800 Subject: [PATCH 06/15] fix: cursor comment --- app/components/Views/SuccessErrorSheet/index.tsx | 2 ++ .../androidHandlers/googleFallback.test.ts | 3 +++ app/core/OAuthService/OAuthLoginHandlers/index.ts | 9 +++++---- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/components/Views/SuccessErrorSheet/index.tsx b/app/components/Views/SuccessErrorSheet/index.tsx index 737c51da683..99f76e5c022 100644 --- a/app/components/Views/SuccessErrorSheet/index.tsx +++ b/app/components/Views/SuccessErrorSheet/index.tsx @@ -78,6 +78,8 @@ const SuccessErrorSheet = ({ route }: SuccessErrorSheetProps) => { return IconName.CircleX; case 'warning': return IconName.Warning; + default: + return IconName.CircleX; } }, [type]); 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/index.ts b/app/core/OAuthService/OAuthLoginHandlers/index.ts index d702c5cbb97..8d8fe015e31 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.ts @@ -37,17 +37,18 @@ export function createLoginHandler( ) { throw new Error('Missing environment variables'); } - const iosGoogleConfig = getIosGoogleConfig(); switch (platformOS) { case 'ios': switch (provider) { - case AuthConnection.Google: + case AuthConnection.Google: { + const { clientId, redirectUri } = getIosGoogleConfig(); return new IosGoogleLoginHandler({ - clientId: iosGoogleConfig.clientId, - redirectUri: iosGoogleConfig.redirectUri, + clientId, + redirectUri, authServerUrl: AuthServerUrl, web3AuthNetwork, }); + } case AuthConnection.Apple: return new IosAppleLoginHandler({ clientId: AppleWebClientId, From a5996514303b24cbb1d4a4e455cdbb8db3e14015 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:07:34 +0800 Subject: [PATCH 07/15] fix: update cursor comment --- .../Views/SuccessErrorSheet/index.test.tsx | 21 +++++++++++++++++++ .../Views/SuccessErrorSheet/index.tsx | 6 +++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/components/Views/SuccessErrorSheet/index.test.tsx b/app/components/Views/SuccessErrorSheet/index.test.tsx index 0f841cc3bcf..44f4c4b9cd6 100644 --- a/app/components/Views/SuccessErrorSheet/index.test.tsx +++ b/app/components/Views/SuccessErrorSheet/index.test.tsx @@ -125,6 +125,27 @@ describe('SuccessErrorSheet', () => { expect(tree).toContain('Warning'); }); + it('renders custom icon when icon prop is provided, overriding type-based icon', () => { + const mockErrorRouteWithCustomIcon = { + params: { + title: 'Custom Icon Title', + description: 'Custom Icon Description', + type: 'error' as const, + icon: IconName.Confirmation, + onClose: jest.fn(), + }, + }; + + const { toJSON } = renderWithProvider( + , + ); + + // The rendered icon should be Confirmation (custom icon), not CircleX (error type default) + const tree = JSON.stringify(toJSON()); + expect(tree).toContain('Confirmation'); + expect(tree).not.toContain('CircleX'); + }); + it('calls onPrimaryButtonPress when primary button is pressed', () => { const onPrimaryButtonPress = jest.fn(); const mockWarningRoute = { diff --git a/app/components/Views/SuccessErrorSheet/index.tsx b/app/components/Views/SuccessErrorSheet/index.tsx index 99f76e5c022..89ef6fab12c 100644 --- a/app/components/Views/SuccessErrorSheet/index.tsx +++ b/app/components/Views/SuccessErrorSheet/index.tsx @@ -71,6 +71,10 @@ const SuccessErrorSheet = ({ route }: SuccessErrorSheetProps) => { }; const getIcon = useMemo(() => { + if (icon) { + return icon; + } + switch (type) { case 'success': return IconName.Confirmation; @@ -81,7 +85,7 @@ const SuccessErrorSheet = ({ route }: SuccessErrorSheetProps) => { default: return IconName.CircleX; } - }, [type]); + }, [type, icon]); const getIconColor = iconColor || From 87423108a9578e42cb1863122aeba803ec28449a Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:18:17 +0800 Subject: [PATCH 08/15] refactor: revert not needed codes --- app/components/Views/Onboarding/index.tsx | 4 +- .../Views/SuccessErrorSheet/index.test.tsx | 68 ------------------- .../Views/SuccessErrorSheet/index.tsx | 20 +----- .../Views/SuccessErrorSheet/interface.ts | 2 +- 4 files changed, 7 insertions(+), 87 deletions(-) diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index 52265b8ded9..05902329131 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -111,6 +111,7 @@ import { 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; @@ -767,7 +768,8 @@ const Onboarding = () => { Device.comparePlatformVersionTo('17.4') < 0 ) { await navigateToSuccessErrorSheetPromise(navigation, { - type: 'warning', + 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`, diff --git a/app/components/Views/SuccessErrorSheet/index.test.tsx b/app/components/Views/SuccessErrorSheet/index.test.tsx index 44f4c4b9cd6..ed83169eafb 100644 --- a/app/components/Views/SuccessErrorSheet/index.test.tsx +++ b/app/components/Views/SuccessErrorSheet/index.test.tsx @@ -98,72 +98,4 @@ describe('SuccessErrorSheet', () => { expect(getByText('Test Description')).toBeTruthy(); expect(getByText('Custom Button')).toBeTruthy(); }); - - it('renders correctly with warning type and shows warning icon', () => { - const mockWarningRoute = { - params: { - title: 'Warning Title', - description: 'Warning description', - type: 'warning' as const, - primaryButtonLabel: 'Continue', - onPrimaryButtonPress: jest.fn(), - onClose: jest.fn(), - descriptionAlign: 'center' as const, - }, - }; - - const { getByText, toJSON } = renderWithProvider( - , - ); - - expect(getByText('Warning Title')).toBeTruthy(); - expect(getByText('Warning description')).toBeTruthy(); - expect(getByText('Continue')).toBeTruthy(); - - // Warning icon should be rendered - const tree = JSON.stringify(toJSON()); - expect(tree).toContain('Warning'); - }); - - it('renders custom icon when icon prop is provided, overriding type-based icon', () => { - const mockErrorRouteWithCustomIcon = { - params: { - title: 'Custom Icon Title', - description: 'Custom Icon Description', - type: 'error' as const, - icon: IconName.Confirmation, - onClose: jest.fn(), - }, - }; - - const { toJSON } = renderWithProvider( - , - ); - - // The rendered icon should be Confirmation (custom icon), not CircleX (error type default) - const tree = JSON.stringify(toJSON()); - expect(tree).toContain('Confirmation'); - expect(tree).not.toContain('CircleX'); - }); - - it('calls onPrimaryButtonPress when primary button is pressed', () => { - const onPrimaryButtonPress = jest.fn(); - const mockWarningRoute = { - params: { - title: 'Warning', - description: 'Description', - type: 'warning' as const, - primaryButtonLabel: 'Continue', - onPrimaryButtonPress, - onClose: jest.fn(), - }, - }; - - const { getByRole } = renderWithProvider( - , - ); - - fireEvent.press(getByRole('button', { name: 'Continue' })); - expect(onPrimaryButtonPress).toHaveBeenCalledTimes(1); - }); }); diff --git a/app/components/Views/SuccessErrorSheet/index.tsx b/app/components/Views/SuccessErrorSheet/index.tsx index 89ef6fab12c..0f606daf115 100644 --- a/app/components/Views/SuccessErrorSheet/index.tsx +++ b/app/components/Views/SuccessErrorSheet/index.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef } from 'react'; +import React, { useRef } from 'react'; import { View } from 'react-native'; import Text from '../../../component-library/components/Texts/Text'; import { @@ -70,22 +70,8 @@ const SuccessErrorSheet = ({ route }: SuccessErrorSheetProps) => { } }; - const getIcon = useMemo(() => { - if (icon) { - return icon; - } - - switch (type) { - case 'success': - return IconName.Confirmation; - case 'error': - return IconName.CircleX; - case 'warning': - return IconName.Warning; - default: - return IconName.CircleX; - } - }, [type, icon]); + const getIcon = + icon || (type === 'success' ? IconName.Confirmation : IconName.CircleX); const getIconColor = iconColor || diff --git a/app/components/Views/SuccessErrorSheet/interface.ts b/app/components/Views/SuccessErrorSheet/interface.ts index a8dacb61f0c..c0487cd5606 100644 --- a/app/components/Views/SuccessErrorSheet/interface.ts +++ b/app/components/Views/SuccessErrorSheet/interface.ts @@ -9,7 +9,7 @@ export interface SuccessErrorSheetParams { title: string | React.ReactNode; description: string | React.ReactNode; customButton?: React.ReactNode; - type: 'success' | 'error' | 'warning'; + type: 'success' | 'error'; icon?: IconName; secondaryButtonLabel?: string; onSecondaryButtonPress?: () => void; From 1b601a02fa70f9b5ded8b519af38a2cd2ba79891 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:27:42 +0800 Subject: [PATCH 09/15] fix: make prompt not interactable ( close when touch outside) --- app/components/Views/Onboarding/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index 05902329131..d0ef070af5c 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -781,6 +781,7 @@ const Onboarding = () => { onPrimaryButtonPress: () => { navigation.goBack(); }, + isInteractable: false, }); } setLoading(); From ad6f63872086f745096391b78bc529d18c86c153 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:32:52 +0800 Subject: [PATCH 10/15] fix: update tests --- app/components/Views/Onboarding/index.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index ab11b3dfeb8..39b8cb75bd2 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'; @@ -1326,7 +1327,9 @@ describe('Onboarding', () => { expect.objectContaining({ screen: Routes.SHEET.SUCCESS_ERROR_SHEET, params: expect.objectContaining({ - type: 'warning', + 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', From 0ec8d3207a30c23e357d6627e95911a95c986c35 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:34:37 +0800 Subject: [PATCH 11/15] chore: yarm format --- app/components/Views/SuccessErrorSheet/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/Views/SuccessErrorSheet/utils.ts b/app/components/Views/SuccessErrorSheet/utils.ts index c8298849bd9..0e1ecd145eb 100644 --- a/app/components/Views/SuccessErrorSheet/utils.ts +++ b/app/components/Views/SuccessErrorSheet/utils.ts @@ -17,7 +17,8 @@ export const navigateToSuccessErrorSheet = ( export const navigateToSuccessErrorSheetPromise = async ( navigation: NavigationProp, params: SuccessErrorSheetParams, -) => new Promise((resolve) => { +) => + new Promise((resolve) => { navigateToSuccessErrorSheet(navigation, { ...params, onPrimaryButtonPress: () => { From fb198f28aee7610a7961a77fcaf8cc0487c5962d Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:47:54 +0800 Subject: [PATCH 12/15] fix: update tests --- app/components/Views/Onboarding/index.test.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 39b8cb75bd2..756967d3aff 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -279,11 +279,13 @@ jest.mock('../../../core/OAuthService/OAuthLoginHandlers/constants', () => ({ 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); @@ -1278,16 +1280,17 @@ describe('Onboarding', () => { accountName: 'test@example.com', }); - // Auto-close the error sheet so the promise resolves and the login continues + // 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?.onClose === 'function' + typeof screenParams?.onPrimaryButtonPress === 'function' ) { - (screenParams.onClose as () => void)(); + (screenParams.onPrimaryButtonPress as () => void)(); } }, ); From 6e4622b518e6a1ff15a8d5627a73764b8763210a Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:52:22 +0800 Subject: [PATCH 13/15] fix: tests typing --- app/core/OAuthService/OAuthLoginHandlers/index.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts index 7c3780f7527..5390c4d0135 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'; @@ -403,7 +407,7 @@ describe('OAuth login handlers', () => { const result = await handler.login(); expect(result?.authConnection).toBe(AuthConnection.Google); - expect(result?.code).toBe('test-auth-code'); + expect((result as LoginHandlerCodeResult)?.code).toBe('test-auth-code'); expect(mockGetIosGoogleConfig).toHaveBeenCalledTimes(1); expect(mockExpoAuthSessionPromptAsync).toHaveBeenCalledTimes(1); }); From d93fb7b95fc33948514fcad5650a1f42389c7513 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:02:50 +0800 Subject: [PATCH 14/15] feat: revert the gid --- app/core/OAuthService/OAuthLoginHandlers/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/core/OAuthService/OAuthLoginHandlers/index.ts b/app/core/OAuthService/OAuthLoginHandlers/index.ts index 7d2e8b48fb3..ced6cf44dd2 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.ts @@ -44,8 +44,8 @@ export function createLoginHandler( switch (provider) { case AuthConnection.Google: return new IosGoogleLoginHandler({ - clientId: AndroidGoogleWebGID, - redirectUri: AndroidGoogleRedirectUri, + clientId: IosGID, + redirectUri: IosGoogleRedirectUri, authServerUrl: AuthServerUrl, web3AuthNetwork, }); From b61251e546b898af3df41e47375174523985f734 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:16:16 +0800 Subject: [PATCH 15/15] feat: add feature flag --- .../Views/Onboarding/index.test.tsx | 121 +++++++++++++++++- app/components/Views/Onboarding/index.tsx | 6 +- app/constants/featureFlags.ts | 1 + .../OAuthLoginHandlers/constants.test.ts | 107 +++++++++++++++- .../OAuthLoginHandlers/constants.ts | 22 +++- .../OAuthLoginHandlers/index.test.ts | 20 +-- .../legacyIosGoogleConfig/index.test.ts | 56 ++++++++ .../legacyIosGoogleConfig/index.ts | 28 ++++ 8 files changed, 343 insertions(+), 18 deletions(-) create mode 100644 app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts create mode 100644 app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 756967d3aff..54fac48da3b 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -271,10 +271,13 @@ 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(); @@ -958,6 +961,7 @@ 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); @@ -967,6 +971,7 @@ describe('Onboarding', () => { jest.clearAllMocks(); mockNavigate.mockReset(); mockSeedlessOnboardingEnabled.mockReset(); + mockShouldUseLegacyIosGoogleConfig.mockReset(); }); it('calls Google OAuth login for create wallet flow on iOS and navigates to SocialLoginSuccessNewUser', async () => { @@ -1273,6 +1278,7 @@ describe('Onboarding', () => { 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', @@ -1353,6 +1359,116 @@ describe('Onboarding', () => { ); }); + 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 @@ -1392,12 +1508,13 @@ describe('Onboarding', () => { await flushPromises(); }); - // The iOS version warning sheet should NOT be shown for Apple login + // 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?.type === 'warning', + call[1]?.params?.title === + strings('error_sheet.ios_google_login_not_supported_title'), ); expect(warningSheetCall).toBeUndefined(); diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index d0ef070af5c..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'; @@ -763,6 +766,7 @@ 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 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/constants.test.ts b/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts index f964098da77..e70fba87b4a 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/constants.test.ts @@ -8,10 +8,13 @@ import { 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, @@ -23,6 +26,26 @@ jest.mock('../../../util/device', () => ({ }, })); +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', () => { @@ -107,11 +130,14 @@ describe('getIosGoogleConfig', () => { jest.clearAllMocks(); mockDeviceIsIos.mockReturnValue(false); mockComparePlatformVersionTo.mockReturnValue(0); + mockGetState.mockReturnValue({}); + mockSelectLegacyIosGoogleConfigEnabled.mockReturnValue(true); }); - it('returns iOS-specific config when on iOS < 17.4', () => { + 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(); @@ -121,14 +147,17 @@ describe('getIosGoogleConfig', () => { }); }); - it('returns Android web config when on iOS >= 17.4', () => { + 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.clientId).toBe('androidGoogleWebClientId'); - expect(config.redirectUri).toContain('link.metamask.io'); + expect(config).toEqual({ + clientId: 'iosGoogleClientId', + redirectUri: 'iosGoogleRedirectUri', + }); }); it('returns Android web config when on Android', () => { @@ -141,13 +170,79 @@ describe('getIosGoogleConfig', () => { expect(config.redirectUri).toContain('link.metamask.io'); }); - it('calls Device.isIos and Device.comparePlatformVersionTo to determine config', () => { + 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).toHaveBeenCalledTimes(1); + 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 5b3b0d7bac9..e4743d6cbde 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/constants.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/constants.ts @@ -1,9 +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'; @@ -64,8 +69,23 @@ 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 (Device.isIos() && Device.comparePlatformVersionTo('17.4') < 0) { + if ( + shouldUseLegacyIosGoogleConfig() || + (Device.isIos() && Device.comparePlatformVersionTo('17.4') < 0) + ) { if (!IosGoogleRedirectUri || !IosGID) { throw new Error('IosGoogleConfig is not set'); } diff --git a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts index 5390c4d0135..a45c726c0c5 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts @@ -389,9 +389,7 @@ describe('OAuth login handlers', () => { await expect(handler.login()).rejects.toThrow('Network error'); }); - it('uses iOS-specific config when iOS version is below 17.4', async () => { - mockDeviceIsIos.mockReturnValue(true); - mockComparePlatformVersionTo.mockReturnValue(-1); + 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', @@ -408,16 +406,20 @@ describe('OAuth login handlers', () => { 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 web config when iOS version is 17.4 or later', async () => { - mockDeviceIsIos.mockReturnValue(true); - mockComparePlatformVersionTo.mockReturnValue(0); + it('uses the web Google config returned by the shared config helper', async () => { mockGetIosGoogleConfig.mockReturnValue({ - clientId: 'mock-ios-google-client-id', - redirectUri: 'mock-ios-google-redirect-uri', + clientId: 'mock-android-google-client-id', + redirectUri: 'https://link.metamask.io/oauth-redirect', }); mockExpoAuthSessionPromptAsync.mockResolvedValue({ type: 'success', @@ -431,6 +433,8 @@ describe('OAuth login handlers', () => { 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); 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, + ); + }, +);