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,
+ );
+ },
+);