diff --git a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx
index 43336e3bc074..9524756158cb 100644
--- a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx
+++ b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx
@@ -6,6 +6,7 @@ import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddi
import useInitialDimensions from '@hooks/useInitialWindowDimensions';
import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings';
import useTackInputFocus from '@hooks/useTackInputFocus';
+import useTestToolsTapResponder from '@hooks/useTestToolsTapResponder';
import useThemeStyles from '@hooks/useThemeStyles';
import useViewportOffsetTop from '@hooks/useViewportOffsetTop';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -14,14 +15,12 @@ import {isMobile, isMobileWebKit, isSafari} from '@libs/Browser';
import type {ForwardedFSClassProps} from '@libs/Fullstory/types';
import addViewportResizeListener from '@libs/VisualViewport';
-import toggleTestToolsModal from '@userActions/TestTool';
-
import CONST from '@src/CONST';
import type {ComponentRef, ForwardedRef, ReactNode} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
-import React, {useContext, useEffect, useMemo, useRef} from 'react';
+import React, {useContext, useEffect, useMemo, useState} from 'react';
import {Keyboard, PanResponder, View} from 'react-native';
import {PickerAvoidingView} from 'react-native-picker-select';
@@ -161,24 +160,20 @@ function ScreenWrapperContainer({
[isUsingEdgeToEdgeMode, edgeToEdgeBottomContentStyle, legacyBottomContentStyle, bottomContentStyleProp],
);
- const panResponder = useRef(
- PanResponder.create({
- onStartShouldSetPanResponderCapture: (_e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS,
- onPanResponderRelease: toggleTestToolsModal,
- }),
- ).current;
-
- const keyboardDismissPanResponder = useRef(
- PanResponder.create({
- onMoveShouldSetPanResponderCapture: (_e, gestureState) => {
- const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy);
- const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && Keyboard.isVisible() && isMobile();
-
- return isHorizontalSwipe && shouldDismissKeyboard;
- },
- onPanResponderGrant: Keyboard.dismiss,
- }),
- ).current;
+ const testToolsTapResponder = useTestToolsTapResponder();
+
+ const [keyboardDismissPanResponder] = useState(
+ () =>
+ PanResponder.create({
+ onMoveShouldSetPanResponderCapture: (_e, gestureState) => {
+ const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy);
+ const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && Keyboard.isVisible() && isMobile();
+
+ return isHorizontalSwipe && shouldDismissKeyboard;
+ },
+ onPanResponderGrant: Keyboard.dismiss,
+ }).panHandlers,
+ );
useEffect(() => {
/**
@@ -206,7 +201,7 @@ function ScreenWrapperContainer({
ref={ref}
// This style gives the background for the screens. Stack cards are transparent to make different width screens in RHP possible.
style={[styles.flex1, styles.appBG, styles.screenWrapperContainerMinHeight(minHeight)]}
- {...panResponder.panHandlers}
+ {...testToolsTapResponder}
testID={testID}
fsClass={forwardedFSClass}
tabIndex={-1}
@@ -215,7 +210,7 @@ function ScreenWrapperContainer({
>
([NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR, SCREENS.TEST_TOOLS_MODAL.ROOT, SCREENS.TEST_TOOLS_MODAL.SERVER]);
+
+/**
+ * Classifies the focused screen for the required-2FA overlay.
+ * Returns a primitive so navigation listeners can skip unchanged values.
+ */
+function getRequire2FAFocusedScreen(screenName: string | undefined): Require2FAFocusedScreen {
+ if (isTwoFactorSetupScreen(screenName)) {
+ return REQUIRE_2FA_FOCUSED_SCREEN.TWO_FACTOR_SETUP;
+ }
+ if (screenName && TEST_TOOLS_SCREENS.has(screenName)) {
+ return REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS;
+ }
+ return REQUIRE_2FA_FOCUSED_SCREEN.OTHER;
+}
+
+type Require2FAOverlayVisibility = {
+ /** The blocking required-2FA screen is mounted over the app. */
+ isRequire2FAOverlayVisible: boolean;
+ /** Test tools are the focused route, so they must sit above the overlay and take input. */
+ isTestToolsRouteFocused: boolean;
+};
+
+/**
+ * Shared by the required-2FA overlay and the test-tools navigator.
+ * Test tools move above the overlay only while the overlay is covering the root stack.
+ */
+function useRequire2FAOverlayVisibility(): Require2FAOverlayVisibility {
+ const navigation = useNavigation();
+ const shouldShowRequire2FAPage = useShouldShowRequire2FAPage();
+ const focusedScreen = useRootNavigationState((state) => {
+ // When navigation is not ready yet, use the navigation state from the navigation hook.
+ const screenName = getDeepestFocusedScreen(state ?? navigation.getState())?.name;
+ return getRequire2FAFocusedScreen(screenName);
+ });
+
+ return {
+ isRequire2FAOverlayVisible: shouldShowRequire2FAPage && focusedScreen !== REQUIRE_2FA_FOCUSED_SCREEN.TWO_FACTOR_SETUP,
+ isTestToolsRouteFocused: focusedScreen === REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS,
+ };
+}
+
+export default useRequire2FAOverlayVisibility;
+export {REQUIRE_2FA_ABOVE_PORTAL_HOST, REQUIRE_2FA_FOCUSED_SCREEN, getRequire2FAFocusedScreen};
diff --git a/src/hooks/useTestToolsTapResponder.ts b/src/hooks/useTestToolsTapResponder.ts
new file mode 100644
index 000000000000..e7f98301e172
--- /dev/null
+++ b/src/hooks/useTestToolsTapResponder.ts
@@ -0,0 +1,21 @@
+import toggleTestToolsModal from '@userActions/TestTool';
+
+import CONST from '@src/CONST';
+
+import {useState} from 'react';
+import {PanResponder} from 'react-native';
+
+/** Four-finger tap that opens test tools. Shared by screen wrappers and the required-2FA overlay, which sits above those screens. */
+function useTestToolsTapResponder() {
+ const [panHandlers] = useState(
+ () =>
+ PanResponder.create({
+ onStartShouldSetPanResponderCapture: (_event, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS,
+ onPanResponderRelease: toggleTestToolsModal,
+ }).panHandlers,
+ );
+
+ return panHandlers;
+}
+
+export default useTestToolsTapResponder;
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 21ee4725483c..0bfb54f088d8 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -24,11 +24,13 @@ import WideRHPContextProvider from '@components/WideRHPContextProvider';
import useGPSInProgressModal from '@hooks/useGPSInProgressModal';
import useGPSTripStateChecker from '@hooks/useGPSTripStateChecker';
import useOnboardingFlowRouter from '@hooks/useOnboardingFlow';
+import {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useShouldSuppressPromotionalUI from '@hooks/useShouldSuppressPromotionalUI';
import {SidebarOrderedReportsContextProvider} from '@hooks/useSidebarOrderedReports';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
import setFullscreenVisibility from '@libs/actions/setFullscreenVisibility';
import {READ_COMMANDS} from '@libs/API/types';
@@ -54,7 +56,9 @@ import '@src/libs/subscribeToFullReconnect';
import type {RouteProp} from '@react-navigation/native';
import type {StackCardInterpolationProps} from '@react-navigation/stack';
+import {PortalHost} from '@gorhom/portal';
import React, {useEffect} from 'react';
+import {StyleSheet, View} from 'react-native';
import attachmentModalScreenOptions from './attachmentModalScreenOptions';
import AuthScreensInitHandler from './AuthScreensInitHandler';
@@ -131,6 +135,7 @@ const modalScreenListenersWithCancelSearch = {
function AuthScreens() {
const theme = useTheme();
+ const themeStyles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const rootNavigatorScreenOptions = useRootNavigatorScreenOptions();
@@ -415,6 +420,12 @@ function AuthScreens() {
/>
+
+
+
diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsLiftContext.ts b/src/libs/Navigation/AppNavigator/Navigators/TestToolsLiftContext.ts
new file mode 100644
index 000000000000..fff118fd189a
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsLiftContext.ts
@@ -0,0 +1,19 @@
+import type {CardAnimationContext} from '@react-navigation/stack';
+import type {ContextType} from 'react';
+
+import {createContext} from 'react';
+
+type TestToolsLift = {
+ /** Move the focused screen into the portal host above the required-2FA overlay. */
+ shouldLift: boolean;
+
+ /** Card animation of the root test-tools modal, captured before the nested stack replaces it. */
+ cardAnimation: ContextType;
+};
+
+const TestToolsLiftContext = createContext({
+ shouldLift: false,
+ cardAnimation: undefined,
+});
+
+export default TestToolsLiftContext;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx
index edaf758e3603..8ff48bf95eae 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx
@@ -6,11 +6,15 @@ import TestToolsServerPage from '@components/TestToolsServerPage';
import useIsAuthenticated from '@hooks/useIsAuthenticated';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
+import useRequire2FAOverlayVisibility, {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
import useThemeStyles from '@hooks/useThemeStyles';
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
+import shouldLiftTestToolsAboveRequire2FA from '@libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA';
import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {TestToolsModalModalNavigatorParamList} from '@libs/Navigation/types';
import toggleTestToolsModal from '@userActions/TestTool';
@@ -18,20 +22,39 @@ import toggleTestToolsModal from '@userActions/TestTool';
import CONST from '@src/CONST';
import SCREENS from '@src/SCREENS';
-import type {ComponentRef, MouseEvent} from 'react';
+import type {ComponentRef, ComponentType, MouseEvent, ReactNode} from 'react';
-import React, {useCallback, useRef} from 'react';
-import {View} from 'react-native';
+import {Portal} from '@gorhom/portal';
+import {NavigationContext, NavigationRouteContext, useIsFocused} from '@react-navigation/native';
+import {CardAnimationContext} from '@react-navigation/stack';
+import React, {useCallback, useContext, useMemo, useRef} from 'react';
+import {StyleSheet, View} from 'react-native';
import Overlay from './Overlay';
+import TestToolsLiftContext from './TestToolsLiftContext';
+import TestToolsPortalBackdrop from './TestToolsPortalBackdrop';
const Stack = createPlatformStackNavigator();
-function TestToolsModalNavigator() {
+type TestToolsRouteName = keyof TestToolsModalModalNavigatorParamList;
+
+type TestToolsModalFrameProps = {
+ children: ReactNode;
+
+ /** Keep the stack in this same slot, without painting the sheet. Used while the focused screen is portaled. */
+ isPassThrough?: boolean;
+
+ /** This frame is the copy above the required-2FA overlay, so it needs its own dim and safe-area inset. */
+ isPortaled?: boolean;
+};
+
+function TestToolsModalFrame({children, isPassThrough = false, isPortaled = false}: TestToolsModalFrameProps) {
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {left, right} = useSafeAreaInsets();
const outerViewRef = useRef>(null);
const isAuthenticated = useIsAuthenticated();
+ const showChrome = !isPassThrough;
const handleOuterClick = useCallback(() => {
// Release focus from any focused element before closing the modal
@@ -45,37 +68,112 @@ function TestToolsModalNavigator() {
e.stopPropagation();
}, []);
+ const portaledInsetStyle = useMemo(() => (isPortaled ? {paddingLeft: left, paddingRight: right} : undefined), [isPortaled, left, right]);
+
+ return (
+
+
+
+ {isPortaled && }
+
+
+
+
+ true : undefined}
+ onClick={showChrome ? handleInnerClick : undefined}
+ pointerEvents={showChrome ? 'auto' : 'box-none'}
+ style={showChrome ? styles.getTestToolsNavigatorInnerView(shouldUseNarrowLayout, isAuthenticated) : styles.flex1}
+ >
+ {children}
+
+
+
+
+
+ );
+}
+
+/**
+ * On web and Android, move the focused screen into the portal host above the required-2FA overlay.
+ * The nested stack stays in the root navigator so the URL, backTo, and goBack keep working.
+ * The host is outside that stack, so the frame gets the screen contexts it reads.
+ *
+ * iOS stays in place. Moving that screen into the portal host leaves an empty opaque window,
+ * which covers the tools once the modal transition ends.
+ */
+function withRequire2FAPortal(Screen: ComponentType>) {
+ function Require2FAPortaledTestToolsScreen(props: PlatformStackScreenProps) {
+ const {shouldLift, cardAnimation} = useContext(TestToolsLiftContext);
+ const navigation = useContext(NavigationContext);
+ const route = useContext(NavigationRouteContext);
+ const isFocused = useIsFocused();
+
+ if (!shouldLift) {
+ return ;
+ }
+
+ // A stack keeps the previous screen mounted. Only the focused one should cover the 2FA screen.
+ if (!isFocused || !navigation || !route) {
+ return null;
+ }
+
+ const screenFrame = (
+
+
+
+ );
+ // The nested screen's card is a push, not the modal fade. Keep the root modal's animation on the dim.
+ const frame = cardAnimation ? {screenFrame} : screenFrame;
+
+ return (
+
+
+ {frame}
+
+
+ );
+ }
+
+ return Require2FAPortaledTestToolsScreen;
+}
+
+const PortaledTestToolsModalPage = withRequire2FAPortal(TestToolsModalPage);
+const PortaledTestToolsServerPage = withRequire2FAPortal(TestToolsServerPage);
+
+function TestToolsModalNavigator() {
+ const {isRequire2FAOverlayVisible} = useRequire2FAOverlayVisibility();
+ const shouldLift = shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible);
+ const cardAnimation = useContext(CardAnimationContext);
+ const lift = useMemo(() => ({shouldLift, cardAnimation}), [shouldLift, cardAnimation]);
+
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => toggleTestToolsModal(), {shouldBubble: false});
return (
-
-
-
-
- true}
- onClick={handleInnerClick}
- style={styles.getTestToolsNavigatorInnerView(shouldUseNarrowLayout, isAuthenticated)}
- >
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
);
}
diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.native.tsx
new file mode 100644
index 000000000000..1b8202343beb
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.native.tsx
@@ -0,0 +1,23 @@
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+
+import variables from '@styles/variables';
+
+import React from 'react';
+import {StyleSheet, View} from 'react-native';
+
+/** The native Overlay is empty, and the root modal dim sits under the required-2FA screen once this frame is portaled. */
+function TestToolsPortalBackdrop() {
+ const theme = useTheme();
+ const StyleUtils = useStyleUtils();
+
+ return (
+
+ );
+}
+
+export default TestToolsPortalBackdrop;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.tsx
new file mode 100644
index 000000000000..91ae1ad3292f
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.tsx
@@ -0,0 +1,6 @@
+/** Web already dims through Overlay, which follows the root modal card animation. */
+function TestToolsPortalBackdrop() {
+ return null;
+}
+
+export default TestToolsPortalBackdrop;
diff --git a/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ios.ts b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ios.ts
new file mode 100644
index 000000000000..da785c2b5143
--- /dev/null
+++ b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ios.ts
@@ -0,0 +1,10 @@
+/** iOS already presents this navigator in its own window above the overlay. Moving the screen into the portal host leaves an empty opaque window. */
+function shouldLiftTestToolsAboveRequire2FA(
+ // The flag is part of the shared signature. iOS never lifts, so the value is unused.
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ isRequire2FAOverlayVisible: boolean,
+): boolean {
+ return false;
+}
+
+export default shouldLiftTestToolsAboveRequire2FA;
diff --git a/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ts b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ts
new file mode 100644
index 000000000000..e9a8bc9e4e27
--- /dev/null
+++ b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ts
@@ -0,0 +1,6 @@
+/** Web and Android paint this navigator under the required-2FA overlay, so the focused screen moves into the portal host above it. */
+function shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible: boolean): boolean {
+ return isRequire2FAOverlayVisible;
+}
+
+export default shouldLiftTestToolsAboveRequire2FA;
diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx
index 2b7f7061b380..cde79225d666 100644
--- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx
+++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx
@@ -2,18 +2,22 @@ import Button from '@components/Button';
import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal';
import GpsDraftDetailsRefSync from '@components/GpsDraftDetailsRefSync';
import Icon from '@components/Icon';
+import OfflineIndicator from '@components/OfflineIndicator';
import Text from '@components/Text';
+import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle';
import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
-import useRootNavigationState from '@hooks/useRootNavigationState';
-import useShouldShowRequire2FAPage from '@hooks/useShouldShowRequire2FAPage';
+import useRequire2FAOverlayVisibility from '@hooks/useRequire2FAOverlayVisibility';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSignOut from '@hooks/useSignOut';
+import useTestToolsTapResponder from '@hooks/useTestToolsTapResponder';
import useThemeStyles from '@hooks/useThemeStyles';
import useTwoFactorAuthRoute from '@hooks/useTwoFactorAuthRoute';
-import Navigation, {getDeepestFocusedScreen, isTwoFactorSetupScreen} from '@libs/Navigation/Navigation';
+import Navigation from '@libs/Navigation/Navigation';
import variables from '@styles/variables';
@@ -29,7 +33,6 @@ import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails';
import type {OnyxCollection} from 'react-native-onyx';
-import {useNavigation} from '@react-navigation/core';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {StyleSheet, View} from 'react-native';
@@ -49,17 +52,20 @@ const is2FARequiredBecauseOfXeroSelector = (email?: string) => {
};
function RequireTwoFactorAuthenticationOverlay() {
- const navigation = useNavigation();
- const shouldShowRequire2FAPage = useShouldShowRequire2FAPage();
- const isIn2FASetupFlow = useRootNavigationState((state) => {
- // When navigation is not ready yet, use the navigation state from the navigation hook.
- const focusedScreen = getDeepestFocusedScreen(state ?? navigation.getState());
- return isTwoFactorSetupScreen(focusedScreen?.name);
- });
+ const {isRequire2FAOverlayVisible, isTestToolsRouteFocused} = useRequire2FAOverlayVisibility();
const illustrations = useMemoizedLazyIllustrations(['Encryption']);
const styles = useThemeStyles();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const testToolsTapResponder = useTestToolsTapResponder();
const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+ // OfflineIndicator is a fixed-height row. Safe-area padding has to live on the container around it,
+ // or the home-indicator inset consumes that height and the "You appear to be offline" label clips.
+ const offlineBannerSafeAreaStyle = useBottomSafeSafeAreaPaddingStyle({
+ addBottomSafeAreaPadding: true,
+ addOfflineIndicatorBottomSafeAreaPadding: false,
+ });
const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute();
const {signOut, leaveDelegateAccount, isActingAsDelegate, isTrackingGPS} = useSignOut();
const gpsDraftDetailsRef = useRef(undefined);
@@ -90,11 +96,11 @@ function RequireTwoFactorAuthenticationOverlay() {
}, [account, onboardingValues, onboardingCompanySize, onboardingPurposeSelected, onboardingInitialPath]);
useEffect(() => {
- if (!shouldShowRequire2FAPage || isIn2FASetupFlow) {
+ if (!isRequire2FAOverlayVisible) {
return;
}
snapshotOnboardingResumePathIfNeeded();
- }, [shouldShowRequire2FAPage, isIn2FASetupFlow, snapshotOnboardingResumePathIfNeeded]);
+ }, [isRequire2FAOverlayVisible, snapshotOnboardingResumePathIfNeeded]);
const onEscapePress = () => {
if (isEscapeInFlight) {
@@ -112,17 +118,18 @@ function RequireTwoFactorAuthenticationOverlay() {
Navigation.navigate(getTwoFactorAuthRoute(ROUTES.SETTINGS_SECURITY, {forceSetup: true}));
};
- if (!shouldShowRequire2FAPage || isIn2FASetupFlow) {
+ if (!isRequire2FAOverlayVisible) {
return null;
}
return (
<>
{isActingAsDelegate && isTrackingGPS && }
-
+
@@ -153,13 +160,21 @@ function RequireTwoFactorAuthenticationOverlay() {
variant={CONST.BUTTON_VARIANT.SUCCESS}
onPress={enableTwoFactorAuth}
>
-
+ {!isTestToolsRouteFocused && }
{translate('twoFactorAuth.enable2FA')}
+ {shouldUseNarrowLayout && isOffline && (
+
+
+
+ )}
>
diff --git a/src/styles/index.ts b/src/styles/index.ts
index d8648782516e..4643b40ed752 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -6239,6 +6239,10 @@ const staticStyles = (theme: ThemeColors) =>
zIndex: 20, // must be greater than floatingCameraButton.zIndex
},
+ twoFARequiredTestToolsHost: {
+ zIndex: 22, // must be greater than twoFARequiredOverlay.zIndex
+ },
+
twoFARequiredContainer: {
maxWidth: 520,
margin: 'auto',
diff --git a/tests/ui/RequireTwoFactorOverlayStackingTest.tsx b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx
new file mode 100644
index 000000000000..6523f5800aee
--- /dev/null
+++ b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx
@@ -0,0 +1,478 @@
+// The jest.mock factories below construct React elements and read props off untyped mock values,
+// so the unsafe-* rules fire on intentional test wiring rather than real type problems.
+/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */
+import {render, screen, within} from '@testing-library/react-native';
+
+import {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility';
+
+import TestToolsModalNavigator from '@libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator';
+
+import RequireTwoFactorAuthenticationOverlay from '@pages/RequireTwoFactorAuthenticationOverlay';
+
+import React from 'react';
+import {Platform} from 'react-native';
+
+type Visibility = {
+ isRequire2FAOverlayVisible: boolean;
+ isTestToolsRouteFocused: boolean;
+};
+
+let mockVisibility: Visibility = {
+ isRequire2FAOverlayVisible: true,
+ isTestToolsRouteFocused: false,
+};
+let mockIsOffline = true;
+let mockShouldUseNarrowLayout = true;
+let testToolsStackMountCount = 0;
+
+jest.mock('@hooks/useRequire2FAOverlayVisibility', () => ({
+ __esModule: true,
+ REQUIRE_2FA_ABOVE_PORTAL_HOST: 'require2FAAbove',
+ default: () => mockVisibility,
+}));
+
+jest.mock('@hooks/useNetwork', () => ({
+ __esModule: true,
+ default: () => ({isOffline: mockIsOffline}),
+}));
+
+jest.mock('@hooks/useBottomSafeSafeAreaPaddingStyle', () => ({
+ __esModule: true,
+ default: () => ({paddingBottom: 34}),
+}));
+
+jest.mock('@hooks/useOnyx', () => ({
+ __esModule: true,
+ default: () => [undefined, {status: 'loaded'}],
+}));
+
+jest.mock('@hooks/useSignOut', () => ({
+ __esModule: true,
+ default: () => ({
+ signOut: jest.fn(() => Promise.resolve()),
+ leaveDelegateAccount: jest.fn(() => Promise.resolve()),
+ isActingAsDelegate: false,
+ isTrackingGPS: false,
+ }),
+}));
+
+jest.mock('@hooks/useLocalize', () => ({
+ __esModule: true,
+ default: () => ({
+ translate: (key: string) => (key === 'common.youAppearToBeOffline' ? 'You appear to be offline.' : key),
+ localeCompare: (a: string, b: string) => a.localeCompare(b),
+ }),
+}));
+
+jest.mock('@hooks/useTheme', () => ({
+ __esModule: true,
+ default: () => ({icon: '#000', overlay: '#000000'}),
+}));
+
+jest.mock('@hooks/useThemeStyles', () => ({
+ __esModule: true,
+ default: () =>
+ new Proxy(
+ {},
+ {
+ get: (_target, prop) => {
+ if (prop === 'getTestToolsNavigatorOuterView' || prop === 'getTestToolsNavigatorInnerView') {
+ return () => ({});
+ }
+ return {};
+ },
+ },
+ ),
+}));
+
+jest.mock('@hooks/useLazyAsset', () => ({
+ useMemoizedLazyIllustrations: () => ({Encryption: 'encryption'}),
+ useMemoizedLazyExpensifyIcons: () => ({OfflineCloud: 'offline'}),
+}));
+
+jest.mock('@hooks/useTwoFactorAuthRoute', () => ({
+ __esModule: true,
+ default: () => ({getTwoFactorAuthRoute: jest.fn(() => '/settings/security/two-factor-auth')}),
+}));
+
+jest.mock('@hooks/useKeyboardShortcut', () => ({
+ __esModule: true,
+ default: () => {},
+}));
+
+jest.mock('@hooks/useResponsiveLayout', () => ({
+ __esModule: true,
+ default: () => ({shouldUseNarrowLayout: mockShouldUseNarrowLayout}),
+}));
+
+jest.mock('@hooks/useSafeAreaInsets', () => ({
+ __esModule: true,
+ default: () => ({top: 0, right: 0, bottom: 0, left: 0}),
+}));
+
+// Jest resolves the iOS helper, which never lifts. This follows Platform.OS at call time so one suite can cover web and Android too.
+jest.mock('@libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA', () => {
+ const {Platform: RNPlatform} = require('react-native');
+
+ return {
+ __esModule: true,
+ default: (isRequire2FAOverlayVisible: boolean) => isRequire2FAOverlayVisible && RNPlatform.OS !== 'ios',
+ };
+});
+
+jest.mock('@hooks/useIsAuthenticated', () => ({
+ __esModule: true,
+ default: () => true,
+}));
+
+jest.mock('@libs/Navigation/Navigation', () => ({
+ __esModule: true,
+ default: {
+ getActiveRoute: () => '/home',
+ navigate: jest.fn(),
+ },
+}));
+
+jest.mock('@userActions/Welcome', () => ({
+ updateOnboardingLastVisitedPath: jest.fn(),
+}));
+
+jest.mock('@userActions/Welcome/OnboardingFlow', () => ({
+ buildOnboardingFlowParams: jest.fn(() => ({})),
+ getRequired2FAOnboardingResumePath: jest.fn(() => ''),
+}));
+
+jest.mock('@userActions/TestTool', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('@components/FocusTrap/FocusTrapForModal', () => {
+ const ReactActual = require('react');
+ const {View} = require('react-native');
+
+ return {
+ __esModule: true,
+ default: ({active, children}: {active?: boolean; children: React.ReactNode}) =>
+ ReactActual.createElement(View, {testID: active ? 'focus-trap-active' : 'focus-trap-inactive'}, children),
+ };
+});
+
+jest.mock('@components/FocusTrap/FocusTrapForScreen', () => {
+ return {
+ __esModule: true,
+ default: ({children}: {children: React.ReactNode}) => children,
+ };
+});
+
+jest.mock('@components/Icon', () => {
+ const ReactActual = require('react');
+ const {View} = require('react-native');
+
+ return {
+ __esModule: true,
+ default: () => ReactActual.createElement(View),
+ };
+});
+
+jest.mock('@components/Button/primitives/ButtonKeyboardShortcut', () => {
+ const ReactActual = require('react');
+ const {View} = require('react-native');
+
+ return {
+ __esModule: true,
+ default: () => ReactActual.createElement(View, {testID: 'enable-2fa-enter-shortcut'}),
+ };
+});
+
+let mockFocusedRouteName = 'TestToolsModal_Root';
+
+jest.mock('@gorhom/portal', () => {
+ const ReactActual = require('react');
+ const {View} = require('react-native');
+ const {NavigationContext, NavigationRouteContext} = require('@react-navigation/native');
+ const {CardAnimationContext} = require('@react-navigation/stack');
+
+ // A real portal renders at the host, outside the screen's providers. Drop those contexts so the test fails unless the screen puts them back.
+ return {
+ Portal: ({hostName, children}: {hostName: string; children: React.ReactNode}) =>
+ ReactActual.createElement(
+ View,
+ {testID: `portal-${hostName}`},
+ ReactActual.createElement(
+ CardAnimationContext.Provider,
+ {value: undefined},
+ ReactActual.createElement(NavigationContext.Provider, {value: undefined}, ReactActual.createElement(NavigationRouteContext.Provider, {value: undefined}, children)),
+ ),
+ ),
+ PortalHost: () => null,
+ };
+});
+
+jest.mock('@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator', () => {
+ const ReactActual = require('react');
+ const {View} = require('react-native');
+ const {NavigationContext, NavigationRouteContext} = require('@react-navigation/native');
+
+ function Navigator({children}: {children: React.ReactNode}) {
+ ReactActual.useEffect(() => {
+ testToolsStackMountCount += 1;
+ }, []);
+ return ReactActual.createElement(View, {testID: 'test-tools-stack'}, children);
+ }
+
+ function Screen({name, component: Component}: {name: string; component: React.ComponentType<{route: {key: string; name: string}; navigation: {isFocused: () => boolean}}>}) {
+ const navigation = {
+ isFocused: () => name === mockFocusedRouteName,
+ addListener: () => () => {},
+ };
+ const route = {key: `${name}-key`, name};
+ return ReactActual.createElement(
+ NavigationContext.Provider,
+ {value: navigation},
+ ReactActual.createElement(NavigationRouteContext.Provider, {value: route}, ReactActual.createElement(Component, {route, navigation})),
+ );
+ }
+
+ return {
+ __esModule: true,
+ default: () => ({Navigator, Screen}),
+ };
+});
+
+jest.mock('@components/TestToolsModalPage', () => {
+ const ReactActual = require('react');
+ const {Text} = require('react-native');
+ const {useRoute} = require('@react-navigation/native');
+
+ return function MockTestToolsModalPage() {
+ const route = useRoute();
+ return ReactActual.createElement(Text, {testID: 'test-tools-page'}, route.name);
+ };
+});
+
+jest.mock('@components/TestToolsServerPage', () => {
+ const ReactActual = require('react');
+ const {Text} = require('react-native');
+ const {useRoute} = require('@react-navigation/native');
+
+ return function MockTestToolsServerPage() {
+ const route = useRoute();
+ return ReactActual.createElement(Text, {testID: 'test-tools-server-page'}, route.name);
+ };
+});
+
+describe('RequireTwoFactorAuthenticationOverlay stacking', () => {
+ beforeEach(() => {
+ mockVisibility = {
+ isRequire2FAOverlayVisible: true,
+ isTestToolsRouteFocused: false,
+ };
+ mockIsOffline = true;
+ mockShouldUseNarrowLayout = true;
+ });
+
+ it('shows the offline banner inside the overlay on a narrow screen and opens test tools from a four-finger tap', () => {
+ // Given required 2FA is visible on a phone and the user is offline. The overlay covers the ScreenWrapper banner and its four-finger handler.
+
+ // When the overlay renders its own banner in the column, and the user taps with four fingers.
+ render();
+
+ // Then the banner is visible without covering the buttons, and the overlay itself handles the four-finger tap.
+ const overlay = screen.getByTestId('RequireTwoFactorAuthenticationOverlay');
+ expect(overlay).toBeOnTheScreen();
+ expect(within(overlay).getByTestId('RequireTwoFactorOfflineBanner')).toBeOnTheScreen();
+ expect(within(overlay).getByText('You appear to be offline.')).toBeOnTheScreen();
+ const fourTouches = {nativeEvent: {touches: [{}, {}, {}, {}]}, touchHistory: {numberActiveTouches: 4}};
+ const oneTouch = {nativeEvent: {touches: [{}]}, touchHistory: {numberActiveTouches: 1}};
+ expect(overlay.props.onStartShouldSetResponderCapture(fourTouches)).toBe(true);
+ expect(overlay.props.onStartShouldSetResponderCapture(oneTouch)).toBe(false);
+ overlay.props.onResponderRelease({touchHistory: {numberActiveTouches: 0}});
+ expect(jest.requireMock('@userActions/TestTool').default).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId('focus-trap-active')).toBeOnTheScreen();
+ expect(screen.getByTestId('enable-2fa-enter-shortcut')).toBeOnTheScreen();
+ });
+
+ it('hides the offline banner on a wide layout', () => {
+ // Given required 2FA is visible on a wide screen, where ScreenWrapper does not show an offline indicator.
+ mockShouldUseNarrowLayout = false;
+
+ // When the overlay renders.
+ render();
+
+ // Then it does not add a full-width banner of its own.
+ expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen();
+ expect(screen.queryByTestId('RequireTwoFactorOfflineBanner')).toBeNull();
+ });
+
+ it('hides the banner when the overlay is not showing', () => {
+ // Given required 2FA is not showing, so the ScreenWrapper banner is visible again.
+ mockVisibility = {
+ isRequire2FAOverlayVisible: false,
+ isTestToolsRouteFocused: false,
+ };
+
+ // When the overlay decides whether to render.
+ render();
+
+ // Then there is no second banner, because the two would stack.
+ expect(screen.queryByText('You appear to be offline.')).toBeNull();
+ expect(screen.queryByTestId('RequireTwoFactorAuthenticationOverlay')).toBeNull();
+ });
+
+ it('hides the banner while online', () => {
+ // Given required 2FA is visible but the user is online.
+ mockIsOffline = false;
+
+ // When the overlay renders.
+ render();
+
+ // Then the extra banner stays hidden, because there is no offline state to show.
+ expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen();
+ expect(screen.queryByText('You appear to be offline.')).toBeNull();
+ });
+
+ it('turns the web focus trap off while test tools are focused', () => {
+ // Given test tools are focused on top of the required-2FA overlay.
+ mockVisibility = {
+ isRequire2FAOverlayVisible: true,
+ isTestToolsRouteFocused: true,
+ };
+
+ // When the overlay renders.
+ render();
+
+ // Then the web focus trap turns off, so keyboard focus can reach the tools, and Enter no longer starts 2FA setup.
+ expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen();
+ expect(screen.getByTestId('focus-trap-inactive')).toBeOnTheScreen();
+ expect(screen.queryByTestId('focus-trap-active')).toBeNull();
+ expect(screen.queryByTestId('enable-2fa-enter-shortcut')).toBeNull();
+ });
+});
+
+describe('TestToolsModalNavigator portal', () => {
+ const originalOS = Platform.OS;
+
+ beforeEach(() => {
+ mockVisibility = {
+ isRequire2FAOverlayVisible: false,
+ isTestToolsRouteFocused: false,
+ };
+ mockFocusedRouteName = 'TestToolsModal_Root';
+ mockShouldUseNarrowLayout = true;
+ testToolsStackMountCount = 0;
+ Platform.OS = originalOS;
+ });
+
+ afterEach(() => {
+ Platform.OS = originalOS;
+ });
+
+ it('keeps test tools in place when the required-2FA overlay is hidden', () => {
+ // Given the required-2FA overlay is hidden, so the screen contentStyle already dims the tools.
+
+ // When test tools render in the root stack.
+ render();
+
+ // Then they stay in place, because nothing is covering them.
+ expect(screen.getByTestId('test-tools-stack')).toBeOnTheScreen();
+ expect(screen.getByTestId('test-tools-page')).toBeOnTheScreen();
+ expect(screen.queryByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeNull();
+ });
+
+ it('portals the focused screen above the overlay on web and keeps its route', () => {
+ // Given web, where the overlay is painted after the navigator and would cover an in-place screen.
+ Platform.OS = 'web';
+ mockVisibility = {
+ isRequire2FAOverlayVisible: true,
+ isTestToolsRouteFocused: true,
+ };
+
+ // When test tools render above that overlay.
+ render();
+
+ // Then that screen is portaled with its route. Web dims via Overlay inside the frame.
+ const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`);
+
+ expect(screen.getByTestId('test-tools-stack')).toBeOnTheScreen();
+ expect(within(portal).getByText('TestToolsModal_Root')).toBeOnTheScreen();
+ expect(screen.queryByTestId('test-tools-server-page')).toBeNull();
+ });
+
+ it('renders nothing for an unfocused test tools screen on web', () => {
+ // Given the server page is the focused test-tools screen while required 2FA is showing on web.
+ Platform.OS = 'web';
+ mockVisibility = {
+ isRequire2FAOverlayVisible: true,
+ isTestToolsRouteFocused: true,
+ };
+ mockFocusedRouteName = 'TestToolsModal_Server';
+
+ // When test tools render above the overlay.
+ render();
+
+ // Then only the focused page is portaled, so the previous tool screen does not stay on top of 2FA.
+ const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`);
+
+ expect(within(portal).getByText('TestToolsModal_Server')).toBeOnTheScreen();
+ expect(screen.queryByTestId('test-tools-page')).toBeNull();
+ });
+
+ it('portals the focused screen above the overlay on Android', () => {
+ // Given Android, where a transparent modal stays inside the stack and the overlay would cover an in-place screen.
+ Platform.OS = 'android';
+ mockVisibility = {
+ isRequire2FAOverlayVisible: true,
+ isTestToolsRouteFocused: true,
+ };
+
+ // When test tools render above that overlay.
+ render();
+
+ // Then that screen is portaled with its route, the same as on web.
+ const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`);
+
+ expect(within(portal).getByText('TestToolsModal_Root')).toBeOnTheScreen();
+ expect(within(portal).getByTestId('test-tools-portal-backdrop')).toBeOnTheScreen();
+ });
+
+ it('keeps the stack mounted when required 2FA appears while test tools are open', () => {
+ // Given test tools are already open on web, before the required-2FA flag arrives.
+ Platform.OS = 'web';
+ mockVisibility = {
+ isRequire2FAOverlayVisible: false,
+ isTestToolsRouteFocused: true,
+ };
+ const view = render();
+ const mountsAfterOpen = testToolsStackMountCount;
+
+ // When required 2FA becomes visible.
+ mockVisibility = {
+ isRequire2FAOverlayVisible: true,
+ isTestToolsRouteFocused: true,
+ };
+ view.rerender();
+
+ // Then the stack stays mounted and the focused screen moves into the portal.
+ expect(testToolsStackMountCount).toBe(mountsAfterOpen);
+ expect(screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeOnTheScreen();
+ });
+
+ it('keeps test tools inside the iOS modal while required 2FA is showing', () => {
+ // Given iOS, where this navigator is already a window above the overlay.
+ // Moving the screen out of that window into the portal host leaves an empty opaque screen that covers the tools after the transition.
+ Platform.OS = 'ios';
+ mockVisibility = {
+ isRequire2FAOverlayVisible: true,
+ isTestToolsRouteFocused: true,
+ };
+
+ // When test tools render.
+ render();
+
+ // Then they stay in the navigator, where the modal can show them, and nothing is portaled behind it.
+ expect(screen.queryByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeNull();
+ expect(screen.queryByTestId('test-tools-portal-backdrop')).toBeNull();
+ expect(screen.getByTestId('test-tools-page')).toBeOnTheScreen();
+ });
+});
diff --git a/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts b/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts
new file mode 100644
index 000000000000..0f2e332bc3e3
--- /dev/null
+++ b/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts
@@ -0,0 +1,151 @@
+import {renderHook} from '@testing-library/react-native';
+
+import useRequire2FAOverlayVisibility, {getRequire2FAFocusedScreen, REQUIRE_2FA_FOCUSED_SCREEN} from '@hooks/useRequire2FAOverlayVisibility';
+
+import NAVIGATORS from '@src/NAVIGATORS';
+import SCREENS from '@src/SCREENS';
+
+import type {NavigationState} from '@react-navigation/native';
+
+let mockRootState: NavigationState | undefined;
+let mockShouldShowRequire2FAPage = true;
+const mockGetState = jest.fn();
+
+jest.mock('@react-navigation/core', () => {
+ // jest.requireActual is typed as any.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const actual = jest.requireActual('@react-navigation/core');
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return {
+ ...actual,
+ useNavigation: () => ({
+ getState: mockGetState,
+ }),
+ };
+});
+
+jest.mock('@hooks/useShouldShowRequire2FAPage', () => ({
+ __esModule: true,
+ default: () => mockShouldShowRequire2FAPage,
+}));
+
+jest.mock('@hooks/useRootNavigationState', () => ({
+ __esModule: true,
+ default: (selector: (state: NavigationState | undefined) => number) => selector(mockRootState),
+}));
+
+function navigationState(screenName: string): NavigationState {
+ return {
+ key: 'root',
+ index: 0,
+ routeNames: [screenName],
+ routes: [{key: `${screenName}-key`, name: screenName}],
+ type: 'stack',
+ stale: false,
+ };
+}
+
+describe('getRequire2FAFocusedScreen', () => {
+ it('classifies a 2FA setup screen', () => {
+ // Given the focused route is the 2FA setup screen the overlay opens.
+ const screenName = SCREENS.TWO_FACTOR_AUTH.DYNAMIC_ROOT;
+
+ // When the focused screen is classified.
+ const focusedScreen = getRequire2FAFocusedScreen(screenName);
+
+ // Then it is setup, so the overlay can hide and stop covering the flow it opened.
+ expect(focusedScreen).toBe(REQUIRE_2FA_FOCUSED_SCREEN.TWO_FACTOR_SETUP);
+ });
+
+ it('classifies test tools screens', () => {
+ // Given the focused route is a test-tools screen rather than setup.
+ const screenNames = [SCREENS.TEST_TOOLS_MODAL.ROOT, SCREENS.TEST_TOOLS_MODAL.SERVER, NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR];
+
+ // When each of those routes is classified.
+ const focusedScreens = screenNames.map((screenName) => getRequire2FAFocusedScreen(screenName));
+
+ // Then they stay distinct from setup, so the overlay can remain up while the tools are open.
+ expect(focusedScreens).toEqual([REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS, REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS, REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS]);
+ });
+
+ it('classifies every other screen as other', () => {
+ // Given a route that is neither setup nor test tools, including a missing route.
+ const screenNames = [SCREENS.TWO_FACTOR_AUTH.ENABLED, undefined];
+
+ // When those routes are classified.
+ const focusedScreens = screenNames.map((screenName) => getRequire2FAFocusedScreen(screenName));
+
+ // Then they are normal screens, so the overlay follows the required-2FA flag alone.
+ expect(focusedScreens).toEqual([REQUIRE_2FA_FOCUSED_SCREEN.OTHER, REQUIRE_2FA_FOCUSED_SCREEN.OTHER]);
+ });
+});
+
+describe('useRequire2FAOverlayVisibility', () => {
+ beforeEach(() => {
+ mockRootState = undefined;
+ mockShouldShowRequire2FAPage = true;
+ mockGetState.mockReset();
+ mockGetState.mockReturnValue(undefined);
+ });
+
+ it('shows the overlay when required 2FA is on and the user is not in setup', () => {
+ // Given required 2FA is on and the user is on Home, not in setup.
+ mockRootState = navigationState(SCREENS.HOME);
+
+ // When the visibility hook runs.
+ const {result} = renderHook(() => useRequire2FAOverlayVisibility());
+
+ // Then the overlay shows, because the user still has to enable 2FA.
+ expect(result.current.isRequire2FAOverlayVisible).toBe(true);
+ expect(result.current.isTestToolsRouteFocused).toBe(false);
+ });
+
+ it('hides the overlay during 2FA setup', () => {
+ // Given the user is on the 2FA setup screen the overlay opened.
+ mockRootState = navigationState(SCREENS.TWO_FACTOR_AUTH.DYNAMIC_ROOT);
+
+ // When the visibility hook runs.
+ const {result} = renderHook(() => useRequire2FAOverlayVisibility());
+
+ // Then the overlay hides, so it does not cover that setup flow.
+ expect(result.current.isRequire2FAOverlayVisible).toBe(false);
+ expect(result.current.isTestToolsRouteFocused).toBe(false);
+ });
+
+ it('hides the overlay when required 2FA is off', () => {
+ // Given required 2FA is turned off.
+ mockShouldShowRequire2FAPage = false;
+ mockRootState = navigationState(SCREENS.HOME);
+
+ // When the visibility hook runs.
+ const {result} = renderHook(() => useRequire2FAOverlayVisibility());
+
+ // Then the overlay hides, because there is nothing left to block.
+ expect(result.current.isRequire2FAOverlayVisible).toBe(false);
+ });
+
+ it('keeps the overlay visible while test tools are focused', () => {
+ // Given required 2FA is on and test tools are the focused screen.
+ mockRootState = navigationState(SCREENS.TEST_TOOLS_MODAL.ROOT);
+
+ // When the visibility hook runs.
+ const {result} = renderHook(() => useRequire2FAOverlayVisibility());
+
+ // Then the overlay stays visible, so closing the tools returns to the 2FA screen instead of the app.
+ expect(result.current.isRequire2FAOverlayVisible).toBe(true);
+ expect(result.current.isTestToolsRouteFocused).toBe(true);
+ });
+
+ it('falls back to the navigation hook when root state is missing', () => {
+ // Given the root navigation state is missing and the navigation hook still reports test tools.
+ mockGetState.mockReturnValue(navigationState(SCREENS.TEST_TOOLS_MODAL.SERVER));
+
+ // When the visibility hook runs.
+ const {result} = renderHook(() => useRequire2FAOverlayVisibility());
+
+ // Then it uses that hook, so the overlay does not flicker off before the root state is ready.
+ expect(result.current.isTestToolsRouteFocused).toBe(true);
+ expect(result.current.isRequire2FAOverlayVisible).toBe(true);
+ });
+});