diff --git a/android/app/src/main/java/io/metamask/MainActivity.kt b/android/app/src/main/java/io/metamask/MainActivity.kt index afa21957426b..09cb0a1136cd 100644 --- a/android/app/src/main/java/io/metamask/MainActivity.kt +++ b/android/app/src/main/java/io/metamask/MainActivity.kt @@ -11,9 +11,33 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate import expo.modules.ReactActivityDelegateWrapper import io.branch.rnbranch.RNBranchModule import io.metamask.nativeModules.NotificationModule +import io.metamask.nativeModules.PrivacyCover import com.braze.reactbridge.BrazeReactUtils class MainActivity : ReactActivity() { + override fun onPause() { + PrivacyCover.markAuthenticationUnavailable() + PrivacyCover.show(this) + super.onPause() + } + + override fun onPostResume() { + super.onPostResume() + PrivacyCover.markAuthenticationReady() + } + + /** + * Predictive back calls this from React Native's dispatcher callback. + * Swallow it while the cover is up so it cannot pop the screen underneath. + */ + @Suppress("OVERRIDE_DEPRECATION") + override fun onBackPressed() { + if (PrivacyCover.isShown()) { + return + } + super.onBackPressed() + } + override fun onCreate(savedInstanceState: Bundle?) { // Capture Notification Intent NotificationModule.saveNotificationIntent(intent) @@ -24,6 +48,12 @@ class MainActivity : ReactActivity() { // This is required for expo-splash-screen. setTheme(R.style.AppTheme) super.onCreate(null) + // Android 13+ can suppress the Recents thumbnail without FLAG_SECURE, so + // screenshots keep working while the app is open. Older versions hold + // FLAG_SECURE for the wallet session instead (see AppLockService). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setRecentsScreenshotEnabled(false) + } } /** diff --git a/android/app/src/main/java/io/metamask/MainApplication.kt b/android/app/src/main/java/io/metamask/MainApplication.kt index fbdeded918f0..ee2ddced05e4 100644 --- a/android/app/src/main/java/io/metamask/MainApplication.kt +++ b/android/app/src/main/java/io/metamask/MainApplication.kt @@ -24,6 +24,7 @@ import io.metamask.nativeModules.RCTMinimizerPackage import io.metamask.nativeModules.RNTar.RNTarPackage import io.metamask.nativeModules.BrazePushPackage import io.metamask.nativeModules.NotificationPackage +import io.metamask.nativeModules.PrivacyCoverPackage import io.metamask.nativeModules.HermesProfiler.HermesProfilerPackage import com.braze.BrazeActivityLifecycleCallbackListener import com.margelo.nitro.nitrofetch.AutoPrefetcher @@ -46,6 +47,7 @@ class MainApplication : Application(), ShareApplication, ReactApplication { add(RCTMinimizerPackage()) add(RNTarPackage()) add(NotificationPackage()) + add(PrivacyCoverPackage()) add(BrazePushPackage()) if (BuildConfig.IS_PERFORMANCE_TEST) { add(HermesProfilerPackage()) diff --git a/android/app/src/main/java/io/metamask/nativeModules/PrivacyCover.kt b/android/app/src/main/java/io/metamask/nativeModules/PrivacyCover.kt new file mode 100644 index 000000000000..d3e6c3d04951 --- /dev/null +++ b/android/app/src/main/java/io/metamask/nativeModules/PrivacyCover.kt @@ -0,0 +1,149 @@ +package io.metamask.nativeModules + +import android.app.Activity +import android.os.IBinder +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import com.facebook.react.bridge.Promise +import io.metamask.R + +/** + * The Android privacy cover. [io.metamask.MainActivity] shows it from `onPause` + * and [PrivacyCoverModule] hides it once resume routing resolves. + * + * Theme fill plus the centered splash fox, matching the iOS cover. Recents stays + * a blank card: Android 13+ uses `setRecentsScreenshotEnabled`, older versions + * hold FLAG_SECURE from login until logout. The snapshot is taken before + * `onPause`, so this view is what the user sees on the way back in. + */ +internal object PrivacyCover { + private var overlay: View? = null + /** Consumes system back while the cover is visible so the screen underneath stays put. */ + private var backCallback: OnBackPressedCallback? = null + private var backCallbackActivity: Activity? = null + /** Bumped on every pause so a stale resume cannot start authentication. */ + private var authenticationEpoch = 0 + /** Set to [authenticationEpoch] only while `onPostResume` is the latest lifecycle event. */ + private var resumedEpoch: Int? = null + private val authenticationWaiters = mutableListOf>() + + fun show(activity: Activity) { + val decor = activity.window.decorView as? ViewGroup ?: return + val keyboardWindowToken = + activity.currentFocus?.windowToken ?: decor.windowToken + val existing = overlay + val cover = if (existing != null && existing.context === activity) { + existing + } else { + View(activity).apply { + setBackgroundResource(R.drawable.app_background) + isClickable = true + isFocusable = true + isFocusableInTouchMode = true + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + }.also { overlay = it } + } + + if (cover.parent !== decor) { + (cover.parent as? ViewGroup)?.removeView(cover) + decor.addView( + cover, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + } + cover.bringToFront() + cover.visibility = View.VISIBLE + // The IME is a separate window above this cover. Move focus to the + // visible cover before hiding it so the input cannot immediately + // reclaim focus and reopen the keyboard on resume. + cover.requestFocus() + dismissKeyboard(activity, keyboardWindowToken) + blockBack(activity) + } + + fun isShown(): Boolean = overlay?.visibility == View.VISIBLE + + private fun dismissKeyboard(activity: Activity, windowToken: IBinder?) { + if (windowToken == null) { + return + } + val inputMethodManager = + activity.getSystemService(InputMethodManager::class.java) ?: return + inputMethodManager.hideSoftInputFromWindow(windowToken, 0) + } + + fun hide() { + // A dismiss queued while the activity is pausing belongs to a resume + // that is no longer current. Dropping it here keeps the cover up. + if (resumedEpoch == null) { + return + } + overlay?.visibility = View.GONE + backCallback?.isEnabled = false + } + + /** + * Registered after React Native's own back callback, so while it is enabled + * the dispatcher delivers back here first and the screen underneath does not move. + */ + private fun blockBack(activity: Activity) { + val componentActivity = activity as? ComponentActivity ?: return + if (backCallback == null || backCallbackActivity !== componentActivity) { + backCallback?.remove() + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // The privacy cover is up. Drop the press. + } + } + componentActivity.onBackPressedDispatcher.addCallback(componentActivity, callback) + backCallback = callback + backCallbackActivity = componentActivity + } else { + backCallback?.isEnabled = true + } + } + + /** + * Authentication may start. Resolves waiters for this resume only. + * Call from [android.app.Activity.onPostResume]. + */ + fun markAuthenticationReady() { + resumedEpoch = authenticationEpoch + val ready = authenticationWaiters.filter { it.first == authenticationEpoch } + authenticationWaiters.removeAll { it.first == authenticationEpoch } + ready.forEach { it.second.resolve(null) } + } + + /** + * Invalidates the current resume. Pending authentication waits fail and + * must be requested again after the next [markAuthenticationReady]. + * Call from [android.app.Activity.onPause] before `super.onPause()`. + */ + fun markAuthenticationUnavailable() { + resumedEpoch = null + authenticationEpoch += 1 + val waiting = authenticationWaiters.toList() + authenticationWaiters.clear() + waiting.forEach { (_, promise) -> + promise.reject( + "ACTIVITY_PAUSED", + "The activity paused before authentication could start", + ) + } + } + + fun waitUntilAuthenticationReady(promise: Promise) { + if (resumedEpoch == authenticationEpoch) { + promise.resolve(null) + return + } + authenticationWaiters.add(authenticationEpoch to promise) + } +} diff --git a/android/app/src/main/java/io/metamask/nativeModules/PrivacyCoverModule.kt b/android/app/src/main/java/io/metamask/nativeModules/PrivacyCoverModule.kt new file mode 100644 index 000000000000..2051d38e77ed --- /dev/null +++ b/android/app/src/main/java/io/metamask/nativeModules/PrivacyCoverModule.kt @@ -0,0 +1,33 @@ +package io.metamask.nativeModules + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.UiThreadUtil + +/** + * Native bridge for [PrivacyCover]. `hide` dismisses the cover once JavaScript + * resume routing has resolved. `waitUntilAuthenticationReady` resolves after + * `onPostResume`, which is when Android will accept a biometric prompt. + * Showing the cover stays on the activity lifecycle. + */ +class PrivacyCoverModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) { + + override fun getName(): String = "PrivacyCoverModule" + + @ReactMethod + fun hide() { + UiThreadUtil.runOnUiThread { PrivacyCover.hide() } + } + + /** + * Resolves once [android.app.Activity.onPostResume] has run for the + * current resume. Rejects if [android.app.Activity.onPause] invalidates + * that resume first. + */ + @ReactMethod + fun waitUntilAuthenticationReady(promise: Promise) { + UiThreadUtil.runOnUiThread { PrivacyCover.waitUntilAuthenticationReady(promise) } + } +} diff --git a/android/app/src/main/java/io/metamask/nativeModules/PrivacyCoverPackage.kt b/android/app/src/main/java/io/metamask/nativeModules/PrivacyCoverPackage.kt new file mode 100644 index 000000000000..b3a34dbf1124 --- /dev/null +++ b/android/app/src/main/java/io/metamask/nativeModules/PrivacyCoverPackage.kt @@ -0,0 +1,16 @@ +package io.metamask.nativeModules + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class PrivacyCoverPackage : ReactPackage { + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() + + @Suppress("OVERRIDE_DEPRECATION") + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(PrivacyCoverModule(reactContext)) +} diff --git a/app/components/Nav/App/App.test.tsx b/app/components/Nav/App/App.test.tsx index 87e73b57b4a0..64db8c28901d 100644 --- a/app/components/Nav/App/App.test.tsx +++ b/app/components/Nav/App/App.test.tsx @@ -239,9 +239,6 @@ jest.mock('../../Views/ProHub/screens/Earned', () => () => ( jest.mock('../../Views/ProHub/screens/CancelMembership', () => () => ( )); -jest.mock('../../Views/LockScreen', () => () => ( - -)); jest.mock('../../Views/MultichainAccounts/AddressList', () => ({ AddressList: () => , })); @@ -710,10 +707,6 @@ describe('App', () => { expect(Routes.EDIT_NETWORK).toBeDefined(); }); - it('has lock screen route defined', () => { - expect(Routes.LOCK_SCREEN).toBeDefined(); - }); - it('has confirmation routes defined', () => { expect(Routes.CONFIRMATION_REQUEST_MODAL).toBeDefined(); expect(Routes.CONFIRMATION_SWITCH_ACCOUNT_TYPE).toBeDefined(); @@ -857,19 +850,6 @@ describe('App', () => { expect(getByTestId(MOCK_FOX_LOADER_ID)).toBeTruthy(); }); }); - - it('renders the lock screen route', async () => { - const routeState = { - index: 0, - routes: [{ name: Routes.LOCK_SCREEN }], - }; - - const { toJSON } = renderAppWithDefaultState(routeState); - - await waitFor(() => { - expect(toJSON()).toBeTruthy(); - }); - }); }); describe('Onboarding navigation', () => { @@ -2237,19 +2217,6 @@ describe('App', () => { expect(getByTestId('mock-pk-list')).toBeOnTheScreen(); }); }); - - it('renders the LockScreen route', async () => { - const routeState = { - index: 0, - routes: [{ name: Routes.LOCK_SCREEN }], - }; - - const { getByTestId } = renderAppAtRoute(routeState); - - await waitFor(() => { - expect(getByTestId('mock-lock-screen')).toBeTruthy(); - }); - }); }); describe('isNetworkUiRedesignEnabled conditional rendering', () => { diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 56cf7f5dc77a..3f3b0d9539f8 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -103,7 +103,6 @@ import ReturnToAppNotification from '../../Views/ReturnToAppNotification'; import EditAccountName from '../../Views/EditAccountName/EditAccountName'; import LegacyEditMultichainAccountName from '../../Views/MultichainAccounts/sheets/EditAccountName'; import { EditMultichainAccountName } from '../../Views/MultichainAccounts/sheets/EditMultichainAccountName'; -import LockScreen from '../../Views/LockScreen'; import StorageWrapper from '../../../store/storage-wrapper'; import ShowIpfsGatewaySheet from '../../Views/ShowIpfsGatewaySheet/ShowIpfsGatewaySheet'; import ShowDisplayNftMediaSheet from '../../Views/ShowDisplayMediaNFTSheet/ShowDisplayNFTMediaSheet'; @@ -1353,11 +1352,6 @@ const AppFlow = () => { /> ) : null} - { }; }); -jest.mock('../../../../core/LockManagerService', () => ({ - __esModule: true, - default: { - stopListening: jest.fn(), - startListening: jest.fn(), - }, -})); - // Mock navigation components jest.mock('../components/Onboarding/SignUp', () => 'SignUp'); jest.mock('../components/Onboarding/ConfirmEmail', () => 'ConfirmEmail'); diff --git a/app/components/UI/Card/routes/index.test.tsx b/app/components/UI/Card/routes/index.test.tsx index 2bbfce6f4775..b12a9d9dfdd5 100644 --- a/app/components/UI/Card/routes/index.test.tsx +++ b/app/components/UI/Card/routes/index.test.tsx @@ -233,20 +233,6 @@ jest.mock('../../../../constants/navigation/Routes', () => ({ }, })); -jest.mock('../../../../core/LockManagerService', () => ({ - __esModule: true, - default: { - stopListening: jest.fn(), - startListening: jest.fn(), - }, -})); - -const mockLockManagerService = jest.requireMock( - '../../../../core/LockManagerService', -).default; -const mockStopListening = mockLockManagerService.stopListening as jest.Mock; -const mockStartListening = mockLockManagerService.startListening as jest.Mock; - const createMockStore = (isAuthenticated = false, isCardholder = false) => configureStore({ reducer: { @@ -347,27 +333,4 @@ describe('CardRoutes', () => { expect(getAllByText('headerShown: false').length).toBeGreaterThan(0); }); }); - - describe('Auto-lock Management', () => { - beforeEach(() => { - mockStopListening.mockClear(); - mockStartListening.mockClear(); - }); - - it('disables auto-lock when Card root mounts', () => { - renderWithProviders(); - - expect(mockStopListening).toHaveBeenCalledTimes(1); - }); - - it('re-enables auto-lock when Card root unmounts', () => { - const { unmount } = renderWithProviders(); - - expect(mockStartListening).not.toHaveBeenCalled(); - - unmount(); - - expect(mockStartListening).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/app/components/UI/Card/routes/index.tsx b/app/components/UI/Card/routes/index.tsx index a25a692ec84e..26906acc2d89 100644 --- a/app/components/UI/Card/routes/index.tsx +++ b/app/components/UI/Card/routes/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { createNativeStackNavigator, NativeStackNavigationOptions, @@ -16,7 +16,6 @@ import { selectIsCardholder, } from '../../../../selectors/cardController'; import { useSelector } from 'react-redux'; -import LockManagerService from '../../../../core/LockManagerService'; import { withCardSDK } from '../sdk'; import AddFundsBottomSheet from '../components/AddFundsBottomSheet/AddFundsBottomSheet'; import AssetSelectionBottomSheet from '../components/AssetSelectionBottomSheet/AssetSelectionBottomSheet'; @@ -234,30 +233,21 @@ const CardModalsRoutes = () => ( ); -const CardRoutes = () => { - useEffect(() => { - LockManagerService.stopListening(); - return () => { - LockManagerService.startListening(); - }; - }, []); - - return ( - - - - - ); -}; +const CardRoutes = () => ( + + + + +); export default withCardSDK(CardRoutes); diff --git a/app/components/UI/Ramp/routes.test.tsx b/app/components/UI/Ramp/routes.test.tsx index ce154bb1b300..bcc7c7d4b4be 100644 --- a/app/components/UI/Ramp/routes.test.tsx +++ b/app/components/UI/Ramp/routes.test.tsx @@ -288,14 +288,6 @@ jest.mock('./Views/OrderDetails', () => { return MockView; }); -const mockStartListening = jest.fn(); -const mockStopListening = jest.fn(); - -jest.mock('../../../core/LockManagerService', () => ({ - startListening: () => mockStartListening(), - stopListening: () => mockStopListening(), -})); - const mockStore = configureMockStore(); const initialState = { engine: { @@ -323,17 +315,6 @@ describe('TokenListRoutes', () => { const { toJSON } = renderWithProviders(); expect(toJSON()).toBeTruthy(); }); - - it('stops lock manager listening on mount', () => { - renderWithProviders(); - expect(mockStopListening).toHaveBeenCalled(); - }); - - it('starts lock manager listening on unmount', () => { - const { unmount } = renderWithProviders(); - unmount(); - expect(mockStartListening).toHaveBeenCalled(); - }); }); describe('Ramp Route Constants', () => { diff --git a/app/components/UI/Ramp/routes.tsx b/app/components/UI/Ramp/routes.tsx index 1263211ebac2..7b0aa3ce561a 100644 --- a/app/components/UI/Ramp/routes.tsx +++ b/app/components/UI/Ramp/routes.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { QueryClientProvider } from '@tanstack/react-query'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import reactQueryService from '../../../core/ReactQueryService/ReactQueryService'; @@ -29,7 +29,6 @@ import StateSelectorModal from './Views/Modals/StateSelectorModal'; import UnsupportedStateModal from './Views/Modals/UnsupportedStateModal'; import PhoneCountrySelectorModal from './Views/Modals/PhoneCountrySelectorModal'; import RampsOrderDetails from './Views/OrderDetails'; -import LockManagerService from '../../../core/LockManagerService'; import { clearNativeStackNavigatorOptions, transparentModalScreenOptions, @@ -229,38 +228,26 @@ const TokenListModalsRoutes = () => ( ); -const TokenListRoutes = () => { - // Disable auto-lock during Ramps unified buy v2 flow - // This allows users to minimize the app to check personal details or complete - // verification steps without being locked out and redirected to wallet home - useEffect(() => { - LockManagerService.stopListening(); - return () => { - LockManagerService.startListening(); - }; - }, []); - - return ( - - - - - - - ); -}; +const TokenListRoutes = () => ( + + + + + + +); export default TokenListRoutes; diff --git a/app/components/Views/LockScreen/index.test.tsx b/app/components/Views/LockScreen/index.test.tsx deleted file mode 100644 index 5bb0bf9a5141..000000000000 --- a/app/components/Views/LockScreen/index.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react-native'; -import LockScreen from './'; -import { FoxLoaderSelectorsIDs } from '../../UI/FoxLoader/FoxLoader.testIds'; - -describe('LockScreen', () => { - it('should render correctly', () => { - render(); - expect( - screen.getByTestId(FoxLoaderSelectorsIDs.CONTAINER), - ).toBeOnTheScreen(); - }); -}); diff --git a/app/components/Views/LockScreen/index.tsx b/app/components/Views/LockScreen/index.tsx deleted file mode 100644 index fd474485d543..000000000000 --- a/app/components/Views/LockScreen/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable import-x/no-commonjs */ -import React from 'react'; -import FoxLoader from '../../UI/FoxLoader'; - -/** - * View that displays a loading animation when the app is locked. - */ -const LockScreen: React.FC = () => ; - -export default LockScreen; diff --git a/app/components/Views/ProtectWalletMandatoryModal/ProtectWalletMandatoryModal.test.tsx b/app/components/Views/ProtectWalletMandatoryModal/ProtectWalletMandatoryModal.test.tsx index 19c623e33c5f..2573161033a9 100644 --- a/app/components/Views/ProtectWalletMandatoryModal/ProtectWalletMandatoryModal.test.tsx +++ b/app/components/Views/ProtectWalletMandatoryModal/ProtectWalletMandatoryModal.test.tsx @@ -458,23 +458,6 @@ describe('ProtectWalletMandatoryModal', () => { }); }); - it('does not show modal when on LockScreen route', async () => { - mockGetState.mockReturnValue({ - routes: [{ name: 'LockScreen' }], - }); - - const store = createMockStore(false, false); - - const { queryByTestId } = renderWithTheme( - , - store, - ); - - await waitFor(() => { - expect(queryByTestId('modal-container')).toBeNull(); - }); - }); - it('displays correct title text', async () => { const store = createMockStore(false, false); diff --git a/app/components/Views/ProtectWalletMandatoryModal/ProtectWalletMandatoryModal.tsx b/app/components/Views/ProtectWalletMandatoryModal/ProtectWalletMandatoryModal.tsx index fe610366ec37..eee87088b0ba 100644 --- a/app/components/Views/ProtectWalletMandatoryModal/ProtectWalletMandatoryModal.tsx +++ b/app/components/Views/ProtectWalletMandatoryModal/ProtectWalletMandatoryModal.tsx @@ -21,7 +21,6 @@ import { selectSelectedInternalAccountAddress } from '../../../selectors/account import { useNavigation } from '@react-navigation/native'; import type { AppNavigationProp } from '../../../core/NavigationService/types'; -import Routes from '../../../constants/navigation/Routes'; import { findRouteNameFromNavigatorState } from '../../../util/general'; import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController'; @@ -63,7 +62,6 @@ const ProtectWalletMandatoryModal = () => { 'ManualBackupStep2', 'ManualBackupStep3', 'Webview', - Routes.LOCK_SCREEN, ].includes(route) ) { setShowProtectWalletModal(false); diff --git a/app/components/hooks/useBasicFunctionalityConsolidation.tsx b/app/components/hooks/useBasicFunctionalityConsolidation.tsx index 790e06e86418..9fb541a16189 100644 --- a/app/components/hooks/useBasicFunctionalityConsolidation.tsx +++ b/app/components/hooks/useBasicFunctionalityConsolidation.tsx @@ -145,11 +145,11 @@ export function useBasicFunctionalityConsolidation(): void { }); }, [completedOnboarding, dispatch, isUnlocked, shouldRunConsolidation]); - // `isUnlocked` is not enough on its own to keep the notice off the lock - // screen, since the keyring unlocks before Login hands the session over. - // Mounting on the wallet stack is what guarantees the handoff has happened; - // `isUnlocked` covers LockScreen, which covers the wallet without - // unmounting it. Clearing the ref while locked lets it present on unlock. + // `isUnlocked` is not enough on its own to keep the notice off the login + // handoff, since the keyring unlocks before Login hands the session over. + // Mounting on the wallet stack is what guarantees the handoff has happened. + // Clearing the ref while locked lets it present on unlock. The privacy + // screen covers the wallet while the vault is locked; it does not unmount it. useEffect(() => { if (!shouldShowBottomSheet || !isUnlocked) { hasPresentedBottomSheet.current = false; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 78f928f9c898..32c850a01808 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -472,7 +472,6 @@ const Routes = { ADD_FUNDS_SHEET: 'PredictAddFundsSheet', }, }, - LOCK_SCREEN: 'LockScreen', CONFIRMATION_REQUEST_MODAL: 'ConfirmationRequestModal', CONFIRMATION_SWITCH_ACCOUNT_TYPE: 'ConfirmationSwitchAccountType', CONFIRMATION_PAY_WITH_MODAL: 'ConfirmationPayWithModal', diff --git a/app/core/AppLock/AppLockService.test.ts b/app/core/AppLock/AppLockService.test.ts new file mode 100644 index 000000000000..70b3dca207cf --- /dev/null +++ b/app/core/AppLock/AppLockService.test.ts @@ -0,0 +1,786 @@ +import { + AppState, + AppStateStatus, + NativeModules, + Platform, +} from 'react-native'; +import { providerErrors } from '@metamask/rpc-errors'; +import { AppLockService, PRIVACY_COVER_MIN_VISIBLE_MS } from './AppLockService'; +import Authentication from '../Authentication'; +import Engine from '../Engine'; +import ReduxService, { type ReduxStore } from '../redux'; +import SecureKeychain from '../SecureKeychain'; +import Logger from '../../util/Logger'; +import Routes from '../../constants/navigation/Routes'; +import { checkForDeeplink, lockApp } from '../../actions/user'; +import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics'; +import PreventScreenshot, { CAPTURE_KEYS } from '../PreventScreenshot'; + +jest.mock('../Engine', () => ({ + context: { + KeyringController: { + setLocked: jest.fn(), + isUnlocked: jest.fn(), + }, + ApprovalController: { + clearRequests: jest.fn(), + }, + }, +})); + +const mockSecureKeychainInstance = { isAuthenticating: false }; +jest.mock('../SecureKeychain', () => ({ + getInstance: jest.fn(), +})); + +const mockNavigate = jest.fn(); +const mockReset = jest.fn(); +jest.mock('../NavigationService', () => ({ + __esModule: true, + default: { + get navigation() { + return { navigate: mockNavigate, reset: mockReset }; + }, + }, +})); + +jest.mock('../Authentication', () => ({ + __esModule: true, + default: { + tryBiometricUnlock: jest.fn(), + }, +})); + +jest.mock('../../util/metrics/TrackError/trackErrorAsAnalytics', () => + jest.fn(), +); + +jest.mock('../../util/Logger', () => ({ + log: jest.fn(), + error: jest.fn(), +})); + +jest.mock('../PreventScreenshot', () => ({ + __esModule: true, + CAPTURE_KEYS: { unlockedWallet: 'metamask-unlocked-wallet' }, + default: { + forbid: jest.fn(() => Promise.resolve()), + allow: jest.fn(() => Promise.resolve()), + }, +})); + +const mockSetLocked = Engine.context.KeyringController.setLocked as jest.Mock; +const mockIsUnlocked = Engine.context.KeyringController.isUnlocked as jest.Mock; +const mockClearRequests = Engine.context.ApprovalController + .clearRequests as jest.Mock; +const mockTryBiometricUnlock = Authentication.tryBiometricUnlock as jest.Mock; +const mockGetInstance = SecureKeychain.getInstance as jest.Mock; +const mockHidePrivacyCover = jest.fn(); + +const LOCK_TIME_NEVER = -1; +const LOCK_TIME_IMMEDIATE = 0; +const LOCK_TIME_30S = 30_000; +const START_TIME = 1_700_000_000_000; + +const flushPromises = async () => { + // Several ticks so chained `.finally` / awaited promises settle. + for (let i = 0; i < 10; i += 1) { + // eslint-disable-next-line no-await-in-loop + await Promise.resolve(); + } +}; + +/** Resolves the wallet unlock so the whole resume path can settle. */ +const settle = async () => { + await flushPromises(); +}; + +describe('AppLockService', () => { + let service: AppLockService; + let emitAppState: (state: AppStateStatus) => void; + let mockDispatch: jest.Mock; + let lockTime: number; + let nowSpy: jest.SpyInstance; + let currentTime: number; + + const setLockTime = (value: number) => { + lockTime = value; + }; + + const advanceClock = (ms: number) => { + currentTime += ms; + nowSpy.mockReturnValue(currentTime); + }; + + const setCurrentAppState = (state: AppStateStatus) => { + Object.defineProperty(AppState, 'currentState', { + value: state, + configurable: true, + writable: true, + }); + }; + + /** Simulates the wallet getting locked as a side effect of `setLocked`. */ + const lockKeyringOnSetLocked = () => { + mockSetLocked.mockImplementation(async () => { + mockIsUnlocked.mockReturnValue(false); + }); + }; + + const expectPrivacyCoverDismissed = () => { + jest.advanceTimersByTime(PRIVACY_COVER_MIN_VISIBLE_MS); + expect(mockHidePrivacyCover).toHaveBeenCalled(); + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + NativeModules.PrivacyCoverModule = { hide: mockHidePrivacyCover }; + lockTime = LOCK_TIME_NEVER; + mockDispatch = jest.fn(); + mockSecureKeychainInstance.isAuthenticating = false; + mockGetInstance.mockReturnValue(mockSecureKeychainInstance); + mockIsUnlocked.mockReturnValue(true); + mockSetLocked.mockResolvedValue(undefined); + mockTryBiometricUnlock.mockImplementation(async () => { + mockIsUnlocked.mockReturnValue(true); + }); + currentTime = START_TIME; + nowSpy = jest.spyOn(Date, 'now').mockReturnValue(currentTime); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + (AppState.addEventListener as jest.Mock).mockImplementation( + (_type, listener) => { + emitAppState = (state: AppStateStatus) => { + setCurrentAppState(state); + listener(state); + }; + return { remove: jest.fn() }; + }, + ); + setCurrentAppState('active'); + service = new AppLockService(); + service.initialize(); + }); + + afterEach(() => { + service.destroy(); + delete NativeModules.PrivacyCoverModule; + jest.useRealTimers(); + nowSpy.mockRestore(); + }); + + describe('screen capture', () => { + const originalOS = Platform.OS; + const originalVersion = Platform.Version; + + afterEach(() => { + Platform.OS = originalOS; + Object.defineProperty(Platform, 'Version', { + get: () => originalVersion, + configurable: true, + }); + }); + + it('blocks capture for the wallet session on Android 12 and older', () => { + Platform.OS = 'android'; + Object.defineProperty(Platform, 'Version', { + get: () => 31, + configurable: true, + }); + + service.start(); + + expect(PreventScreenshot.forbid).toHaveBeenCalledWith( + CAPTURE_KEYS.unlockedWallet, + ); + }); + + it('releases the capture block on logout on Android 12 and older', () => { + Platform.OS = 'android'; + Object.defineProperty(Platform, 'Version', { + get: () => 31, + configurable: true, + }); + service.start(); + + service.stop(); + + expect(PreventScreenshot.allow).toHaveBeenCalledWith( + CAPTURE_KEYS.unlockedWallet, + ); + }); + + it('does not block capture on Android 13 and newer', () => { + Platform.OS = 'android'; + Object.defineProperty(Platform, 'Version', { + get: () => 33, + configurable: true, + }); + + service.start(); + service.stop(); + + expect(PreventScreenshot.forbid).not.toHaveBeenCalled(); + expect(PreventScreenshot.allow).not.toHaveBeenCalled(); + }); + }); + + describe('initialize', () => { + it('subscribes to AppState changes once', () => { + service.initialize(); + + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); + expect(AppState.addEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + }); + }); + + describe('privacy screen', () => { + it('keeps the privacy screen hidden while the app is only inactive', () => { + emitAppState('inactive'); + + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + }); + + it('shows the privacy screen when the app is backgrounded', () => { + emitAppState('background'); + + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + }); + + it('shows the privacy screen on background even when auto-lock is not started', () => { + emitAppState('background'); + + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + expect(mockSetLocked).not.toHaveBeenCalled(); + }); + + it('waits out the minimum visible time before dismissing the privacy cover', async () => { + emitAppState('background'); + + emitAppState('active'); + await settle(); + + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + jest.advanceTimersByTime(PRIVACY_COVER_MIN_VISIBLE_MS - 1); + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(mockHidePrivacyCover).toHaveBeenCalledTimes(1); + }); + + it('dismisses the privacy cover immediately once it has been up for the minimum time', async () => { + emitAppState('background'); + advanceClock(PRIVACY_COVER_MIN_VISIBLE_MS); + + emitAppState('active'); + await settle(); + + expect(mockHidePrivacyCover).toHaveBeenCalledTimes(1); + }); + + it('keeps the privacy cover up when the app backgrounds again before the dismiss delay elapses', async () => { + emitAppState('background'); + emitAppState('active'); + await settle(); + jest.advanceTimersByTime(PRIVACY_COVER_MIN_VISIBLE_MS - 1); + + emitAppState('background'); + jest.advanceTimersByTime(PRIVACY_COVER_MIN_VISIBLE_MS); + + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + }); + + it('hides the privacy screen on resume when auto-lock is not started', async () => { + emitAppState('background'); + + emitAppState('active'); + await settle(); + + expectPrivacyCoverDismissed(); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockTryBiometricUnlock).not.toHaveBeenCalled(); + }); + + it('hides the privacy screen after inactive to active without auth or deeplink check', async () => { + service.start(); + setLockTime(LOCK_TIME_IMMEDIATE); + + emitAppState('inactive'); + + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + + emitAppState('active'); + await settle(); + + expectPrivacyCoverDismissed(); + expect(mockSetLocked).not.toHaveBeenCalled(); + expect(mockTryBiometricUnlock).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); + + describe('auto-lock disabled (lockTime -1)', () => { + beforeEach(() => { + service.start(); + setLockTime(LOCK_TIME_NEVER); + }); + + it('does not lock or hold deeplinks on background', () => { + emitAppState('background'); + + expect(mockSetLocked).not.toHaveBeenCalled(); + expect(service.isAutoLockPending()).toBe(false); + }); + + it('dispatches checkForDeeplink and hides the privacy screen on resume', async () => { + emitAppState('background'); + advanceClock(60 * 60_000); + + emitAppState('active'); + await settle(); + + expect(mockDispatch).toHaveBeenCalledWith(checkForDeeplink()); + expect(mockTryBiometricUnlock).not.toHaveBeenCalled(); + expectPrivacyCoverDismissed(); + }); + }); + + describe('immediate lock (lockTime 0)', () => { + beforeEach(() => { + service.start(); + setLockTime(LOCK_TIME_IMMEDIATE); + lockKeyringOnSetLocked(); + }); + + it('locks the keyring, rejects pending approvals and dispatches lockApp on background', async () => { + emitAppState('background'); + await settle(); + + expect(mockSetLocked).toHaveBeenCalledTimes(1); + expect(mockClearRequests).toHaveBeenCalledWith( + providerErrors.userRejectedRequest(), + ); + expect(mockDispatch).toHaveBeenCalledWith(lockApp()); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(service.isAutoLockPending()).toBe(true); + }); + + it('still dispatches lockApp when rejecting approvals throws', async () => { + mockClearRequests.mockImplementationOnce(() => { + throw new Error('clear failed'); + }); + + emitAppState('background'); + await settle(); + + expect(mockDispatch).toHaveBeenCalledWith(lockApp()); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(service.isAutoLockPending()).toBe(true); + expect(Logger.error).toHaveBeenCalled(); + }); + + it('prompts biometric unlock on resume and hides the privacy screen once it resolves', async () => { + let resolveUnlock: () => void = () => undefined; + mockTryBiometricUnlock.mockImplementation( + () => + new Promise((resolve) => { + resolveUnlock = () => { + mockIsUnlocked.mockReturnValue(true); + resolve(); + }; + }), + ); + emitAppState('background'); + await settle(); + + emitAppState('active'); + await flushPromises(); + + expect(mockTryBiometricUnlock).toHaveBeenCalledWith({ + navigationBehavior: 'preserve', + }); + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + expect(service.isAutoLockPending()).toBe(true); + + mockDispatch.mockImplementation((action) => { + if (action?.type === checkForDeeplink().type) { + expect(service.isAutoLockPending()).toBe(false); + } + }); + resolveUnlock(); + await settle(); + + expect(service.isAutoLockPending()).toBe(false); + expectPrivacyCoverDismissed(); + expect(mockDispatch).toHaveBeenCalledWith(checkForDeeplink()); + }); + + it('resets to Login and tracks the error when biometric unlock fails', async () => { + mockTryBiometricUnlock.mockRejectedValue(new Error('user cancelled')); + emitAppState('background'); + await settle(); + + emitAppState('active'); + await settle(); + + expect(mockReset).toHaveBeenCalledWith({ + routes: [{ name: Routes.ONBOARDING.LOGIN }], + }); + expect(trackErrorAsAnalytics).toHaveBeenCalledWith( + 'Lockscreen: Authentication failed', + 'user cancelled', + ); + expectPrivacyCoverDismissed(); + }); + + it('does not lock while a keychain prompt is in progress and lets a deeplink parse on resume', async () => { + mockSecureKeychainInstance.isAuthenticating = true; + + emitAppState('background'); + await settle(); + emitAppState('active'); + await settle(); + + expect(mockSetLocked).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalledWith(lockApp()); + expect(mockDispatch).toHaveBeenCalledWith(checkForDeeplink()); + expectPrivacyCoverDismissed(); + }); + + it('logs and does not dispatch lockApp when setLocked rejects', async () => { + mockSetLocked.mockRejectedValue(new Error('keyring busy')); + + emitAppState('background'); + await settle(); + + expect(Logger.log).toHaveBeenCalledWith( + 'AppLockService: Failed to lock KeyringController', + expect.any(Error), + ); + expect(mockDispatch).not.toHaveBeenCalledWith(lockApp()); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + describe('timed lock (lockTime 30s)', () => { + beforeEach(() => { + service.start(); + setLockTime(LOCK_TIME_30S); + lockKeyringOnSetLocked(); + }); + + it('holds deeplinks on background without locking', () => { + emitAppState('background'); + + expect(mockSetLocked).not.toHaveBeenCalled(); + expect(service.isAutoLockPending()).toBe(true); + }); + + it('releases the deeplink hold before parsing a link when resumed before the lock time', async () => { + emitAppState('background'); + advanceClock(LOCK_TIME_30S - 1); + mockDispatch.mockImplementation(() => { + expect(service.isAutoLockPending()).toBe(false); + }); + + emitAppState('active'); + await settle(); + + expect(mockSetLocked).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith(checkForDeeplink()); + expect(service.isAutoLockPending()).toBe(false); + expectPrivacyCoverDismissed(); + }); + + it('does not lock when Android resumes through background, inactive, active', async () => { + emitAppState('background'); + emitAppState('inactive'); + expect(service.isAutoLockPending()).toBe(true); + + emitAppState('active'); + await settle(); + + expect(mockSetLocked).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith(checkForDeeplink()); + expect(service.isAutoLockPending()).toBe(false); + }); + + it('locks on resume from elapsed wall-clock time', async () => { + emitAppState('background'); + advanceClock(LOCK_TIME_30S); + + emitAppState('active'); + await settle(); + + expect(mockSetLocked).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith(lockApp()); + expect(mockTryBiometricUnlock).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith(checkForDeeplink()); + expect(service.isAutoLockPending()).toBe(false); + expectPrivacyCoverDismissed(); + }); + + it('keeps the deeplink hold while the resume lock is in flight', async () => { + let releaseLock: () => void = () => undefined; + mockSetLocked.mockImplementation( + () => + new Promise((resolve) => { + releaseLock = () => { + mockIsUnlocked.mockReturnValue(false); + resolve(); + }; + }), + ); + emitAppState('background'); + advanceClock(LOCK_TIME_30S); + expect(service.isAutoLockPending()).toBe(true); + + emitAppState('active'); + expect(service.isAutoLockPending()).toBe(true); + await flushPromises(); + + expect(mockTryBiometricUnlock).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalledWith(checkForDeeplink()); + expect(service.isAutoLockPending()).toBe(true); + + releaseLock(); + await settle(); + + expect(mockDispatch).toHaveBeenCalledWith(lockApp()); + expect(mockTryBiometricUnlock).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith(checkForDeeplink()); + expect(service.isAutoLockPending()).toBe(false); + }); + + it('keeps the deeplink hold when the app backgrounds again before the resume decision finishes', async () => { + let releaseLock: () => void = () => undefined; + mockSetLocked.mockImplementation( + () => + new Promise((resolve) => { + releaseLock = () => { + mockIsUnlocked.mockReturnValue(false); + resolve(); + }; + }), + ); + emitAppState('background'); + advanceClock(LOCK_TIME_30S); + emitAppState('active'); + await flushPromises(); + + emitAppState('background'); + releaseLock(); + await settle(); + + expect(service.isAutoLockPending()).toBe(true); + expect(mockTryBiometricUnlock).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalledWith(checkForDeeplink()); + }); + }); + + describe('resume resolution concurrency', () => { + let resolveUnlock: () => void; + let rejectUnlock: (error: Error) => void; + + beforeEach(async () => { + service.start(); + setLockTime(LOCK_TIME_IMMEDIATE); + lockKeyringOnSetLocked(); + mockTryBiometricUnlock.mockImplementation( + () => + new Promise((resolve, reject) => { + resolveUnlock = resolve; + rejectUnlock = reject; + }), + ); + emitAppState('background'); + await settle(); + emitAppState('active'); + await flushPromises(); + expect(mockTryBiometricUnlock).toHaveBeenCalledTimes(1); + }); + + it('does not prompt again when the biometric sheet bounces the app through inactive and active', async () => { + emitAppState('inactive'); + emitAppState('active'); + await flushPromises(); + + expect(mockTryBiometricUnlock).toHaveBeenCalledTimes(1); + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + + resolveUnlock(); + await settle(); + + expectPrivacyCoverDismissed(); + }); + + it('does not prompt again when Android backgrounds for the system biometric prompt', async () => { + mockSecureKeychainInstance.isAuthenticating = true; + + emitAppState('background'); + emitAppState('active'); + await flushPromises(); + + expect(mockTryBiometricUnlock).toHaveBeenCalledTimes(1); + + resolveUnlock(); + await settle(); + + expectPrivacyCoverDismissed(); + }); + + it('keeps the privacy screen up when the prompt fails while backgrounded and resolves on the next resume', async () => { + emitAppState('background'); + rejectUnlock(new Error('cancelled by system')); + await settle(); + + expect(mockReset).toHaveBeenCalledWith({ + routes: [{ name: Routes.ONBOARDING.LOGIN }], + }); + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + + // The wallet is still locked when the user comes back. + mockTryBiometricUnlock.mockResolvedValue(undefined); + emitAppState('active'); + await settle(); + + expect(mockTryBiometricUnlock).toHaveBeenCalledTimes(2); + expectPrivacyCoverDismissed(); + }); + + it('does not lock from a stale background timestamp after a joined resume', async () => { + // Background during the prompt, then the prompt succeeds and the wallet + // is unlocked again. A later inactive -> active must not re-lock. + mockSecureKeychainInstance.isAuthenticating = true; + emitAppState('background'); + emitAppState('active'); + await flushPromises(); + mockIsUnlocked.mockReturnValue(true); + mockSecureKeychainInstance.isAuthenticating = false; + resolveUnlock(); + await settle(); + mockSetLocked.mockClear(); + advanceClock(60_000); + + emitAppState('inactive'); + emitAppState('active'); + await settle(); + + expect(mockSetLocked).not.toHaveBeenCalled(); + expectPrivacyCoverDismissed(); + }); + }); + + describe('Android authentication resume race', () => { + const originalOS = Platform.OS; + let releaseAuthenticationReady: () => void; + let cancelAuthenticationReady: () => void; + + beforeEach(() => { + Platform.OS = 'android'; + NativeModules.PrivacyCoverModule.waitUntilAuthenticationReady = jest.fn( + () => + new Promise((resolve, reject) => { + releaseAuthenticationReady = resolve; + cancelAuthenticationReady = () => { + reject(new Error('ACTIVITY_PAUSED')); + }; + }), + ); + service.start(); + setLockTime(LOCK_TIME_IMMEDIATE); + lockKeyringOnSetLocked(); + }); + + afterEach(() => { + Platform.OS = originalOS; + }); + + it('retries the latest foreground after backgrounding before the auth prompt appears', async () => { + emitAppState('background'); + await settle(); + emitAppState('active'); + await flushPromises(); + + expect(mockTryBiometricUnlock).not.toHaveBeenCalled(); + + emitAppState('background'); + cancelAuthenticationReady(); + await settle(); + + expect(mockTryBiometricUnlock).not.toHaveBeenCalled(); + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + + emitAppState('active'); + await flushPromises(); + releaseAuthenticationReady(); + await settle(); + + expect(mockTryBiometricUnlock).toHaveBeenCalledTimes(1); + expect(service.isAutoLockPending()).toBe(false); + expectPrivacyCoverDismissed(); + }); + }); + + describe('stop', () => { + it('releases the deeplink hold and disables auto-lock', async () => { + service.start(); + setLockTime(LOCK_TIME_30S); + emitAppState('background'); + + service.stop(); + + expect(service.isAutoLockPending()).toBe(false); + + advanceClock(60_000); + emitAppState('active'); + await settle(); + + expect(mockSetLocked).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + expectPrivacyCoverDismissed(); + }); + }); + + describe('error handling', () => { + it('logs when reading state throws during background handling', () => { + service.start(); + jest.spyOn(ReduxService, 'store', 'get').mockImplementation(() => { + throw new Error('Redux store does not exist!'); + }); + + emitAppState('background'); + + expect(Logger.error).toHaveBeenCalledWith( + expect.any(Error), + 'AppLockService: Error handling app state change', + ); + expect(mockHidePrivacyCover).not.toHaveBeenCalled(); + }); + + it('logs and hides the privacy screen when resume resolution throws', async () => { + service.start(); + setLockTime(LOCK_TIME_IMMEDIATE); + emitAppState('background'); + await settle(); + mockIsUnlocked.mockImplementation(() => { + throw new Error('engine not ready'); + }); + + emitAppState('active'); + await settle(); + + expect(Logger.error).toHaveBeenCalledWith( + expect.any(Error), + 'AppLockService: Error resolving foreground', + ); + expect(service.isAutoLockPending()).toBe(false); + expectPrivacyCoverDismissed(); + }); + }); +}); diff --git a/app/core/AppLock/AppLockService.ts b/app/core/AppLock/AppLockService.ts new file mode 100644 index 000000000000..e26fc3b8ab00 --- /dev/null +++ b/app/core/AppLock/AppLockService.ts @@ -0,0 +1,514 @@ +import { + AppState, + AppStateStatus, + NativeEventSubscription, + NativeModules, + Platform, +} from 'react-native'; +import { providerErrors } from '@metamask/rpc-errors'; +import Engine from '../Engine'; +import Logger from '../../util/Logger'; +import ReduxService from '../redux'; +import SecureKeychain from '../SecureKeychain'; +import NavigationService from '../NavigationService'; +import Routes from '../../constants/navigation/Routes'; +import { lockApp, checkForDeeplink } from '../../actions/user'; +import { selectLockTime } from '../../selectors/settings'; +import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics'; +import PreventScreenshot, { CAPTURE_KEYS } from '../PreventScreenshot'; +import Authentication from '../Authentication'; + +/** + * `Activity.setRecentsScreenshotEnabled` exists from Android 13. Below that, + * the only way to blank the Recents card is FLAG_SECURE, and it has to be set + * before the user leaves. Holding it also blocks screenshots while the app is + * open, so it is limited to those older versions. + */ +const RECENTS_SCREENSHOT_API = 33; + +/** + * Minimum time the privacy cover stays up. A fast resume otherwise paints and + * removes the fox in a few frames. A cover already up longer than this + * dismisses immediately. + */ +export const PRIVACY_COVER_MIN_VISIBLE_MS = 250; + +interface PrivacyCoverNativeModule { + hide: () => void; + /** + * Android only. Resolves after `onPostResume` and rejects if `onPause` + * invalidates that resume before authentication starts. + */ + waitUntilAuthenticationReady?: () => Promise; +} + +const holdsSecureFlagForRecents = (): boolean => + Platform.OS === 'android' && + Number(Platform.Version) < RECENTS_SCREENSHOT_API; + +/** + * Single owner of the app's background / foreground lock lifecycle. + * + * Responsibilities: + * - Privacy cover: the native view is shown by the OS lifecycle. This class + * records that on full `background` (not iOS `inactive`) and calls + * `PrivacyCoverModule.hide` once resume routing has resolved, after the cover + * has been up for at least 250ms. + * - Auto-lock: records when the app was backgrounded and, on resume, decides + * from wall-clock time whether to lock. `settings.lockTime` is `-1` (never), + * `0` (immediate), or a duration in milliseconds. A timed lock does not run + * while the app is away; the keys stay until the user returns. + * - Resume routing: if locked, prompt authentication (biometrics, with Login + * as the fallback); if still unlocked, let a pending deeplink parse. + * + * Lifecycle: + * - `initialize()` once at startup: subscribes to AppState. The privacy + * screen works regardless of login state. + * - `start()` / `stop()` on LOGIN / LOGOUT: enables / disables auto-lock and + * the auth prompt on resume. + * + * Concurrency: a single in-flight resume resolution. Any `active` event that + * arrives while one is running (e.g. the system biometric sheet toggling + * `inactive` -> `active` on iOS, or `background` -> `active` on Android) + * joins it instead of prompting again. + */ +export class AppLockService { + #appStateSubscription?: NativeEventSubscription; + #currentAppState: AppStateStatus = AppState.currentState; + + /** True between LOGIN and LOGOUT. */ + #isAutoLockEnabled = false; + + /** `Date.now()` when the app last entered `background`. */ + #backgroundedAt?: number; + /** + * True from background until resume has either left the wallet unlocked or + * finished the unlock prompt. Holds deeplinks across that whole stretch. + */ + #lockDecisionPending = false; + #lockPromise?: Promise; + #resolveForegroundPromise?: Promise; + /** `Date.now()` when the privacy cover was last shown. */ + #privacyCoverShownAtMs?: number; + /** Waits out the remainder of the minimum visible time before the native dismiss. */ + #privacyCoverDismissTimer?: ReturnType; + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + /** + * Subscribes to AppState. Safe to call more than once. + */ + initialize = (): void => { + if (this.#appStateSubscription) { + return; + } + this.#currentAppState = AppState.currentState; + this.#appStateSubscription = AppState.addEventListener( + 'change', + this.#handleAppStateChange, + ); + }; + + /** + * Enables auto-lock and resume authentication. Called on LOGIN. + * On Android 12 and older, blocks screen capture for the session so the + * Recents card is blank before the user ever leaves. Android 13+ blanks + * Recents natively and keeps screenshots working. Released in `stop`. + */ + start = (): void => { + this.#isAutoLockEnabled = true; + if (!holdsSecureFlagForRecents()) { + return; + } + PreventScreenshot.forbid(CAPTURE_KEYS.unlockedWallet).catch((error) => { + Logger.error( + error as Error, + 'AppLockService: Failed to block screen capture', + ); + }); + }; + + /** + * Disables auto-lock and resume authentication. Called on LOGOUT. + */ + stop = (): void => { + this.#isAutoLockEnabled = false; + this.#lockDecisionPending = false; + this.#backgroundedAt = undefined; + this.#releaseSecureFlag(); + }; + + /** + * True from background until resume has either left the wallet unlocked or + * finished the unlock prompt. Deeplink parsing must wait: a lock would + * reset navigation, and unlock dispatches onboarding-complete before + * post-unlock navigation reads the pending link. + */ + isAutoLockPending = (): boolean => this.#lockDecisionPending; + + /** + * Removes the AppState subscription and resets all state. Test-only. + */ + destroy = (): void => { + this.#appStateSubscription?.remove(); + this.#appStateSubscription = undefined; + this.#lockDecisionPending = false; + this.#isAutoLockEnabled = false; + this.#backgroundedAt = undefined; + this.#lockPromise = undefined; + this.#resolveForegroundPromise = undefined; + this.#clearPrivacyCoverDismissTimer(); + this.#privacyCoverShownAtMs = undefined; + this.#releaseSecureFlag(); + }; + + /** + * Drops the pre-Android 13 capture block. No-op where Recents is suppressed + * natively, so a release cannot clear FLAG_SECURE for another owner. + */ + readonly #releaseSecureFlag = (): void => { + if (!holdsSecureFlagForRecents()) { + return; + } + PreventScreenshot.allow(CAPTURE_KEYS.unlockedWallet).catch((error) => { + Logger.error( + error as Error, + 'AppLockService: Failed to release screen capture block', + ); + }); + }; + + /** + * Records that the native cover is up. Cancels a dismiss that has not fired + * yet, so backgrounding again during the minimum visible time keeps it up. + */ + readonly #showPrivacyCover = (): void => { + const dismissWasPending = this.#privacyCoverDismissTimer !== undefined; + this.#clearPrivacyCoverDismissTimer(); + if (this.#privacyCoverShownAtMs !== undefined && !dismissWasPending) { + return; + } + this.#privacyCoverShownAtMs = Date.now(); + }; + + /** + * Dismisses the native cover. A cover shown by the OS before this class + * recorded it is dismissed immediately. Otherwise the dismiss waits until + * the cover has been up for `PRIVACY_COVER_MIN_VISIBLE_MS`. + */ + readonly #hidePrivacyCover = (): void => { + if (this.#privacyCoverDismissTimer !== undefined) { + return; + } + const elapsedMs = + this.#privacyCoverShownAtMs === undefined + ? PRIVACY_COVER_MIN_VISIBLE_MS + : Date.now() - this.#privacyCoverShownAtMs; + const remainingMs = PRIVACY_COVER_MIN_VISIBLE_MS - elapsedMs; + if (remainingMs <= 0) { + this.#dismissPrivacyCoverNow(); + return; + } + this.#privacyCoverDismissTimer = setTimeout(() => { + this.#privacyCoverDismissTimer = undefined; + this.#privacyCoverShownAtMs = undefined; + this.#dismissNativePrivacyCover(); + }, remainingMs); + }; + + readonly #dismissPrivacyCoverNow = (): void => { + this.#clearPrivacyCoverDismissTimer(); + this.#privacyCoverShownAtMs = undefined; + this.#dismissNativePrivacyCover(); + }; + + readonly #clearPrivacyCoverDismissTimer = (): void => { + if (this.#privacyCoverDismissTimer === undefined) { + return; + } + clearTimeout(this.#privacyCoverDismissTimer); + this.#privacyCoverDismissTimer = undefined; + }; + + readonly #dismissNativePrivacyCover = (): void => { + const privacyCoverModule: PrivacyCoverNativeModule | undefined = + NativeModules.PrivacyCoverModule; + privacyCoverModule?.hide(); + }; + + // --------------------------------------------------------------------------- + // AppState handling + // --------------------------------------------------------------------------- + + readonly #handleAppStateChange = (nextAppState: AppStateStatus): void => { + this.#currentAppState = nextAppState; + try { + switch (nextAppState) { + case 'inactive': + // iOS only. The app switcher, Control Center, and system sheets + // (including Face ID) fire `inactive` while the app is still on + // screen. The cover waits for a full `background`. Android does + // not emit `inactive`. + break; + case 'background': + this.#showPrivacyCover(); + this.#onBackground(); + break; + case 'active': + this.#onForeground(); + break; + default: + break; + } + } catch (error) { + Logger.error( + error as Error, + 'AppLockService: Error handling app state change', + ); + } + }; + + readonly #onBackground = (): void => { + this.#backgroundedAt = Date.now(); + + if (!this.#isAutoLockEnabled) { + return; + } + + const lockTime = selectLockTime(ReduxService.store.getState()); + if (!this.#isLockTimeActive(lockTime)) { + return; + } + + // Hold deeplinks until resume decides. The vault stays unlocked until then + // unless the lock time is immediate. + this.#lockDecisionPending = true; + if (lockTime === 0) { + this.#startLock().catch((error) => { + Logger.error( + error as Error, + 'AppLockService: Failed to lock on background', + ); + }); + } + }; + + /** `settings.lockTime` of `0` or a positive duration. `-1` and non-numbers are off. */ + readonly #isLockTimeActive = (lockTime: number): boolean => + Number.isFinite(lockTime) && lockTime >= 0; + + readonly #onForeground = (): void => { + if (this.#resolveForegroundPromise) { + if (SecureKeychain.getInstance().isAuthenticating) { + // Android backgrounds the host Activity for its credential sheet. + // Returning from that sheet belongs to the current unlock attempt, + // so it must not become another auto-lock cycle. + this.#backgroundedAt = undefined; + } + // Otherwise do not consume a newer background timestamp. Once the + // current resolution settles, its `finally` starts a fresh pass. + return; + } + + const backgroundedAt = this.#backgroundedAt; + this.#backgroundedAt = undefined; + + this.#resolveForegroundPromise = this.#resolveForeground(backgroundedAt) + .catch((error) => { + Logger.error( + error as Error, + 'AppLockService: Error resolving foreground', + ); + }) + .finally(() => { + this.#resolveForegroundPromise = undefined; + if (this.#currentAppState !== 'active') { + return; + } + if (this.#backgroundedAt !== undefined) { + this.#onForeground(); + return; + } + this.#hidePrivacyCover(); + }); + }; + + // --------------------------------------------------------------------------- + // Resume resolution + // --------------------------------------------------------------------------- + + /** + * Decides where the user goes after resume. The privacy screen stays up for + * the whole duration (dismissed by the caller once this settles). + */ + readonly #resolveForeground = async ( + backgroundedAt: number | undefined, + ): Promise => { + try { + // Let a lock that already started finish before reading state. + if (this.#lockPromise) { + await this.#lockPromise; + } + + if (!this.#isAutoLockEnabled) { + // Logged out / onboarding / cold start: nothing to resolve. + return; + } + + if (backgroundedAt === undefined) { + // inactive -> active without a background (Control Center, Face ID + // sheet, share sheet): no time was spent in the background. + return; + } + + const { KeyringController } = Engine.context; + const lockTime = selectLockTime(ReduxService.store.getState()); + + if ( + KeyringController.isUnlocked() && + this.#isLockTimeActive(lockTime) && + Date.now() - backgroundedAt >= lockTime + ) { + await this.#startLock(); + } + + // A background that started during this resolution owns the next + // decision. Leave the deeplink hold in place for it. + if (this.#backgroundedAt !== undefined) { + return; + } + + if (KeyringController.isUnlocked()) { + // Still unlocked: drop the hold before the saga parses the link. + this.#lockDecisionPending = false; + ReduxService.store.dispatch(checkForDeeplink()); + return; + } + + // Stay pending through the prompt so an onboarding-complete dispatch + // cannot parse a deeplink before authentication finishes. + const authenticationReady = + await this.#waitUntilAndroidAuthenticationReady(); + if ( + !authenticationReady || + this.#currentAppState !== 'active' || + this.#backgroundedAt !== undefined + ) { + return; + } + await this.#promptUnlock(); + if ( + this.#currentAppState !== 'active' || + this.#backgroundedAt !== undefined || + !KeyringController.isUnlocked() + ) { + return; + } + // Resume does not reset navigation. A waiting deeplink is the only + // thing that may move the user, and the hold must already be clear. + this.#lockDecisionPending = false; + ReduxService.store.dispatch(checkForDeeplink()); + } finally { + if (this.#backgroundedAt === undefined) { + this.#lockDecisionPending = false; + } + } + }; + + /** + * Android reports `active` from `onResume`, before `onPostResume`. The PIN + * prompt is only safe after `onPostResume`. `onPause` rejects this wait so + * a resume that already ended cannot start authentication. + */ + readonly #waitUntilAndroidAuthenticationReady = + async (): Promise => { + if (Platform.OS !== 'android') { + return true; + } + const privacyCoverModule: PrivacyCoverNativeModule | undefined = + NativeModules.PrivacyCoverModule; + if (!privacyCoverModule?.waitUntilAuthenticationReady) { + return true; + } + try { + await privacyCoverModule.waitUntilAuthenticationReady(); + return ( + this.#currentAppState === 'active' && + this.#backgroundedAt === undefined + ); + } catch { + return false; + } + }; + + /** + * Prompts authentication, falling back to the Login screen on failure. + * The privacy screen is the only cover while this runs. + */ + readonly #promptUnlock = async (): Promise => { + try { + await Authentication.tryBiometricUnlock({ + navigationBehavior: 'preserve', + }); + } catch (error) { + NavigationService.navigation?.reset({ + routes: [{ name: Routes.ONBOARDING.LOGIN }], + }); + trackErrorAsAnalytics( + 'Lockscreen: Authentication failed', + (error as Error)?.message, + ).catch(() => undefined); + } + }; + + // --------------------------------------------------------------------------- + // Locking + // --------------------------------------------------------------------------- + + readonly #startLock = (): Promise => { + if (!this.#lockPromise) { + this.#lockPromise = this.#lockNow().finally(() => { + this.#lockPromise = undefined; + }); + } + return this.#lockPromise; + }; + + /** + * The only place the wallet is auto-locked. Skipped while a keychain / + * biometric prompt is in progress: the system sheet backgrounds the app on + * Android, and locking underneath it would tear down the very unlock the + * user is completing. + */ + readonly #lockNow = async (): Promise => { + if (SecureKeychain.getInstance().isAuthenticating) { + return; + } + + const { KeyringController, ApprovalController } = Engine.context; + try { + await KeyringController.setLocked(); + } catch (error) { + Logger.log('AppLockService: Failed to lock KeyringController', error); + return; + } + + // Reject pending confirmations so a stale one is not shown after unlock. + try { + if (ApprovalController) { + ApprovalController.clearRequests(providerErrors.userRejectedRequest()); + } + } catch (error) { + Logger.error( + error as Error, + 'AppLockService: Failed to reject pending approvals on app lock', + ); + } + + ReduxService.store.dispatch(lockApp()); + }; +} + +export default new AppLockService(); diff --git a/app/core/AppLock/index.ts b/app/core/AppLock/index.ts new file mode 100644 index 000000000000..a95662b087c0 --- /dev/null +++ b/app/core/AppLock/index.ts @@ -0,0 +1 @@ +export { default, AppLockService } from './AppLockService'; diff --git a/app/core/AppStateService/AppStateService.test.ts b/app/core/AppStateService/AppStateService.test.ts deleted file mode 100644 index fc3c5ecb685b..000000000000 --- a/app/core/AppStateService/AppStateService.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { AppStateServiceImplementation } from './AppStateService'; -import { AppState } from 'react-native'; - -// Mock dependencies -jest.mock('react-native', () => ({ - AppState: { - currentState: 'active', - addEventListener: jest.fn(), - }, -})); - -jest.mock('react-native-background-timer', () => ({ - setTimeout: jest.fn((callback, delay) => { - const timer = global.setTimeout(callback, delay); - return timer as unknown as number; - }), - clearTimeout: jest.fn((timer) => { - global.clearTimeout(timer); - }), -})); - -jest.mock('../../util/Logger', () => ({ - log: jest.fn(), - error: jest.fn(), -})); - -describe('AppStateAPI', () => { - let api: AppStateServiceImplementation; - let mockAddEventListener: jest.Mock; - let mockRemove: jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - - // Get the singleton instance - api = AppStateServiceImplementation.getInstance(); - - // Clear any existing listeners and state - api.removeAllListeners(); - api.cleanup(); - - // Setup mock for addEventListener - mockRemove = jest.fn(); - mockAddEventListener = AppState.addEventListener as jest.Mock; - mockAddEventListener.mockReturnValue({ - remove: mockRemove, - }); - }); - - afterEach(() => { - api.cleanup(); - api.removeAllListeners(); - }); - - describe('initialization', () => { - it('should initialize successfully', () => { - api.initialize(); - expect(mockAddEventListener).toHaveBeenCalledWith( - 'change', - expect.any(Function), - ); - expect(api.isInitialized()).toBe(true); - }); - - it('should not initialize twice', () => { - api.initialize(); - api.initialize(); - expect(mockAddEventListener).toHaveBeenCalledTimes(1); - }); - - it('should cleanup properly', () => { - // Arrange - api.initialize(); - const localMockRemove = jest.fn(); - mockAddEventListener.mockReturnValue({ remove: localMockRemove }); - - // Act - api.cleanup(); - - // Assert - expect(api.isInitialized()).toBe(false); - }); - }); - - describe('app state monitoring', () => { - it('should get current app state', () => { - // Arrange - ensure state is set - api.initialize(); - const changeHandler = mockAddEventListener.mock.calls[0][1]; - changeHandler('active'); - - // Act - const currentState = api.getCurrentAppState(); - - // Assert - expect(currentState).toBe('active'); - }); - - it('should emit foreground event when app becomes active', () => { - const handler = jest.fn(); - api.on('foreground', handler); - - api.initialize(); - const changeHandler = mockAddEventListener.mock.calls[0][1]; - - // Simulate app going to background then foreground - changeHandler('background'); - changeHandler('active'); - - expect(handler).toHaveBeenCalledWith('active'); - }); - - it('should emit background event when app goes to background', () => { - const handler = jest.fn(); - api.on('background', handler); - - api.initialize(); - const changeHandler = mockAddEventListener.mock.calls[0][1]; - - changeHandler('background'); - - expect(handler).toHaveBeenCalledWith('background'); - }); - - it('should emit change event on any state change', () => { - const handler = jest.fn(); - api.on('change', handler); - - api.initialize(); - const changeHandler = mockAddEventListener.mock.calls[0][1]; - - changeHandler('inactive'); - - expect(handler).toHaveBeenCalledWith('inactive'); - }); - }); - - describe('lock timer', () => { - it('should start lock timer', () => { - const callback = jest.fn(); - api.startLockTimer(1000, callback); - - expect(api.isLockTimerActive()).toBe(true); - }); - - it('should call callback when timer expires', (done) => { - const callback = jest.fn(() => { - expect(callback).toHaveBeenCalled(); - done(); - }); - - api.startLockTimer(100, callback); - }); - - it('should clear lock timer', () => { - const callback = jest.fn(); - api.startLockTimer(1000, callback); - - api.clearLockTimer(); - - expect(api.isLockTimerActive()).toBe(false); - }); - - it('should return remaining time', () => { - // Arrange - const callback = jest.fn(); - - // Act - api.startLockTimer(5000, callback); - const remaining = api.getLockTimerRemaining(); - - // Assert - expect(remaining).toBeGreaterThan(0); - expect(remaining).toBeLessThanOrEqual(5000); - - // Cleanup - clear timer to not affect other tests - api.clearLockTimer(); - }); - - it('should return null when no timer active', () => { - // Arrange - ensure no timer is active - api.clearLockTimer(); - - // Act - const remaining = api.getLockTimerRemaining(); - - // Assert - expect(remaining).toBeNull(); - }); - - it('should clear existing timer when starting new one', () => { - const callback1 = jest.fn(); - const callback2 = jest.fn(); - - api.startLockTimer(1000, callback1); - api.startLockTimer(2000, callback2); - - expect(api.isLockTimerActive()).toBe(true); - }); - }); - - describe('state queries', () => { - it('should check if app is in foreground', () => { - api.initialize(); - const changeHandler = mockAddEventListener.mock.calls[0][1]; - - changeHandler('active'); - expect(api.isAppInForeground()).toBe(true); - - changeHandler('background'); - expect(api.isAppInForeground()).toBe(false); - }); - - it('should check if app is in background', () => { - api.initialize(); - const changeHandler = mockAddEventListener.mock.calls[0][1]; - - changeHandler('background'); - expect(api.isAppInBackground()).toBe(true); - - changeHandler('active'); - expect(api.isAppInBackground()).toBe(false); - }); - - it('should check if app is inactive', () => { - api.initialize(); - const changeHandler = mockAddEventListener.mock.calls[0][1]; - - changeHandler('inactive'); - expect(api.isAppInactive()).toBe(true); - - changeHandler('active'); - expect(api.isAppInactive()).toBe(false); - }); - }); - - describe('event emitter', () => { - it('should allow registering event handlers', () => { - const handler = jest.fn(); - api.on('background', handler); - - api.initialize(); - const changeHandler = mockAddEventListener.mock.calls[0][1]; - - changeHandler('background'); - - expect(handler).toHaveBeenCalled(); - }); - - it('should allow removing event handlers', () => { - const handler = jest.fn(); - api.on('background', handler); - api.off('background', handler); - - api.initialize(); - const changeHandler = mockAddEventListener.mock.calls[0][1]; - - changeHandler('background'); - - expect(handler).not.toHaveBeenCalled(); - }); - - it('should remove all listeners on cleanup', () => { - const handler1 = jest.fn(); - const handler2 = jest.fn(); - - api.on('background', handler1); - api.on('foreground', handler2); - - api.initialize(); - api.cleanup(); - - const changeHandler = mockAddEventListener.mock.calls[0][1]; - changeHandler('background'); - - expect(handler1).not.toHaveBeenCalled(); - expect(handler2).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/app/core/AppStateService/AppStateService.ts b/app/core/AppStateService/AppStateService.ts deleted file mode 100644 index 9c88ede0739b..000000000000 --- a/app/core/AppStateService/AppStateService.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * AppStateAPI - App state monitoring and lock timer management - * - * This API monitors app state changes (background/foreground/inactive) and - * provides lock timer functionality. It does NOT perform locking - it only - * emits events that the state machine listens to. - * - * Key principle: Separation of concerns - * - This API: Monitors and emits events - * - State machine: Handles the actual locking - */ - -import { - AppState, - AppStateStatus, - NativeEventSubscription, -} from 'react-native'; -import BackgroundTimer from 'react-native-background-timer'; -import Logger from '../../util/Logger'; -import EventEmitter from 'eventemitter2'; - -/** - * AppStateService monitors app state and emits events. - * Uses EventEmitter pattern for clean separation of concerns. - */ -class AppStateServiceImplementation extends EventEmitter { - private static instance: AppStateServiceImplementation; - - // Current app state - private currentAppState: AppStateStatus = AppState.currentState; - - // Native listener - private appStateListener?: NativeEventSubscription; - - // Lock timer (timing only, not locking) - private lockTimer?: number; - private lockTimerStartTime?: number; - private lockTimerDuration?: number; - private lockCallback?: () => void; - - // Initialization state - private initialized: boolean = false; - - private constructor() { - super(); - this.currentAppState = AppState.currentState; - } - - /** - * Get singleton instance - */ - public static getInstance(): AppStateServiceImplementation { - if (!AppStateServiceImplementation.instance) { - AppStateServiceImplementation.instance = - new AppStateServiceImplementation(); - } - return AppStateServiceImplementation.instance; - } - - // ========================================================================== - // Lifecycle - // ========================================================================== - - /** - * Initialize the AppStateAPI - * Sets up native app state listener - */ - initialize(): void { - if (this.initialized) { - Logger.log('AppStateAPI: Already initialized'); - return; - } - - this.appStateListener = AppState.addEventListener( - 'change', - this.handleAppStateChange, - ); - - this.initialized = true; - Logger.log('AppStateAPI: Initialized'); - } - - /** - * Cleanup the AppStateAPI - * Removes listeners and clears timers - */ - cleanup(): void { - if (!this.initialized) { - return; - } - - if (this.appStateListener) { - this.appStateListener.remove(); - this.appStateListener = undefined; - } - - this.clearLockTimer(); - this.removeAllListeners(); - - this.initialized = false; - Logger.log('AppStateAPI: Cleaned up'); - } - - // ========================================================================== - // App State Monitoring - // ========================================================================== - - /** - * Handle app state changes from React Native - * Emits specific events based on state transitions - */ - private handleAppStateChange = (nextAppState: AppStateStatus) => { - const previousState = this.currentAppState; - - try { - // Update current state - this.currentAppState = nextAppState; - - // Emit specific event based on state - if (nextAppState === 'active' && previousState !== 'active') { - this.emit('foreground', nextAppState); - Logger.log('AppStateAPI: App foregrounded'); - } else if ( - nextAppState === 'background' && - previousState !== 'background' - ) { - this.emit('background', nextAppState); - Logger.log('AppStateAPI: App backgrounded'); - } else if (nextAppState === 'inactive') { - this.emit('inactive', nextAppState); - Logger.log('AppStateAPI: App inactive'); - } - - // Always emit generic change event - this.emit('change', nextAppState); - } catch (error) { - Logger.error( - error as Error, - 'AppStateAPI: Error handling app state change', - ); - } - }; - - /** - * Get the current app state - */ - getCurrentAppState(): AppStateStatus { - return this.currentAppState; - } - - // ========================================================================== - // Lock Timer Management (Timing only, not locking!) - // ========================================================================== - - /** - * Start a lock timer that will call callback after duration - * - * Note: This only manages timing. The callback should dispatch an action - * that the state machine handles. This API does NOT lock anything. - * - * @param duration - Time in milliseconds until callback - * @param callback - Function to call when timer expires - */ - startLockTimer(duration: number, callback: () => void): void { - // Clear any existing timer first - this.clearLockTimer(); - - this.lockCallback = callback; - this.lockTimerDuration = duration; - this.lockTimerStartTime = Date.now(); - - this.lockTimer = BackgroundTimer.setTimeout(() => { - Logger.log('AppStateAPI: Lock timer expired'); - if (this.lockCallback) { - this.lockCallback(); - } - this.clearLockTimer(); - }, duration); - - Logger.log(`AppStateAPI: Lock timer started for ${duration}ms`); - } - - /** - * Clear the active lock timer - */ - clearLockTimer(): void { - if (!this.lockTimer) { - return; - } - - BackgroundTimer.clearTimeout(this.lockTimer); - this.lockTimer = undefined; - this.lockTimerStartTime = undefined; - this.lockTimerDuration = undefined; - this.lockCallback = undefined; - - Logger.log('AppStateAPI: Lock timer cleared'); - } - - /** - * Get remaining time on lock timer - * - * @returns Milliseconds remaining, or null if no timer active - */ - getLockTimerRemaining(): number | null { - if ( - !this.lockTimer || - !this.lockTimerStartTime || - !this.lockTimerDuration - ) { - return null; - } - - const elapsed = Date.now() - this.lockTimerStartTime; - const remaining = Math.max(0, this.lockTimerDuration - elapsed); - - return remaining; - } - - /** - * Check if lock timer is currently active - */ - isLockTimerActive(): boolean { - return !!this.lockTimer; - } - - // ========================================================================== - // State Queries - // ========================================================================== - - /** - * Check if app is in foreground (active) - */ - isAppInForeground(): boolean { - return this.currentAppState === 'active'; - } - - /** - * Check if app is in background - */ - isAppInBackground(): boolean { - return this.currentAppState === 'background'; - } - - /** - * Check if app is inactive (transitioning) - */ - isAppInactive(): boolean { - return this.currentAppState === 'inactive'; - } - - /** - * Check if API is initialized - */ - isInitialized(): boolean { - return this.initialized; - } -} - -// Export singleton instance -export const AppStateService = AppStateServiceImplementation.getInstance(); - -// Export class for testing -export { AppStateServiceImplementation }; - -// Export types -export type { AppStateStatus }; diff --git a/app/core/AppStateService/index.ts b/app/core/AppStateService/index.ts deleted file mode 100644 index 3f119142bcbc..000000000000 --- a/app/core/AppStateService/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - AppStateService, - AppStateServiceImplementation, -} from './AppStateService'; -export type { AppStateStatus } from './AppStateService'; diff --git a/app/core/Authentication/Authentication.test.ts b/app/core/Authentication/Authentication.test.ts index d980d3545446..5f93c9b3087d 100644 --- a/app/core/Authentication/Authentication.test.ts +++ b/app/core/Authentication/Authentication.test.ts @@ -5929,6 +5929,17 @@ describe('Authentication', () => { }); }); + it('leaves the current route in place when navigationBehavior is preserve', async () => { + await Authentication.unlockWallet({ + password: passwordToUse, + navigationBehavior: 'preserve', + }); + + expect(mockDispatch).toHaveBeenCalledWith(setExistingUser(true)); + expect(mockNavigateToPostUnlockHome).not.toHaveBeenCalled(); + expect(mockReset).not.toHaveBeenCalled(); + }); + it('navigates to the post-unlock home destination when a password is provided', async () => { // Call unlockWallet with a password. await Authentication.unlockWallet({ password: passwordToUse }); @@ -6792,4 +6803,72 @@ describe('Authentication', () => { }); }); }); + + describe('tryBiometricUnlock', () => { + let checkIsSeedlessPasswordOutdatedSpy: jest.SpyInstance; + let unlockWalletSpy: jest.SpyInstance; + + beforeEach(() => { + checkIsSeedlessPasswordOutdatedSpy = jest + .spyOn(Authentication, 'checkIsSeedlessPasswordOutdated') + .mockResolvedValue(false); + unlockWalletSpy = jest + .spyOn(Authentication, 'unlockWallet') + .mockResolvedValue(undefined); + }); + + afterEach(() => { + checkIsSeedlessPasswordOutdatedSpy.mockRestore(); + unlockWalletSpy.mockRestore(); + }); + + it('checks seedless password status without cache and without Sentry capture', async () => { + await Authentication.tryBiometricUnlock(); + + expect(checkIsSeedlessPasswordOutdatedSpy).toHaveBeenCalledWith({ + skipCache: true, + captureSentryError: false, + }); + }); + + it('unlocks the wallet when the seedless password is current', async () => { + await Authentication.tryBiometricUnlock(); + + expect(unlockWalletSpy).toHaveBeenCalledWith(); + expect(mockReset).not.toHaveBeenCalled(); + }); + + it('preserves the current route when resume asks it to', async () => { + await Authentication.tryBiometricUnlock({ + navigationBehavior: 'preserve', + }); + + expect(unlockWalletSpy).toHaveBeenCalledWith({ + navigationBehavior: 'preserve', + }); + }); + + it('resets to the rehydrate screen without unlocking when the seedless password is outdated', async () => { + checkIsSeedlessPasswordOutdatedSpy.mockResolvedValue(true); + + await Authentication.tryBiometricUnlock(); + + expect(mockReset).toHaveBeenCalledWith({ + routes: [ + { + name: Routes.ONBOARDING.REHYDRATE, + params: { isSeedlessPasswordOutdated: true }, + }, + ], + }); + expect(unlockWalletSpy).not.toHaveBeenCalled(); + }); + + it('rejects with the unlock error when unlockWallet fails', async () => { + const error = new Error('biometric cancelled'); + unlockWalletSpy.mockRejectedValue(error); + + await expect(Authentication.tryBiometricUnlock()).rejects.toBe(error); + }); + }); }); diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts index e24a9187f495..37d88075f8ed 100644 --- a/app/core/Authentication/Authentication.ts +++ b/app/core/Authentication/Authentication.ts @@ -928,6 +928,7 @@ class AuthenticationService { * @param options - Options for unlocking the wallet. * @param options.password - The password to use to unlock the wallet. * @param options.onBeforeNavigate - When set, awaited after unlock succeeds and before navigation to home/opt-in. + * @param options.navigationBehavior - `preserve` leaves the current route in place. Resume uses it because the privacy cover is hiding that route. The default navigates to Home, a pending deeplink, or the metrics opt-in screen. * @returns - void */ unlockWallet = async ( @@ -935,6 +936,7 @@ class AuthenticationService { password, authPreference, onBeforeNavigate, + navigationBehavior = 'default', // Optional onboarding trace context; forwarded to rehydrateSeedPhrase so the seedless // OnboardingFetchSrps span nests under the onboarding journey. Omitted by non-onboarding // callers (login/biometric unlock), which leaves tracing behaviour unchanged for them. @@ -943,6 +945,7 @@ class AuthenticationService { password?: string; authPreference?: AuthData; onBeforeNavigate?: () => Promise; + navigationBehavior?: 'default' | 'preserve'; parentContext?: TraceContext; } = { password: undefined, @@ -1027,6 +1030,11 @@ class AuthenticationService { await onBeforeNavigate(); } + // Resume already has a route under the privacy cover. Leave it. + if (navigationBehavior === 'preserve') { + return; + } + // TODO: Refactor this orchestration to sagas. // Navigate to optin metrics or home screen based on metrics consent and UI seen. const isMetricsEnabled = analytics.isEnabled(); @@ -1741,6 +1749,39 @@ class AuthenticationService { } }; + /** + * Prompts biometric unlock after checking whether the seedless password is + * outdated. Shared by cold start and resume so both prompt identically. + * Rejects when unlock fails; callers decide the Login fallback. + */ + tryBiometricUnlock = async (options?: { + navigationBehavior?: 'default' | 'preserve'; + }): Promise => { + if ( + await this.checkIsSeedlessPasswordOutdated({ + skipCache: true, + captureSentryError: false, + }) + ) { + NavigationService.navigation?.reset({ + routes: [ + { + name: Routes.ONBOARDING.REHYDRATE, + params: { isSeedlessPasswordOutdated: true }, + }, + ], + }); + return; + } + + if (options?.navigationBehavior === 'preserve') { + await this.unlockWallet({ navigationBehavior: 'preserve' }); + return; + } + + await this.unlockWallet(); + }; + /** * Checks if the seedless password is outdated and shows a modal if it is. * This method verifies the outdated state and navigates to show the password outdated modal. diff --git a/app/core/LockManagerService/index.test.ts b/app/core/LockManagerService/index.test.ts deleted file mode 100644 index 5f2996195ef8..000000000000 --- a/app/core/LockManagerService/index.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { LockManagerService } from '.'; -import { AppState, AppStateStatus } from 'react-native'; -import { lockApp, checkForDeeplink } from '../../actions/user'; -import Logger from '../../util/Logger'; -import ReduxService, { type ReduxStore } from '../redux'; -import Engine from '../Engine'; - -jest.mock('../Engine', () => ({ - context: { - KeyringController: { - setLocked: jest.fn().mockResolvedValue(true), - isUnlocked: jest.fn().mockReturnValue(true), - }, - }, -})); - -const mockSetTimeout = jest.fn(); -const mockClearTimeout = jest.fn(); - -jest.mock('react-native-background-timer', () => ({ - setTimeout: (callback: () => void) => mockSetTimeout(callback), - clearTimeout: (id: number) => mockClearTimeout(id), -})); - -jest.mock('../SecureKeychain', () => ({ - getInstance: () => ({ - isAuthenticating: false, - }), -})); - -jest.mock('../../util/Logger', () => ({ - log: jest.fn(), - error: jest.fn(), -})); - -describe('LockManagerService', () => { - let lockManagerService: LockManagerService; - let mockAppStateListener: (state: AppStateStatus) => Promise; - - beforeEach(() => { - jest.clearAllMocks(); - jest.resetModules(); - jest.useFakeTimers(); - // Returning an id lets the service track and later clear the pending timer. - mockSetTimeout.mockReturnValue(1); - (Engine.context.KeyringController.isUnlocked as jest.Mock).mockReturnValue( - true, - ); - (Engine.context.KeyringController.setLocked as jest.Mock).mockResolvedValue( - true, - ); - (AppState.addEventListener as jest.Mock).mockImplementation( - (_, listener) => { - mockAppStateListener = listener; - return { remove: jest.fn() }; - }, - ); - lockManagerService = new LockManagerService(); - }); - - afterEach(() => { - lockManagerService.stopListening(); - jest.useFakeTimers({ legacyFakeTimers: true }); - }); - - describe('startListening', () => { - it('should do nothing when app state listener is already subscribed.', async () => { - lockManagerService.startListening(); - expect(AppState.addEventListener).toHaveBeenCalledTimes(1); - lockManagerService.startListening(); - expect(AppState.addEventListener).toHaveBeenCalledTimes(1); - expect(Logger.log).toHaveBeenCalledWith( - 'Already subscribed to app state listener.', - ); - }); - - it('should add event listener when it is not yet subscribed.', async () => { - lockManagerService.startListening(); - expect(AppState.addEventListener).toHaveBeenCalled(); - }); - }); - - describe('stopListening', () => { - it('should remove app state listener.', async () => { - lockManagerService.startListening(); - expect(AppState.addEventListener).toHaveBeenCalledTimes(1); - lockManagerService.stopListening(); - lockManagerService.startListening(); - expect(AppState.addEventListener).toHaveBeenCalledTimes(2); - }); - }); - - describe('handleAppStateChange', () => { - it('should throw an error if store is undefined.', async () => { - lockManagerService.startListening(); - mockAppStateListener('active'); - expect(Logger.error).toHaveBeenCalledWith( - new Error('Redux store does not exist!'), - 'LockManagerService: Error handling app state change', - ); - }); - - it('should do nothing if lockTime is -1 while going into the background', async () => { - const mockDispatch = jest.fn(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - getState: () => ({ settings: { lockTime: -1 } }), - dispatch: mockDispatch, - } as unknown as ReduxStore); - lockManagerService.startListening(); - mockAppStateListener('background'); - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - it('should do nothing if lockTime is 0 while going inactive.', async () => { - const mockDispatch = jest.fn(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - getState: () => ({ settings: { lockTime: 0 } }), - dispatch: mockDispatch, - } as unknown as ReduxStore); - lockManagerService.startListening(); - mockAppStateListener('inactive'); - expect(mockDispatch).not.toHaveBeenCalled(); - }); - - it('should only dispatch checkForDeeplink while lockTime is 0 while going from inactive to active', async () => { - const mockDispatch = jest.fn(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - getState: () => ({ settings: { lockTime: 0 } }), - dispatch: mockDispatch, - } as unknown as ReduxStore); - lockManagerService.startListening(); - await mockAppStateListener('inactive'); - await mockAppStateListener('active'); - expect(mockDispatch).toHaveBeenCalledWith(checkForDeeplink()); - }); - - it('should dispatch lockApp when lockTimer is 0 while going into the background', async () => { - const mockDispatch = jest.fn(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - getState: () => ({ settings: { lockTime: 0 } }), - dispatch: mockDispatch, - } as unknown as ReduxStore); - lockManagerService.startListening(); - await mockAppStateListener('background'); - await Promise.resolve(); - expect(mockDispatch).toHaveBeenCalledWith(lockApp()); - }); - - it('should set background timer when lockTimer is non-zero while going into the background', async () => { - const mockDispatch = jest.fn(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - getState: () => ({ settings: { lockTime: 5 } }), - dispatch: mockDispatch, - } as unknown as ReduxStore); - lockManagerService.startListening(); - mockAppStateListener('background'); - expect(mockSetTimeout).toHaveBeenCalled(); - }); - - it('clears the pending background timer when resuming through inactive', async () => { - const mockDispatch = jest.fn(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - getState: () => ({ settings: { lockTime: 5 } }), - dispatch: mockDispatch, - } as unknown as ReduxStore); - lockManagerService.startListening(); - - // Android resumes as background -> inactive -> active, which takes the - // ignored-transition path and must still cancel the pending lock. - await mockAppStateListener('background'); - await mockAppStateListener('inactive'); - await mockAppStateListener('active'); - - expect(mockClearTimeout).toHaveBeenCalledWith(1); - }); - - it('parses a pending deeplink after resume cancels auto-lock before it fires', async () => { - const mockDispatch = jest.fn(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - getState: () => ({ settings: { lockTime: 5 } }), - dispatch: mockDispatch, - } as unknown as ReduxStore); - lockManagerService.startListening(); - - await mockAppStateListener('background'); - await mockAppStateListener('inactive'); - await mockAppStateListener('active'); - - expect(mockDispatch).toHaveBeenCalledWith(checkForDeeplink()); - expect(mockDispatch).not.toHaveBeenCalledWith(lockApp()); - }); - - it('does not parse a deeplink on resume when auto-lock already locked the wallet', async () => { - const mockDispatch = jest.fn(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - getState: () => ({ settings: { lockTime: 5 } }), - dispatch: mockDispatch, - } as unknown as ReduxStore); - lockManagerService.startListening(); - - await mockAppStateListener('background'); - const scheduledLock = mockSetTimeout.mock.calls[0][0] as () => void; - scheduledLock(); - await Promise.resolve(); - ( - Engine.context.KeyringController.isUnlocked as jest.Mock - ).mockReturnValue(false); - - await mockAppStateListener('inactive'); - await mockAppStateListener('active'); - - expect(mockDispatch).toHaveBeenCalledWith(lockApp()); - expect(mockDispatch).not.toHaveBeenCalledWith(checkForDeeplink()); - }); - - it('waits for an in-flight auto-lock before deciding whether to parse a deeplink', async () => { - let releaseLock: () => void = () => undefined; - ( - Engine.context.KeyringController.setLocked as jest.Mock - ).mockImplementation( - () => - new Promise((resolve) => { - releaseLock = resolve; - }), - ); - const mockDispatch = jest.fn(); - jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - getState: () => ({ settings: { lockTime: 5 } }), - dispatch: mockDispatch, - } as unknown as ReduxStore); - lockManagerService.startListening(); - - await mockAppStateListener('background'); - const scheduledLock = mockSetTimeout.mock.calls[0][0] as () => void; - scheduledLock(); - - expect(lockManagerService.isAutoLockPending()).toBe(true); - - const resume = mockAppStateListener('inactive').then(() => - mockAppStateListener('active'), - ); - await Promise.resolve(); - - expect(mockDispatch).not.toHaveBeenCalledWith(checkForDeeplink()); - expect(mockDispatch).not.toHaveBeenCalledWith(lockApp()); - - ( - Engine.context.KeyringController.isUnlocked as jest.Mock - ).mockReturnValue(false); - releaseLock(); - await resume; - - expect(mockDispatch).toHaveBeenCalledWith(lockApp()); - expect(mockDispatch).not.toHaveBeenCalledWith(checkForDeeplink()); - }); - }); -}); diff --git a/app/core/LockManagerService/index.ts b/app/core/LockManagerService/index.ts deleted file mode 100644 index 293ce4fa609f..000000000000 --- a/app/core/LockManagerService/index.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - AppState, - AppStateStatus, - NativeEventSubscription, -} from 'react-native'; -import SecureKeychain from '../SecureKeychain'; -import BackgroundTimer from 'react-native-background-timer'; -import Engine from '../Engine'; -import Logger from '../../util/Logger'; -import { lockApp, checkForDeeplink } from '../../actions/user'; -import ReduxService from '../redux'; - -export class LockManagerService { - #appState?: AppStateStatus; - #appStateListener?: NativeEventSubscription; - #lockTimer?: number; - #lockAppPromise?: Promise; - - /** - * True while Auto-lock still has work: a timer is scheduled, or lock has - * started and not finished. Deeplink parse must wait so a resume cannot - * consume a URL and then lose it to a lock that lands a tick later. - */ - isAutoLockPending(): boolean { - return this.#lockTimer !== undefined || this.#lockAppPromise !== undefined; - } - - #startLock = () => { - if (this.#lockAppPromise) { - return; - } - this.#lockAppPromise = this.#lockApp().finally(() => { - this.#lockAppPromise = undefined; - }); - }; - - #lockApp = async () => { - if (!SecureKeychain.getInstance().isAuthenticating) { - const { KeyringController } = Engine.context; - try { - await KeyringController.setLocked(); - ReduxService.store.dispatch(lockApp()); - } catch (error) { - Logger.log('Failed to lock KeyringController', error); - } - } else if (this.#lockTimer) { - BackgroundTimer.clearTimeout(this.#lockTimer); - this.#lockTimer = undefined; - } - }; - - #clearBackgroundTimer = () => { - if (!this.#lockTimer) { - return; - } - BackgroundTimer.clearTimeout(this.#lockTimer); - this.#lockTimer = undefined; - }; - - /** - * Cancels a scheduled lock, waits for an in-flight lock, then parses a - * pending deeplink only if the wallet is still unlocked. - */ - #settleAutoLockAndMaybeCheckDeeplink = async () => { - this.#clearBackgroundTimer(); - if (this.#lockAppPromise) { - await this.#lockAppPromise; - } - if (!Engine.context.KeyringController.isUnlocked()) { - return; - } - ReduxService.store.dispatch(checkForDeeplink()); - }; - - #handleAppStateChange = async (nextAppState: AppStateStatus) => { - // Don't auto-lock. - try { - const lockTime = ReduxService.store.getState().settings.lockTime; - if ( - lockTime === -1 || // Lock timer isn't set. - nextAppState === 'inactive' || // Ignore inactive state. - (this.#appState === 'inactive' && nextAppState === 'active') // Ignore going from inactive -> active state. - ) { - // Lets other services know that the lock manager app state event is resolved while active - if (nextAppState === 'active') { - // Android resumes as background -> inactive -> active, which lands - // here rather than in the `active` branch below. Without this the - // pending timer survives the resume and locks mid-session. - await this.#settleAutoLockAndMaybeCheckDeeplink(); - } - this.#appState = nextAppState; - return; - } - - // Handle lock logic on background. - if (nextAppState === 'background') { - if (lockTime === 0) { - this.#startLock(); - } else { - // Autolock after some time. - this.#clearBackgroundTimer(); - this.#lockTimer = BackgroundTimer.setTimeout(() => { - if (!this.#lockTimer) { - return; - } - this.#lockTimer = undefined; - this.#startLock(); - }, lockTime); - } - } - - // App has foregrounded from background. - if (nextAppState === 'active') { - await this.#settleAutoLockAndMaybeCheckDeeplink(); - } - - this.#appState = nextAppState; - } catch (error) { - Logger.error( - error as Error, - 'LockManagerService: Error handling app state change', - ); - } - }; - - /** - * Listen to AppState events to control lock state. - */ - startListening = () => { - if (this.#appStateListener) { - Logger.log('Already subscribed to app state listener.'); - return; - } - this.#appStateListener = AppState.addEventListener( - 'change', - this.#handleAppStateChange, - ); - }; - - // Pause listening to AppState events. - stopListening = () => { - if (!this.#appStateListener) { - Logger.log('App state listener is not set.'); - return; - } - this.#appStateListener.remove(); - this.#appStateListener = undefined; - }; -} - -export default new LockManagerService(); diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index dd0142f2675c..c5447439b2eb 100644 --- a/app/core/NavigationService/types.ts +++ b/app/core/NavigationService/types.ts @@ -1057,7 +1057,6 @@ export type RootStackParamList = { TradingSignalsSetupBottomSheet: TradingSignalsSetupParams | undefined; // Misc routes - LockScreen: undefined; MoreTokenActionsMenu: MoreTokenActionsMenuParams; SecurityBadgeBottomSheet: SecurityBadgeBottomSheetParams; AgenticCliApprovalConfirm: AgenticCliApprovalParams; diff --git a/app/core/PreventScreenshot.js b/app/core/PreventScreenshot.js index 4b5f8930169a..6b3e8a60410b 100644 --- a/app/core/PreventScreenshot.js +++ b/app/core/PreventScreenshot.js @@ -27,6 +27,11 @@ export const CAPTURE_KEYS = { // overlapping mounts internally before calling through to here. credentialScreens: 'metamask-credential-screens', onboarding: 'metamask-onboarding', + // Held from LOGIN until LOGOUT on Android 12 and older, where FLAG_SECURE is + // the only way to blank the Recents card. Set while the app is in the + // foreground: applying the flag from a background event lands after Android + // has already snapshotted. Android 13+ uses setRecentsScreenshotEnabled. + unlockedWallet: 'metamask-unlocked-wallet', // Callers that don't pass a key get their own bucket rather than joining an // existing owner's, so an unqualified allow() can never release someone // else's block. CardScreenshotDeterrent relies on this. diff --git a/app/core/SDKConnect/ConnectionManagement/connectToChannel.ts b/app/core/SDKConnect/ConnectionManagement/connectToChannel.ts index 1dc1565d81c5..66e018a6bd2b 100644 --- a/app/core/SDKConnect/ConnectionManagement/connectToChannel.ts +++ b/app/core/SDKConnect/ConnectionManagement/connectToChannel.ts @@ -157,7 +157,6 @@ async function connectToChannel({ try { // We cannot request permissions if the user is on the login screen or the account connect screen otherwise it will kill other permissions requests. const skipRoutes: readonly string[] = [ - Routes.LOCK_SCREEN, Routes.ONBOARDING.LOGIN, Routes.SHEET.ACCOUNT_CONNECT, ]; diff --git a/app/core/SDKConnect/InitializationManagement/postInit.ts b/app/core/SDKConnect/InitializationManagement/postInit.ts index d5da922eb305..7bd8e4f402b3 100644 --- a/app/core/SDKConnect/InitializationManagement/postInit.ts +++ b/app/core/SDKConnect/InitializationManagement/postInit.ts @@ -52,10 +52,7 @@ async function postInit(instance: SDKConnect) { `SDKConnect::postInit() - currentRouteName=${currentRouteName}`, ); - const waitRoutes: readonly string[] = [ - Routes.LOCK_SCREEN, - Routes.ONBOARDING.LOGIN, - ]; + const waitRoutes: readonly string[] = [Routes.ONBOARDING.LOGIN]; await waitForCondition({ fn: () => { currentRouteName = instance.state.navigation?.getCurrentRoute()?.name; diff --git a/app/core/SDKConnect/StateManagement/updateSDKLoadingState.ts b/app/core/SDKConnect/StateManagement/updateSDKLoadingState.ts index 0e7eb9fdc73a..d48e3074784c 100644 --- a/app/core/SDKConnect/StateManagement/updateSDKLoadingState.ts +++ b/app/core/SDKConnect/StateManagement/updateSDKLoadingState.ts @@ -25,7 +25,6 @@ async function updateSDKLoadingState({ `updateSDKLoadingState:: currentRouteName=${currentRouteName} loading=${loading}`, ); const skipRoutes: readonly string[] = [ - Routes.LOCK_SCREEN, Routes.ONBOARDING.LOGIN, Routes.SHEET.ACCOUNT_CONNECT, ]; diff --git a/app/core/SDKConnect/handlers/checkPermissions.ts b/app/core/SDKConnect/handlers/checkPermissions.ts index 9beac3418884..8722f93cf3e6 100644 --- a/app/core/SDKConnect/handlers/checkPermissions.ts +++ b/app/core/SDKConnect/handlers/checkPermissions.ts @@ -58,10 +58,7 @@ export const checkPermissions = async ({ ).PermissionController; // Make sure to wait for user to be on main pages before requesting permissions or request can get cancelled. - const pendingRoutes: readonly string[] = [ - Routes.LOCK_SCREEN, - Routes.ONBOARDING.LOGIN, - ]; + const pendingRoutes: readonly string[] = [Routes.ONBOARDING.LOGIN]; if (currentRouteName && pendingRoutes.includes(currentRouteName)) { await waitForCondition({ diff --git a/app/store/sagas/index.ts b/app/store/sagas/index.ts index 57849d403d91..ade8a1f9054a 100644 --- a/app/store/sagas/index.ts +++ b/app/store/sagas/index.ts @@ -1,7 +1,6 @@ import { fork, take, - cancel, put, call, all, @@ -20,10 +19,10 @@ import { CheckForDeeplinkAction, } from '../../actions/user'; import { NavigationActionType } from '../../actions/navigation'; -import { EventChannel, Task, eventChannel } from 'redux-saga'; +import { Task } from 'redux-saga'; import Engine from '../../core/Engine'; import Logger from '../../util/Logger'; -import LockManagerService from '../../core/LockManagerService'; +import AppLockService from '../../core/AppLock/AppLockService'; import { overrideXMLHttpRequest, restoreXMLHttpRequest, @@ -47,9 +46,6 @@ import UrlParser from 'url-parse'; import { isSDKServiceDeeplink } from '../../core/DeeplinkManager/util/deeplinks'; import { rewardsBulkLinkSaga } from './rewardsBulkLinkAccountGroups'; import Authentication from '../../core/Authentication'; -import { AppState, AppStateStatus } from 'react-native'; -import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics'; -import { providerErrors } from '@metamask/rpc-errors'; import { backfillSocialLoginMarketingConsentSaga } from './backfillSocialLoginMarketingConsent'; import { promptIosGoogleWarningSheetSaga } from './onboarding/legacyIosGoogleReminder'; import { @@ -164,122 +160,6 @@ export function* parseDeeplinkAfterNavReady( yield call(parseDeeplink, deeplink, origin, appStartType); } -/** - * Creates a channel to listen to app state changes. - */ -function appStateListenerChannel() { - return eventChannel((emitter) => { - const appStateListener = AppState.addEventListener('change', emitter); - return () => { - appStateListener.remove(); - }; - }); -} - -/** - * Checks seedless password status and performs the correct auth flow. - */ -async function tryBiometricUnlock(): Promise { - if ( - await Authentication.checkIsSeedlessPasswordOutdated({ - skipCache: true, - captureSentryError: false, - }) - ) { - NavigationService.navigation?.reset({ - routes: [ - { - name: Routes.ONBOARDING.REHYDRATE, - params: { isSeedlessPasswordOutdated: true }, - }, - ], - }); - return; - } - - // Prompt authentication. - await Authentication.unlockWallet(); -} - -/** - * Prompts authentication, falling back to the Login screen on failure. The - * lock screen has no affordances of its own, so every path off it runs this. - */ -async function promptUnlockFromLockScreen(): Promise { - // This is in a try catch since errors are not propogated in event channels. - try { - await tryBiometricUnlock(); - } catch (error) { - // Navigate to login. - NavigationService.navigation?.reset({ - routes: [{ name: Routes.ONBOARDING.LOGIN }], - }); - trackErrorAsAnalytics( - 'Lockscreen: Authentication failed', - (error as Error)?.message, - ); - } -} - -/** - * Listens to app state changes and prompts authentication when the app is foregrounded. - */ -export function* appStateListenerTask() { - // The lock can land after the app is already foregrounded: Android delivers a - // pending background timer and the `active` event together on resume, in no - // guaranteed order. Waiting on the channel here would block on an `active` - // event that has already been and gone, stranding the user on the lock - // screen, so prompt straight away instead. - if (AppState.currentState === 'active') { - yield call(promptUnlockFromLockScreen); - return; - } - - // Create channel to listen to app state changes. - const channel: EventChannel = yield call( - appStateListenerChannel, - ); - - try { - while (true) { - const appState: AppStateStatus = yield take(channel); - if (appState === 'active') { - yield call(promptUnlockFromLockScreen); - // Close channel once authentication is prompted. - channel.close(); - } - } - } finally { - // Unconditionally close channel to prevent memory leaks. - channel.close(); - } -} - -export function* appLockStateMachine() { - while (true) { - yield take(UserActionType.LOCKED_APP); - - // Reject any pending confirmations so the user doesn't see a stale confirmation after unlock. - try { - const { ApprovalController } = Engine.context; - if (ApprovalController) { - ApprovalController.clearRequests(providerErrors.userRejectedRequest()); - } - } catch (error) { - Logger.error( - error as Error, - 'Failed to reject pending approvals on app lock', - ); - } - - // Navigate to lock screen. - NavigationService.navigation?.navigate(Routes.LOCK_SCREEN); - - // App state listener for prompting authentication when the app is foregrounded. - yield call(appStateListenerTask); - } -} - /** * Automatically requests authentication on app start. */ @@ -296,7 +176,7 @@ export function* requestAuthOnAppStart() { } } - yield call(tryBiometricUnlock); + yield call(Authentication.tryBiometricUnlock); } catch (_) { // If authentication fails, navigate to login screen // TODO: Consolidate error handling in future PRs. For now, we'll rely on the Login screen to handle triaging specific errors. @@ -308,22 +188,19 @@ export function* requestAuthOnAppStart() { /** * The state machine for detecting when the app is logged vs logged out. - * While on the Wallet screen, this state machine - * will "listen" to the app lock state machine. + * `AppLockService` owns the background / foreground lifecycle (privacy screen, + * auto-lock timer, and resume authentication); this saga only scopes its + * auto-lock behavior to the logged-in session. */ export function* authStateMachine() { - // Start when the user is logged in. + // Subscribe to AppState once so the privacy screen covers the app on + // background regardless of login state. + AppLockService.initialize(); while (true) { yield take(UserActionType.LOGIN); - // Listen to the app once it enters the locked state. - const appLockStateMachineTask: Task = yield fork(appLockStateMachine); - // Handles locking the app when the app is backgrounded. - LockManagerService.startListening(); - // Listen to app lock behavior. + AppLockService.start(); yield take(UserActionType.LOGOUT); - LockManagerService.stopListening(); - // Cancels appLockStateMachineTask, which also cancels nested sagas once logged out. - yield cancel(appLockStateMachineTask); + AppLockService.stop(); } } @@ -422,22 +299,23 @@ export function* handleDeeplinkSaga() { continue; } - // Resume can deliver the URL while Auto-lock is still scheduled or - // in-flight. Parsing now would clear the pending link, then the lock - // would reset navigation to Home. - if (LockManagerService.isAutoLockPending()) { + // Resume can deliver the URL while a lock decision is open or the unlock + // prompt has not finished. Parsing now would clear the pending link, then + // the lock or the post-unlock navigation would replace it. + if (AppLockService.isAutoLockPending()) { continue; } - // Password and biometric unlock dispatch SET_COMPLETED_ONBOARDING from the - // login or lock screen, before navigateToPostUnlockHome reads the pending - // link. Consuming it here clears the URL, then the home reset replaces any - // navigation this parse managed to start. + // Password unlock dispatches SET_COMPLETED_ONBOARDING from the Login + // screen, before navigateToPostUnlockHome reads the pending link. + // Consuming it here clears the URL, then the home reset replaces any + // navigation this parse managed to start. Resume biometric unlock is + // covered by `isAutoLockPending` above, which stays set until that + // prompt settles. const currentRouteName = NavigationService.getCurrentRoute()?.name; if ( value.type === SET_COMPLETED_ONBOARDING && - (currentRouteName === Routes.ONBOARDING.LOGIN || - currentRouteName === Routes.LOCK_SCREEN) + currentRouteName === Routes.ONBOARDING.LOGIN ) { continue; } diff --git a/app/store/sagas/sagas.test.ts b/app/store/sagas/sagas.test.ts index 9398b95450b3..2690ecd96145 100644 --- a/app/store/sagas/sagas.test.ts +++ b/app/store/sagas/sagas.test.ts @@ -1,11 +1,9 @@ -import { AppState } from 'react-native'; -import { take, fork, cancel } from 'redux-saga/effects'; +import { take } from 'redux-saga/effects'; import { expectSaga } from 'redux-saga-test-plan'; import { UserActionType, checkForDeeplink } from '../../actions/user'; import Routes from '../../constants/navigation/Routes'; import { authStateMachine, - appLockStateMachine, startAppServices, initializeSDKServices, initializeSDKServicesSaga, @@ -17,7 +15,6 @@ import { __setMainNavigatorReadyForTesting, __resetSDKServicesInitializationForTesting, requestAuthOnAppStart, - appStateListenerTask, } from './'; import { NavigationActionType, @@ -32,7 +29,7 @@ import { import { resetUnlockAppStartTypeForTesting } from '../../core/Performance/unlockTraces'; import { resetLoginAppStartTypeForTesting } from '../../components/Views/Login/loginPerformanceTags'; import Engine from '../../core/Engine'; -import LockManagerService from '../../core/LockManagerService'; +import AppLockService from '../../core/AppLock/AppLockService'; import SharedDeeplinkManager from '../../core/DeeplinkManager/DeeplinkManager'; import { setCompletedOnboarding } from '../../actions/onboarding'; @@ -40,8 +37,6 @@ import SDKConnect from '../../core/SDKConnect/SDKConnect'; import WC2Manager from '../../core/WalletConnect/WalletConnectV2'; import Authentication from '../../core/Authentication'; import AppConstants from '../../core/AppConstants'; -import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics'; -import { providerErrors } from '@metamask/rpc-errors'; import { getDevAutoUnlockPassword } from '../../util/environment'; import { saveAttribution } from '../../core/redux/slices/attribution'; jest.mock('../../util/analytics/persistAttributionFromPendingDeeplink', () => ({ @@ -171,6 +166,7 @@ jest.mock('../../core/Authentication', () => ({ __esModule: true, default: { unlockWallet: jest.fn().mockResolvedValue(undefined), + tryBiometricUnlock: jest.fn().mockResolvedValue(undefined), lockApp: jest.fn().mockResolvedValue(undefined), checkIsSeedlessPasswordOutdated: jest.fn().mockResolvedValue(false), }, @@ -180,20 +176,16 @@ jest.mock('../../util/environment', () => ({ getDevAutoUnlockPassword: jest.fn(), })); -jest.mock('../../core/LockManagerService', () => ({ +jest.mock('../../core/AppLock/AppLockService', () => ({ __esModule: true, default: { - startListening: jest.fn(), - stopListening: jest.fn(), + initialize: jest.fn(), + start: jest.fn(), + stop: jest.fn(), isAutoLockPending: jest.fn(() => false), }, })); -// Add this mock with the other mocks (around line 151) -jest.mock('../../util/metrics/TrackError/trackErrorAsAnalytics', () => - jest.fn(), -); - const defaultMockState = { onboarding: { completedOnboarding: false, @@ -230,35 +222,13 @@ describe('requestAuthOnAppStart', () => { jest.clearAllMocks(); }); - it('calls Authentication.unlockWallet', async () => { + it('calls Authentication.tryBiometricUnlock', async () => { await expectSaga(requestAuthOnAppStart).run(); - expect(Authentication.unlockWallet).toHaveBeenCalled(); + expect(Authentication.tryBiometricUnlock).toHaveBeenCalled(); }); - it('navigates to rehydrate when seedless password is outdated', async () => { - // Arrange - ( - Authentication.checkIsSeedlessPasswordOutdated as jest.Mock - ).mockResolvedValueOnce(true); - - // Act - await expectSaga(requestAuthOnAppStart).run(); - - // Assert - expect(mockReset).toHaveBeenCalledWith({ - routes: [ - { - name: Routes.ONBOARDING.REHYDRATE, - params: { isSeedlessPasswordOutdated: true }, - }, - ], - }); - expect(Authentication.unlockWallet).not.toHaveBeenCalled(); - }); - - it('navigates to Login when Authentication.unlockWallet throws', async () => { - // Mock Authentication.unlockWallet to throw an error - (Authentication.unlockWallet as jest.Mock).mockRejectedValueOnce( + it('navigates to Login when Authentication.tryBiometricUnlock throws', async () => { + (Authentication.tryBiometricUnlock as jest.Mock).mockRejectedValueOnce( new Error('fail'), ); await expectSaga(requestAuthOnAppStart).run(); @@ -280,9 +250,7 @@ describe('requestAuthOnAppStart', () => { expect(Authentication.unlockWallet).toHaveBeenCalledWith({ password: 'test-password', }); - expect( - Authentication.checkIsSeedlessPasswordOutdated, - ).not.toHaveBeenCalled(); + expect(Authentication.tryBiometricUnlock).not.toHaveBeenCalled(); }); it('falls back to normal app-start authentication when dev auto-unlock is not configured', async () => { @@ -294,10 +262,8 @@ describe('requestAuthOnAppStart', () => { await expectSaga(requestAuthOnAppStart).run(); - expect(Authentication.unlockWallet).toHaveBeenCalledWith(); - expect(Authentication.unlockWallet).not.toHaveBeenCalledWith({ - password: 'test-password', - }); + expect(Authentication.tryBiometricUnlock).toHaveBeenCalled(); + expect(Authentication.unlockWallet).not.toHaveBeenCalled(); }); it('falls back to normal app-start authentication when no vault exists', async () => { @@ -305,210 +271,40 @@ describe('requestAuthOnAppStart', () => { await expectSaga(requestAuthOnAppStart).run(); - expect(Authentication.unlockWallet).toHaveBeenCalledWith(); - expect(Authentication.unlockWallet).not.toHaveBeenCalledWith({ - password: 'test-password', - }); + expect(Authentication.tryBiometricUnlock).toHaveBeenCalled(); + expect(Authentication.unlockWallet).not.toHaveBeenCalled(); }); }); describe('authStateMachine', () => { beforeEach(() => { - mockNavigate.mockClear(); - mockReset.mockClear(); + jest.clearAllMocks(); }); - it('forks appLockStateMachine when logged in', async () => { + it('initializes AppLockService before waiting for login', () => { const generator = authStateMachine(); + expect(generator.next().value).toEqual(take(UserActionType.LOGIN)); - expect(generator.next().value).toEqual(fork(appLockStateMachine)); + expect(AppLockService.initialize).toHaveBeenCalledTimes(1); + expect(AppLockService.start).not.toHaveBeenCalled(); }); - it('cancels appLockStateMachine when logged out', async () => { + it('starts AppLockService when logged in and waits for logout', () => { const generator = authStateMachine(); - // Logged in - generator.next(); - // Fork appLockStateMachine generator.next(); - expect(generator.next().value).toEqual(take(UserActionType.LOGOUT)); - expect(generator.next().value).toEqual(cancel()); - }); -}); - -// Add these tests (after the appLockStateMachine describe block) -describe('appStateListenerTask', () => { - let appStateCallback: (state: string) => void; - - beforeEach(() => { - jest.clearAllMocks(); - - // Capture the AppState callback when addEventListener is called - (AppState.addEventListener as jest.Mock).mockImplementation( - (_, callback) => { - appStateCallback = callback; - return { remove: jest.fn() }; - }, - ); - }); - - it('creates event channel to listen to app state changes', async () => { - await expectSaga(appStateListenerTask).silentRun(50); - - expect(AppState.addEventListener).toHaveBeenCalledWith( - 'change', - expect.any(Function), - ); - }); - - it('calls unlockWallet when app becomes active', async () => { - // Simulate app state change to 'active' after saga starts - setTimeout(() => { - appStateCallback('active'); - }, 10); - - await expectSaga(appStateListenerTask).silentRun(100); - - expect(Authentication.unlockWallet).toHaveBeenCalled(); - }); - - it('navigates to rehydrate when seedless password is outdated', async () => { - // Arrange - ( - Authentication.checkIsSeedlessPasswordOutdated as jest.Mock - ).mockResolvedValueOnce(true); - - // Act - setTimeout(() => { - appStateCallback('active'); - }, 10); - - await expectSaga(appStateListenerTask).silentRun(100); - - // Assert - expect(mockReset).toHaveBeenCalledWith({ - routes: [ - { - name: Routes.ONBOARDING.REHYDRATE, - params: { isSeedlessPasswordOutdated: true }, - }, - ], - }); - expect(Authentication.unlockWallet).not.toHaveBeenCalled(); - }); - - describe('when the app is already active', () => { - const originalCurrentState = AppState.currentState; - - afterEach(() => { - Object.defineProperty(AppState, 'currentState', { - value: originalCurrentState, - configurable: true, - writable: true, - }); - }); - - it('calls unlockWallet without waiting for another app state change', async () => { - // A lock applied after the resume leaves no `active` event to wait for, - // which would otherwise strand the user on the lock screen. - Object.defineProperty(AppState, 'currentState', { - value: 'active', - configurable: true, - writable: true, - }); - - await expectSaga(appStateListenerTask).silentRun(50); - - expect(Authentication.unlockWallet).toHaveBeenCalled(); - expect(AppState.addEventListener).not.toHaveBeenCalled(); - }); - }); - it('does not call unlockWallet when app is in background', async () => { - // Simulate app state change to 'background' - setTimeout(() => { - appStateCallback('background'); - }, 10); - - await expectSaga(appStateListenerTask).silentRun(100); - - expect(Authentication.unlockWallet).not.toHaveBeenCalled(); - }); - - it('does not call unlockWallet when app is inactive', async () => { - // Simulate app state change to 'inactive' - setTimeout(() => { - appStateCallback('inactive'); - }, 10); - - await expectSaga(appStateListenerTask).silentRun(100); - - expect(Authentication.unlockWallet).not.toHaveBeenCalled(); - }); - - it('calls lockApp, navigates to login, and tracks error when unlockWallet fails', async () => { - const mockError = new Error('Authentication failed'); - (Authentication.unlockWallet as jest.Mock).mockRejectedValueOnce(mockError); - - // Simulate app becoming active - setTimeout(() => { - appStateCallback('active'); - }, 10); - - await expectSaga(appStateListenerTask).silentRun(100); - - expect(Authentication.unlockWallet).toHaveBeenCalled(); - expect(mockReset).toHaveBeenCalledWith({ - routes: [{ name: Routes.ONBOARDING.LOGIN }], - }); - expect(trackErrorAsAnalytics).toHaveBeenCalledWith( - 'Lockscreen: Authentication failed', - 'Authentication failed', - ); - }); -}); - -describe('appLockStateMachine', () => { - const mockApprovalControllerClear = Engine.context.ApprovalController - .clearRequests as jest.Mock; - - beforeEach(() => { - mockNavigate.mockClear(); - mockReset.mockClear(); - mockApprovalControllerClear.mockClear(); - }); - - it('forks appStateListenerTask and navigates to LockScreen when app is locked', async () => { - await expectSaga(appLockStateMachine) - .dispatch({ type: UserActionType.LOCKED_APP }) - // Verify appStateListenerTask is called - .call(appStateListenerTask) - .run(); - - // Verify navigation to LockScreen - expect(mockNavigate).toHaveBeenCalledWith(Routes.LOCK_SCREEN); - }); - - it('clears pending approvals via ApprovalController.clearRequests when app is locked', async () => { - await expectSaga(appLockStateMachine) - .dispatch({ type: UserActionType.LOCKED_APP }) - .run(); - - expect(mockApprovalControllerClear).toHaveBeenCalledWith( - providerErrors.userRejectedRequest(), - ); - expect(mockNavigate).toHaveBeenCalledWith(Routes.LOCK_SCREEN); + expect(generator.next().value).toEqual(take(UserActionType.LOGOUT)); + expect(AppLockService.start).toHaveBeenCalledTimes(1); + expect(AppLockService.stop).not.toHaveBeenCalled(); }); - it('navigates to LockScreen even when ApprovalController.clearRequests throws', async () => { - mockApprovalControllerClear.mockImplementationOnce(() => { - throw new Error('clear failed'); - }); - - await expectSaga(appLockStateMachine) - .dispatch({ type: UserActionType.LOCKED_APP }) - .run(); + it('stops AppLockService when logged out and waits for the next login', () => { + const generator = authStateMachine(); + generator.next(); + generator.next(); - expect(mockNavigate).toHaveBeenCalledWith(Routes.LOCK_SCREEN); + expect(generator.next().value).toEqual(take(UserActionType.LOGIN)); + expect(AppLockService.stop).toHaveBeenCalledTimes(1); }); }); @@ -562,7 +358,7 @@ describe('startAppServices', () => { .run(); // Verify authentication is requested - expect(Authentication.unlockWallet).toHaveBeenCalled(); + expect(Authentication.tryBiometricUnlock).toHaveBeenCalled(); }); // SDKConnect/WC2 initialization starts from the unlocked deeplink saga path. @@ -721,7 +517,7 @@ describe('handleDeeplinkSaga', () => { AppStateEventProcessor.pendingDeeplinkSource = null; mockGetUtmAttributesFromDeeplinkUrl.mockReturnValue(null); mockGetCurrentRoute.mockReturnValue(undefined); - (LockManagerService.isAutoLockPending as jest.Mock).mockReturnValue(false); + (AppLockService.isAutoLockPending as jest.Mock).mockReturnValue(false); }); describe('without deeplink', () => { @@ -858,7 +654,7 @@ describe('handleDeeplinkSaga', () => { expect(SDKConnect.init).not.toHaveBeenCalled(); }); - it.each([Routes.ONBOARDING.LOGIN, Routes.LOCK_SCREEN])( + it.each([Routes.ONBOARDING.LOGIN])( 'leaves a pending deeplink in place when onboarding completes on %s', async (routeName) => { AppStateEventProcessor.pendingDeeplink = @@ -882,15 +678,35 @@ describe('handleDeeplinkSaga', () => { }, ); + it('leaves a pending deeplink in place while resume unlock is in progress', async () => { + AppStateEventProcessor.pendingDeeplink = + 'https://link.metamask.io/swap'; + Engine.context.KeyringController.isUnlocked = jest + .fn() + .mockReturnValue(true); + (AppLockService.isAutoLockPending as jest.Mock).mockReturnValue(true); + + await expectSaga(handleDeeplinkSaga) + .withState({ + onboarding: { completedOnboarding: true }, + user: { existingUser: true }, + }) + .dispatch(setCompletedOnboarding(true)) + .silentRun(); + + expect(SharedDeeplinkManager.parse).not.toHaveBeenCalled(); + expect( + AppStateEventProcessor.clearPendingDeeplink, + ).not.toHaveBeenCalled(); + }); + it('leaves a pending deeplink in place while auto-lock is still pending', async () => { AppStateEventProcessor.pendingDeeplink = 'https://link.metamask.io/privacy'; Engine.context.KeyringController.isUnlocked = jest .fn() .mockReturnValue(true); - (LockManagerService.isAutoLockPending as jest.Mock).mockReturnValue( - true, - ); + (AppLockService.isAutoLockPending as jest.Mock).mockReturnValue(true); await expectSaga(handleDeeplinkSaga) .withState({ diff --git a/index.js b/index.js index 3a071b6c3268..30cef0a55acd 100644 --- a/index.js +++ b/index.js @@ -38,6 +38,7 @@ import { handleCustomError, setReactNativeDefaultHandler, } from './app/core/ErrorHandler'; +import AppLockService from './app/core/AppLock/AppLockService'; import { enableFreeze } from 'react-native-screens'; @@ -47,6 +48,11 @@ if (__DEV__) { enableFreeze(true); +// Subscribe to AppState before Engine and any screen hooks do, so the privacy +// screen is raised first when the app backgrounds. Idempotent; the auth saga +// calls it again and is a no-op. +AppLockService.initialize(); + // Setup Sentry setupSentry(__DEV__); diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 137f53b0fd85..eace45138f9e 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -19,6 +19,10 @@ 15D158ED210BD912006982B5 /* Metamask.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 15D158EC210BD8C8006982B5 /* Metamask.ttf */; }; 2EF2825B2B0FF86900D7B4B1 /* File.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654378AF243E2ADC00571B9C /* File.swift */; }; 2EF2825E2B0FF86900D7B4B1 /* RCTMinimizer.m in Sources */ = {isa = PBXBuildFile; fileRef = CF9895762A3B49BE00B4C9B5 /* RCTMinimizer.m */; }; + A1C0FFEE0000000100000002 /* PrivacyCoverModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A1C0FFEE0000000100000001 /* PrivacyCoverModule.m */; }; + A1C0FFEE0000000100000003 /* PrivacyCoverModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A1C0FFEE0000000100000001 /* PrivacyCoverModule.m */; }; + A1C0FFEE0000000100000006 /* PrivacyCover.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0FFEE0000000100000005 /* PrivacyCover.swift */; }; + A1C0FFEE0000000100000007 /* PrivacyCover.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0FFEE0000000100000005 /* PrivacyCover.swift */; }; 2EF282612B0FF86900D7B4B1 /* LinkPresentation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F961A36A28105CF9007442B5 /* LinkPresentation.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 2EF282622B0FF86900D7B4B1 /* libRCTAesForked.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 650F2B9C24DC5FEC00C3B9C4 /* libRCTAesForked.a */; }; 2EF282632B0FF86900D7B4B1 /* JavaScriptCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 153C1A742217BCDC0088EFE0 /* JavaScriptCore.framework */; }; @@ -180,6 +184,8 @@ AA11BB22CC33DD44EE55FF67 /* SplashScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = MetaMask/SplashScreen.storyboard; sourceTree = ""; }; AA9EDF17249955C7005D89EE /* MetaMaskDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = MetaMaskDebug.entitlements; path = MetaMask/MetaMaskDebug.entitlements; sourceTree = ""; }; B0EF7FA827BD16EA00D48B4E /* ThemeColors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = ThemeColors.xcassets; sourceTree = ""; }; + A1C0FFEE0000000100000001 /* PrivacyCoverModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PrivacyCoverModule.m; path = MetaMask/NativeModules/PrivacyCover/PrivacyCoverModule.m; sourceTree = ""; }; + A1C0FFEE0000000100000005 /* PrivacyCover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PrivacyCover.swift; path = MetaMask/NativeModules/PrivacyCover/PrivacyCover.swift; sourceTree = ""; }; B4A2E0110100000100000C06 /* BrazePushModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = BrazePushModule.m; path = MetaMask/NativeModules/BrazePush/BrazePushModule.m; sourceTree = ""; }; B848D40B87744D32949BDC25 /* Inter-Regular.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Inter-Regular.ttf"; path = "../app/fonts/Inter-Regular.ttf"; sourceTree = ""; }; BF485CDA047B4D52852B87F5 /* EvilIcons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = EvilIcons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf"; sourceTree = ""; }; @@ -301,6 +307,7 @@ isa = PBXGroup; children = ( B4A2E0110100000100000C07 /* BrazePush */, + A1C0FFEE0000000100000004 /* PrivacyCover */, CF9895742A3B48DC00B4C9B5 /* RCTMinimizer */, ); name = NativeModules; @@ -440,6 +447,15 @@ name = ExpoModulesProviders; sourceTree = ""; }; + A1C0FFEE0000000100000004 /* PrivacyCover */ = { + isa = PBXGroup; + children = ( + A1C0FFEE0000000100000005 /* PrivacyCover.swift */, + A1C0FFEE0000000100000001 /* PrivacyCoverModule.m */, + ); + name = PrivacyCover; + sourceTree = ""; + }; B4A2E0110100000100000C07 /* BrazePush */ = { isa = PBXGroup; children = ( @@ -941,6 +957,8 @@ 2EF2832A2B17EBD600D7B4B1 /* RnTar.swift in Sources */, 2EF283372B17EC7900D7B4B1 /* Light-Swift-Untar.swift in Sources */, CF9895772A3B49BE00B4C9B5 /* RCTMinimizer.m in Sources */, + A1C0FFEE0000000100000006 /* PrivacyCover.swift in Sources */, + A1C0FFEE0000000100000002 /* PrivacyCoverModule.m in Sources */, A1987088D4835E5FCCABC418 /* ExpoModulesProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -957,6 +975,8 @@ 2EF2832C2B17EBD600D7B4B1 /* RnTar.swift in Sources */, 2EF283392B17EC7900D7B4B1 /* Light-Swift-Untar.swift in Sources */, 2EF2825E2B0FF86900D7B4B1 /* RCTMinimizer.m in Sources */, + A1C0FFEE0000000100000007 /* PrivacyCover.swift in Sources */, + A1C0FFEE0000000100000003 /* PrivacyCoverModule.m in Sources */, F23972D16903249A8EC120BD /* ExpoModulesProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/MetaMask/AppDelegate.swift b/ios/MetaMask/AppDelegate.swift index 5b8fd5e7307f..b68db033101c 100644 --- a/ios/MetaMask/AppDelegate.swift +++ b/ios/MetaMask/AppDelegate.swift @@ -133,6 +133,11 @@ class AppDelegate: ExpoAppDelegate { return superResult } + override func applicationDidEnterBackground(_ application: UIApplication) { + PrivacyCover.shared.show(in: window) + super.applicationDidEnterBackground(application) + } + override func applicationDidBecomeActive(_ application: UIApplication) { super.applicationDidBecomeActive(application) // Re-assert our delegate on every foreground entry so Braze/Notifee cannot reclaim diff --git a/ios/MetaMask/NativeModules/PrivacyCover/PrivacyCover.swift b/ios/MetaMask/NativeModules/PrivacyCover/PrivacyCover.swift new file mode 100644 index 000000000000..91e6e1ad5ff9 --- /dev/null +++ b/ios/MetaMask/NativeModules/PrivacyCover/PrivacyCover.swift @@ -0,0 +1,73 @@ +import UIKit + +/// The iOS privacy cover. `AppDelegate` shows it from +/// `applicationDidEnterBackground` and `PrivacyCoverModule` hides it once +/// resume routing resolves. Not shown for `inactive` (Control Center, the +/// app-switcher gesture). +/// +/// Splash background plus the centered splash fox, matching the Android cover. +/// A separate window so it sits above native-stack modals. +@objc(PrivacyCover) +final class PrivacyCover: NSObject { + @objc static let shared = PrivacyCover() + + private var coverWindow: UIWindow? + private let foxSide: CGFloat = 144 + + private override init() { + super.init() + } + + @objc func show(in host: UIWindow?) { + // The keyboard window sits above this cover and comes back on foreground + // while the field is still first responder. Resign here. `inactive` does + // not call show, so Control Center and Face ID keep the keyboard. + dismissKeyboard() + let scene = coverWindow?.windowScene ?? host?.windowScene + guard let scene else { + return + } + let window = coverWindow ?? makeWindow(for: scene) + window.frame = scene.coordinateSpace.bounds + window.isHidden = false + } + + @objc func hide() { + coverWindow?.isHidden = true + } + + private func dismissKeyboard() { + _ = UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + + private func makeWindow(for scene: UIWindowScene) -> UIWindow { + let window = UIWindow(windowScene: scene) + window.windowLevel = .alert + 1 + // This window is not a sibling of the wallet's views, so VoiceOver needs + // the modal flag on the window itself to stay out of the app underneath. + window.accessibilityViewIsModal = true + let root = UIViewController() + root.view.backgroundColor = UIColor(named: "splashBackground") ?? .systemBackground + root.view.accessibilityViewIsModal = true + + let fox = UIImageView(image: UIImage(named: "fox-splash-screen")) + fox.translatesAutoresizingMaskIntoConstraints = false + fox.contentMode = .scaleAspectFit + root.view.addSubview(fox) + NSLayoutConstraint.activate([ + fox.centerXAnchor.constraint(equalTo: root.view.centerXAnchor), + fox.centerYAnchor.constraint(equalTo: root.view.centerYAnchor), + fox.widthAnchor.constraint(equalToConstant: foxSide), + fox.heightAnchor.constraint(equalToConstant: foxSide), + ]) + + window.rootViewController = root + coverWindow = window + return window + } +} diff --git a/ios/MetaMask/NativeModules/PrivacyCover/PrivacyCoverModule.m b/ios/MetaMask/NativeModules/PrivacyCover/PrivacyCoverModule.m new file mode 100644 index 000000000000..e46566b82f6d --- /dev/null +++ b/ios/MetaMask/NativeModules/PrivacyCover/PrivacyCoverModule.m @@ -0,0 +1,23 @@ +#import +#import "MetaMask-Swift.h" + +@interface PrivacyCoverModule : NSObject +@end + +@implementation PrivacyCoverModule + +RCT_EXPORT_MODULE(); + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +RCT_EXPORT_METHOD(hide) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + [[PrivacyCover shared] hide]; + }); +} + +@end diff --git a/tests/performance/login/launch-times/warm-start-to-login.spec.ts b/tests/performance/login/launch-times/warm-start-to-login.spec.ts index 97849646baa0..e25d2f7a2c42 100644 --- a/tests/performance/login/launch-times/warm-start-to-login.spec.ts +++ b/tests/performance/login/launch-times/warm-start-to-login.spec.ts @@ -34,9 +34,10 @@ perfTest.describe( ) => { await loginToAppPlaywright(); + // The vault lock now runs after foreground, so it is inside this clock. const timer1 = new TimerHelper( 'Time since the user open the app again and the login screen appears', - { ios: 2500, android: 3000 }, + { ios: 2500, android: 3500 }, currentDeviceDetails.platform, );