diff --git a/__tests__/components/LockScreenOverlay.test.tsx b/__tests__/components/LockScreenOverlay.test.tsx
new file mode 100644
index 000000000..c83ed7966
--- /dev/null
+++ b/__tests__/components/LockScreenOverlay.test.tsx
@@ -0,0 +1,72 @@
+import { act } from "@testing-library/react-native";
+import { LockScreenOverlay } from "components/LockScreenOverlay";
+import { AUTH_STATUS } from "config/types";
+import { useAuthenticationStore } from "ducks/auth";
+import { renderWithProviders } from "helpers/testUtils";
+import React from "react";
+
+jest.mock("ducks/auth", () => {
+ const actual = jest.requireActual("ducks/auth");
+ return {
+ ...actual,
+ getActiveAccountPublicKey: jest.fn().mockResolvedValue(null),
+ };
+});
+
+jest.mock("services/autoLock", () => ({
+ persistAutoLockTimer: jest.fn().mockResolvedValue(undefined),
+ applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined),
+}));
+
+describe("LockScreenOverlay", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders nothing when the wallet is not soft-locked", () => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.AUTHENTICATED,
+ isSoftLocked: false,
+ });
+
+ const { queryByTestId } = renderWithProviders();
+
+ expect(queryByTestId("lock-screen-overlay")).toBeNull();
+ expect(queryByTestId("lock-screen")).toBeNull();
+ });
+
+ it("renders the lock UI above the app when soft-locked", () => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.LOCKED,
+ isSoftLocked: true,
+ });
+
+ const { getByTestId } = renderWithProviders();
+
+ expect(getByTestId("lock-screen-overlay")).toBeTruthy();
+ expect(getByTestId("lock-screen")).toBeTruthy();
+ expect(getByTestId("unlock-button")).toBeTruthy();
+ });
+
+ it("disappears when the wallet is unlocked", () => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.LOCKED,
+ isSoftLocked: true,
+ });
+
+ const { getByTestId, queryByTestId } = renderWithProviders(
+ ,
+ );
+ expect(getByTestId("lock-screen-overlay")).toBeTruthy();
+
+ // Unlock: signIn success resets the store which clears isSoftLocked
+ act(() => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.AUTHENTICATED,
+ isSoftLocked: false,
+ });
+ });
+
+ expect(queryByTestId("lock-screen-overlay")).toBeNull();
+ });
+});
diff --git a/__tests__/components/screens/LockScreen.test.tsx b/__tests__/components/screens/LockScreen.test.tsx
new file mode 100644
index 000000000..58af25ca5
--- /dev/null
+++ b/__tests__/components/screens/LockScreen.test.tsx
@@ -0,0 +1,232 @@
+import { NativeStackScreenProps } from "@react-navigation/native-stack";
+import { waitFor } from "@testing-library/react-native";
+import { LockScreen } from "components/screens/LockScreen";
+import { LoginType } from "config/constants";
+import { ROOT_NAVIGATOR_ROUTES, RootStackParamList } from "config/routes";
+import { useAuthenticationStore } from "ducks/auth";
+import { usePreferencesStore } from "ducks/preferences";
+import { renderWithProviders } from "helpers/testUtils";
+import React from "react";
+import { AppState } from "react-native";
+
+jest.mock("ducks/auth", () => {
+ const actual = jest.requireActual("ducks/auth");
+ return {
+ ...actual,
+ getActiveAccountPublicKey: jest.fn().mockResolvedValue(null),
+ };
+});
+
+jest.mock("services/autoLock", () => ({
+ persistAutoLockTimer: jest.fn().mockResolvedValue(undefined),
+ applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined),
+}));
+
+// Controllable privacy-shield state so we can assert the prompt is held until
+// the shield drops on return from background.
+let mockShieldVisible = false;
+const mockShieldHiddenListeners = new Set<() => void>();
+const emitShieldHidden = () => {
+ mockShieldVisible = false;
+ mockShieldHiddenListeners.forEach((listener) => listener());
+};
+jest.mock("helpers/privacyShield", () => ({
+ isPrivacyShieldVisible: () => mockShieldVisible,
+ onPrivacyShieldHidden: (listener: () => void) => {
+ mockShieldHiddenListeners.add(listener);
+ return () => mockShieldHiddenListeners.delete(listener);
+ },
+ markPrivacyShieldVisible: jest.fn(),
+ hidePrivacyShield: jest.fn(),
+}));
+
+type LockScreenNavigationProp = NativeStackScreenProps<
+ RootStackParamList,
+ typeof ROOT_NAVIGATOR_ROUTES.LOCK_SCREEN
+>["navigation"];
+
+type LockScreenRouteProp = NativeStackScreenProps<
+ RootStackParamList,
+ typeof ROOT_NAVIGATOR_ROUTES.LOCK_SCREEN
+>["route"];
+
+const mockNavigation = {
+ replace: jest.fn(),
+ goBack: jest.fn(),
+ setOptions: jest.fn(),
+} as unknown as LockScreenNavigationProp;
+
+const mockRoute = {
+ key: "lock-screen",
+ name: ROOT_NAVIGATOR_ROUTES.LOCK_SCREEN,
+} as unknown as LockScreenRouteProp;
+
+describe("LockScreen", () => {
+ const mockSignIn = jest.fn();
+ const mockVerifyActionWithBiometrics = jest.fn(
+ (callback: (password?: string) => Promise) =>
+ callback("biometric-password"),
+ );
+
+ const previousAppState = AppState.currentState;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (AppState as { currentState: string }).currentState = "active";
+ mockShieldVisible = false;
+ mockShieldHiddenListeners.clear();
+
+ useAuthenticationStore.setState({
+ signIn: mockSignIn,
+ verifyActionWithBiometrics:
+ mockVerifyActionWithBiometrics as unknown as ReturnType<
+ typeof useAuthenticationStore.getState
+ >["verifyActionWithBiometrics"],
+ signInMethod: LoginType.FACE,
+ isLoading: false,
+ error: null,
+ suppressBiometricAutoPrompt: false,
+ });
+ usePreferencesStore.setState({ isBiometricsEnabled: true });
+ });
+
+ afterAll(() => {
+ (AppState as { currentState: typeof previousAppState }).currentState =
+ previousAppState;
+ });
+
+ const renderLockScreen = () =>
+ renderWithProviders(
+ ,
+ );
+
+ it("auto-prompts biometrics on mount and unlocks with the stored password", async () => {
+ renderLockScreen();
+
+ await waitFor(() => {
+ expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1);
+ });
+ expect(mockSignIn).toHaveBeenCalledWith({
+ password: "biometric-password",
+ });
+ });
+
+ it("does not auto-prompt when biometrics are disabled", async () => {
+ usePreferencesStore.setState({ isBiometricsEnabled: false });
+ useAuthenticationStore.setState({ signInMethod: LoginType.PASSWORD });
+
+ renderLockScreen();
+
+ await waitFor(() => {
+ expect(mockVerifyActionWithBiometrics).not.toHaveBeenCalled();
+ });
+ expect(mockSignIn).not.toHaveBeenCalled();
+ });
+
+ it("does not auto-prompt on mount when the lock was user-initiated (idle or manual)", async () => {
+ // The user stayed in the app and idled out, or locked manually — no
+ // unprompted Face ID
+ useAuthenticationStore.setState({ suppressBiometricAutoPrompt: true });
+
+ renderLockScreen();
+
+ // Give the mount effect a chance to (not) fire
+ await waitFor(() => {
+ expect(mockSignIn).not.toHaveBeenCalled();
+ });
+ expect(mockVerifyActionWithBiometrics).not.toHaveBeenCalled();
+ });
+
+ it("still re-prompts on return from background after a user-initiated lock", async () => {
+ useAuthenticationStore.setState({ suppressBiometricAutoPrompt: true });
+
+ renderLockScreen();
+
+ // No mount prompt for the idle lock...
+ await waitFor(() => {
+ expect(mockVerifyActionWithBiometrics).not.toHaveBeenCalled();
+ });
+
+ // ...but returning from the background still prompts (coming from bg)
+ const appStateHandlers = (
+ AppState.addEventListener as jest.Mock
+ ).mock.calls.map(([, handler]) => handler as (state: string) => void);
+
+ appStateHandlers.forEach((handler) => handler("background"));
+ appStateHandlers.forEach((handler) => handler("active"));
+
+ await waitFor(() => {
+ expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("re-prompts biometrics when the app returns from the background", async () => {
+ renderLockScreen();
+
+ await waitFor(() => {
+ expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1);
+ });
+
+ // Simulate the app going to the background and returning to the foreground
+ const appStateHandlers = (
+ AppState.addEventListener as jest.Mock
+ ).mock.calls.map(([, handler]) => handler as (state: string) => void);
+
+ appStateHandlers.forEach((handler) => handler("background"));
+ appStateHandlers.forEach((handler) => handler("active"));
+
+ await waitFor(() => {
+ expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it("holds the biometric prompt until the privacy shield drops on return from background", async () => {
+ renderLockScreen();
+
+ // Mount prompt fires immediately — the shield isn't up at mount
+ await waitFor(() => {
+ expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1);
+ });
+
+ // App backgrounds (shield raised) and returns to the foreground
+ mockShieldVisible = true;
+ const appStateHandlers = (
+ AppState.addEventListener as jest.Mock
+ ).mock.calls.map(([, handler]) => handler as (state: string) => void);
+
+ appStateHandlers.forEach((handler) => handler("background"));
+ appStateHandlers.forEach((handler) => handler("active"));
+
+ // The prompt is held while the shield still covers the wallet
+ await waitFor(() => {
+ expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1);
+ });
+
+ // Once the shield drops, the held prompt fires so Face ID appears over
+ // the now-visible lock screen
+ emitShieldHidden();
+
+ await waitFor(() => {
+ expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it("does not re-prompt on inactive-to-active transitions (e.g. the biometric overlay itself)", async () => {
+ renderLockScreen();
+
+ await waitFor(() => {
+ expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1);
+ });
+
+ const appStateHandlers = (
+ AppState.addEventListener as jest.Mock
+ ).mock.calls.map(([, handler]) => handler as (state: string) => void);
+
+ appStateHandlers.forEach((handler) => handler("inactive"));
+ appStateHandlers.forEach((handler) => handler("active"));
+
+ await waitFor(() => {
+ expect(mockVerifyActionWithBiometrics).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx b/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx
new file mode 100644
index 000000000..322e24c6b
--- /dev/null
+++ b/__tests__/components/screens/SettingsScreen/AutoLockTimerScreen.test.tsx
@@ -0,0 +1,96 @@
+import { NativeStackScreenProps } from "@react-navigation/native-stack";
+import { userEvent } from "@testing-library/react-native";
+import AutoLockTimerScreen from "components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen";
+import { AUTO_LOCK_TIMER, DEFAULT_AUTO_LOCK_TIMER } from "config/constants";
+import { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes";
+import { usePreferencesStore } from "ducks/preferences";
+import { renderWithProviders } from "helpers/testUtils";
+import React from "react";
+
+jest.mock("services/autoLock", () => ({
+ persistAutoLockTimer: jest.fn().mockResolvedValue(undefined),
+ applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined),
+}));
+
+type AutoLockTimerScreenNavigationProp = NativeStackScreenProps<
+ SettingsStackParamList,
+ typeof SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN
+>["navigation"];
+
+type AutoLockTimerScreenRouteProp = NativeStackScreenProps<
+ SettingsStackParamList,
+ typeof SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN
+>["route"];
+
+const mockNavigation = {
+ goBack: jest.fn(),
+ setOptions: jest.fn(),
+} as unknown as AutoLockTimerScreenNavigationProp;
+
+const mockRoute = {
+ key: "auto-lock-timer",
+ name: SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN,
+} as unknown as AutoLockTimerScreenRouteProp;
+
+describe("AutoLockTimerScreen", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ usePreferencesStore.setState({ autoLockTimer: DEFAULT_AUTO_LOCK_TIMER });
+ });
+
+ const renderAutoLockTimerScreen = () =>
+ renderWithProviders(
+ ,
+ );
+
+ it("renders all timer options", () => {
+ const { getByTestId } = renderAutoLockTimerScreen();
+
+ expect(
+ getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.IMMEDIATELY}`),
+ ).toBeTruthy();
+ expect(
+ getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.ONE_MINUTE}`),
+ ).toBeTruthy();
+ expect(
+ getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.FIFTEEN_MINUTES}`),
+ ).toBeTruthy();
+ expect(
+ getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.THIRTY_MINUTES}`),
+ ).toBeTruthy();
+ expect(
+ getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.ONE_HOUR}`),
+ ).toBeTruthy();
+ expect(
+ getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.TWELVE_HOURS}`),
+ ).toBeTruthy();
+ expect(
+ getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS}`),
+ ).toBeTruthy();
+ expect(
+ getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.NONE}`),
+ ).toBeTruthy();
+ });
+
+ it("renders the footer explanation", () => {
+ const { getByText } = renderAutoLockTimerScreen();
+
+ expect(
+ getByText(
+ "After a set time, you will be prompted for your password again as an extra security measure.",
+ ),
+ ).toBeTruthy();
+ });
+
+ it("updates the preference when an option is tapped", async () => {
+ const { getByTestId } = renderAutoLockTimerScreen();
+
+ await userEvent.press(
+ getByTestId(`auto-lock-option-${AUTO_LOCK_TIMER.FIFTEEN_MINUTES}`),
+ );
+
+ expect(usePreferencesStore.getState().autoLockTimer).toBe(
+ AUTO_LOCK_TIMER.FIFTEEN_MINUTES,
+ );
+ });
+});
diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts
index 074afb574..0bb337a96 100644
--- a/__tests__/ducks/auth.test.ts
+++ b/__tests__/ducks/auth.test.ts
@@ -1,6 +1,8 @@
import { NavigationContainerRef } from "@react-navigation/native";
import { act, renderHook } from "@testing-library/react-hooks";
import {
+ AUTO_LOCK_TIMER,
+ DEFAULT_AUTO_LOCK_TIMER,
NETWORKS,
STORAGE_KEYS,
SENSITIVE_STORAGE_KEYS,
@@ -30,6 +32,7 @@ import {
} from "helpers/encryptPassword";
import { createKeyManager } from "helpers/keyManager/keyManager";
import { clearScreenshotDek } from "helpers/screenshotCrypto";
+import { AppState } from "react-native";
import { getSupportedBiometryType, BIOMETRY_TYPE } from "react-native-keychain";
import {
clearNonSensitiveData,
@@ -111,7 +114,11 @@ jest.mock("services/storage/secureStorage", () => ({
jest.mock("ducks/preferences", () => ({
usePreferencesStore: {
- getState: jest.fn(),
+ getState: jest.fn(() => ({
+ isBiometricsEnabled: false,
+ setAutoLockTimer: jest.fn(),
+ })),
+ setState: jest.fn(),
},
}));
@@ -266,6 +273,7 @@ describe("auth duck", () => {
setNavigationRef: useAuthenticationStore.getState().setNavigationRef,
signIn: useAuthenticationStore.getState().signIn,
initializeNetwork: useAuthenticationStore.getState().initializeNetwork,
+ softLock: useAuthenticationStore.getState().softLock,
};
beforeEach(() => {
@@ -1433,6 +1441,11 @@ describe("auth duck", () => {
SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
AUTH_STATUS.LOCKED,
);
+
+ // A manual lock suppresses the lock screen's biometric auto-prompt
+ expect(
+ useAuthenticationStore.getState().suppressBiometricAutoPrompt,
+ ).toBe(true);
});
it("should wipe all data on logout when shouldWipeAllData is true", async () => {
@@ -1473,6 +1486,12 @@ describe("auth duck", () => {
expect(dataStorage.remove).toHaveBeenCalledWith(
STORAGE_KEYS.COLLECTIBLES_LIST,
);
+
+ // Auto-lock is reset to the default so the next wallet doesn't inherit
+ // the wiped wallet's timer
+ expect(usePreferencesStore.setState).toHaveBeenCalledWith({
+ autoLockTimer: DEFAULT_AUTO_LOCK_TIMER,
+ });
});
it("should call resetRoot to AUTH_STACK on logout(true) even though ...initialState clears navigationRef", async () => {
@@ -1610,6 +1629,378 @@ describe("auth duck", () => {
});
});
+ describe("getAuthStatus with auto-lock timer", () => {
+ const ONE_HOUR_MS = 3600000;
+
+ const mockAuthenticatedStorage = ({
+ backgroundedAt,
+ autoLockTimer,
+ }: {
+ backgroundedAt: number | null;
+ autoLockTimer: AUTO_LOCK_TIMER | null;
+ }) => {
+ (dataStorage.getItem as jest.Mock).mockImplementation((key) => {
+ if (key === STORAGE_KEYS.ACCOUNT_LIST) {
+ return Promise.resolve(JSON.stringify([mockAccount]));
+ }
+ return Promise.resolve(null);
+ });
+
+ (secureDataStorage.getItem as jest.Mock).mockImplementation((key) => {
+ if (key === SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE) {
+ return Promise.resolve("encrypted-temp-store");
+ }
+ if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT) {
+ return Promise.resolve(
+ backgroundedAt ? String(backgroundedAt) : null,
+ );
+ }
+ if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING) {
+ return Promise.resolve(autoLockTimer);
+ }
+ // No persisted AUTH_STATUS (not LOCKED)
+ return Promise.resolve(null);
+ });
+
+ (getHashKey as jest.Mock).mockResolvedValue({
+ hashKey: "mock-hash-key",
+ salt: "mock-salt",
+ expiresAt: Date.now() + ONE_HOUR_MS,
+ });
+
+ (secureDataStorage.remove as jest.Mock).mockResolvedValue(undefined);
+ (secureDataStorage.setItem as jest.Mock).mockResolvedValue(undefined);
+ };
+
+ const restoreGetAuthStatus = () => {
+ act(() => {
+ useAuthenticationStore.setState({
+ getAuthStatus: originalStoreMethods.getAuthStatus,
+ });
+ });
+ };
+
+ it("should soft-lock when the background duration exceeds the timer", async () => {
+ const { result } = renderHook(() => useAuthenticationStore());
+ restoreGetAuthStatus();
+
+ mockAuthenticatedStorage({
+ backgroundedAt: Date.now() - 2 * ONE_HOUR_MS,
+ autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR,
+ });
+
+ await act(async () => {
+ const status = await result.current.getAuthStatus();
+ expect(status).toBe(AUTH_STATUS.LOCKED);
+ });
+
+ // LOCKED state is persisted and the timestamp is consumed
+ expect(secureDataStorage.setItem).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
+ AUTH_STATUS.LOCKED,
+ );
+ expect(secureDataStorage.remove).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
+ );
+ });
+
+ it("should stay authenticated and consume the timestamp WITHOUT refreshing the hash key TTL", async () => {
+ const { result } = renderHook(() => useAuthenticationStore());
+ restoreGetAuthStatus();
+
+ // The jest AppState mock has no real currentState; the consume
+ // branch only runs when the app is actively foregrounded
+ const previousAppState = AppState.currentState;
+ (AppState as { currentState: string }).currentState = "active";
+
+ mockAuthenticatedStorage({
+ backgroundedAt: Date.now() - 60000, // 1 minute ago
+ autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR,
+ });
+
+ await act(async () => {
+ const status = await result.current.getAuthStatus();
+ expect(status).toBe(AUTH_STATUS.AUTHENTICATED);
+ });
+
+ // Timestamp is consumed...
+ expect(secureDataStorage.remove).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
+ );
+ // ...but the hash key expiry must NOT advance without credential
+ // verification (key material lifetime stays bounded)
+ expect(secureDataStorage.setItem).not.toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.HASH_KEY,
+ expect.any(String),
+ );
+
+ (AppState as { currentState: typeof previousAppState }).currentState =
+ previousAppState;
+ });
+
+ it("should return HASH_KEY_EXPIRED when the hash key expired even if within the timer", async () => {
+ const { result } = renderHook(() => useAuthenticationStore());
+ restoreGetAuthStatus();
+
+ const previousAppState = AppState.currentState;
+ (AppState as { currentState: string }).currentState = "active";
+
+ mockAuthenticatedStorage({
+ backgroundedAt: Date.now() - 60000, // 1 minute ago, within timer
+ autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR,
+ });
+
+ // Hash key hard-expired (e.g. > 24h since the last unlock)
+ (getHashKey as jest.Mock).mockResolvedValue({
+ hashKey: "mock-hash-key",
+ salt: "mock-salt",
+ expiresAt: Date.now() - ONE_HOUR_MS,
+ });
+
+ await act(async () => {
+ const status = await result.current.getAuthStatus();
+ expect(status).toBe(AUTH_STATUS.HASH_KEY_EXPIRED);
+ });
+
+ (AppState as { currentState: typeof previousAppState }).currentState =
+ previousAppState;
+ });
+
+ it("should return HASH_KEY_EXPIRED (not LOCKED) when backgrounded beyond BOTH the timer and the hash-key TTL", async () => {
+ // The reviewer's scenario: the timer branch would otherwise return a
+ // fast-path LOCKED that refreshes the expired key, silently defeating
+ // the 24h hard-expiry backstop. Expiry must win → full re-auth.
+ const { result } = renderHook(() => useAuthenticationStore());
+ restoreGetAuthStatus();
+
+ mockAuthenticatedStorage({
+ backgroundedAt: Date.now() - 2 * ONE_HOUR_MS, // beyond the 1h timer
+ autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR,
+ });
+
+ // ...and the hash key has hard-expired (beyond its TTL)
+ (getHashKey as jest.Mock).mockResolvedValue({
+ hashKey: "mock-hash-key",
+ salt: "mock-salt",
+ expiresAt: Date.now() - ONE_HOUR_MS,
+ });
+
+ await act(async () => {
+ const status = await result.current.getAuthStatus();
+ expect(status).toBe(AUTH_STATUS.HASH_KEY_EXPIRED);
+ });
+
+ // Must NOT have converted the session to a soft timer lock
+ expect(secureDataStorage.setItem).not.toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
+ AUTH_STATUS.LOCKED,
+ );
+ });
+
+ it("should never soft-lock when the timer is NONE", async () => {
+ const { result } = renderHook(() => useAuthenticationStore());
+ restoreGetAuthStatus();
+
+ mockAuthenticatedStorage({
+ backgroundedAt: Date.now() - 100 * ONE_HOUR_MS,
+ autoLockTimer: AUTO_LOCK_TIMER.NONE,
+ });
+
+ await act(async () => {
+ const status = await result.current.getAuthStatus();
+ expect(status).toBe(AUTH_STATUS.AUTHENTICATED);
+ });
+
+ expect(secureDataStorage.setItem).not.toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
+ AUTH_STATUS.LOCKED,
+ );
+ });
+
+ it("does NOT re-lock via getAuthStatus for IMMEDIATELY (background-only) — it consumes a lingering timestamp instead", async () => {
+ // IMMEDIATELY is locked proactively on backgrounding (useAuthCheck);
+ // its 0ms duration must NOT make getAuthStatus re-lock a fresh
+ // foreground session off a lingering backgrounded-at timestamp, which
+ // would make the app unusable after unlock.
+ const { result } = renderHook(() => useAuthenticationStore());
+ restoreGetAuthStatus();
+
+ const previousAppState = AppState.currentState;
+ (AppState as { currentState: string }).currentState = "active";
+
+ mockAuthenticatedStorage({
+ backgroundedAt: Date.now() - 60000, // a stale timestamp
+ autoLockTimer: AUTO_LOCK_TIMER.IMMEDIATELY,
+ });
+
+ await act(async () => {
+ const status = await result.current.getAuthStatus();
+ expect(status).toBe(AUTH_STATUS.AUTHENTICATED);
+ });
+
+ // Must NOT persist LOCKED, and should consume the stale timestamp
+ expect(secureDataStorage.setItem).not.toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
+ AUTH_STATUS.LOCKED,
+ );
+ expect(secureDataStorage.remove).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
+ );
+
+ (AppState as { currentState: typeof previousAppState }).currentState =
+ previousAppState;
+ });
+
+ it("should default to 24 hours when no timer preference is persisted", async () => {
+ const { result } = renderHook(() => useAuthenticationStore());
+ restoreGetAuthStatus();
+
+ mockAuthenticatedStorage({
+ backgroundedAt: Date.now() - 25 * ONE_HOUR_MS,
+ autoLockTimer: null,
+ });
+
+ await act(async () => {
+ const status = await result.current.getAuthStatus();
+ expect(status).toBe(AUTH_STATUS.LOCKED);
+ });
+ });
+
+ it("should not evaluate the timer when no backgrounded-at timestamp exists", async () => {
+ const { result } = renderHook(() => useAuthenticationStore());
+ restoreGetAuthStatus();
+
+ mockAuthenticatedStorage({
+ backgroundedAt: null,
+ autoLockTimer: AUTO_LOCK_TIMER.ONE_HOUR,
+ });
+
+ await act(async () => {
+ const status = await result.current.getAuthStatus();
+ expect(status).toBe(AUTH_STATUS.AUTHENTICATED);
+ });
+
+ expect(secureDataStorage.remove).not.toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
+ );
+ });
+ });
+
+ describe("softLock", () => {
+ it("should set LOCKED with isSoftLocked and persist the status without touching navigation", async () => {
+ const { result } = renderHook(() => useAuthenticationStore());
+
+ const mockResetRoot = jest.fn();
+ const softLockNavigationRef = {
+ isReady: jest.fn().mockReturnValue(true),
+ getCurrentRoute: jest.fn().mockReturnValue({ name: "Home" }),
+ resetRoot: mockResetRoot,
+ } as unknown as NavigationContainerRef;
+
+ act(() => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.AUTHENTICATED,
+ isSoftLocked: false,
+ navigationRef: softLockNavigationRef,
+ });
+ });
+
+ await act(async () => {
+ await result.current.softLock();
+ });
+
+ expect(result.current.authStatus).toBe(AUTH_STATUS.LOCKED);
+ expect(result.current.isSoftLocked).toBe(true);
+ // The whole point of softLock: the navigation tree stays untouched
+ expect(mockResetRoot).not.toHaveBeenCalled();
+ expect(secureDataStorage.setItem).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
+ AUTH_STATUS.LOCKED,
+ );
+ });
+
+ it("should funnel a warm timer lock through softLock atomically", async () => {
+ const { result } = renderHook(() => useAuthenticationStore());
+ act(() => {
+ useAuthenticationStore.setState({
+ getAuthStatus: originalStoreMethods.getAuthStatus,
+ softLock: originalStoreMethods.softLock,
+ authStatus: AUTH_STATUS.AUTHENTICATED,
+ isSoftLocked: false,
+ });
+ });
+
+ // Storage says the auto-lock timer fired while we were AUTHENTICATED
+ (dataStorage.getItem as jest.Mock).mockImplementation((key) => {
+ if (key === STORAGE_KEYS.ACCOUNT_LIST) {
+ return Promise.resolve(JSON.stringify([mockAccount]));
+ }
+ return Promise.resolve(null);
+ });
+ (secureDataStorage.getItem as jest.Mock).mockImplementation((key) => {
+ if (key === SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE) {
+ return Promise.resolve("encrypted-temp-store");
+ }
+ if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT) {
+ return Promise.resolve(String(Date.now() - 7200000)); // 2h ago
+ }
+ if (key === SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING) {
+ return Promise.resolve(AUTO_LOCK_TIMER.ONE_HOUR);
+ }
+ return Promise.resolve(null);
+ });
+ (getHashKey as jest.Mock).mockResolvedValue({
+ hashKey: "mock-hash-key",
+ salt: "mock-salt",
+ expiresAt: Date.now() + 3600000,
+ });
+
+ // RootNavigator must NEVER observe LOCKED && !isSoftLocked — that
+ // combination unmounts the preserved navigation tree
+ const observedInvalidStates: string[] = [];
+ const unsubscribe = useAuthenticationStore.subscribe((state) => {
+ if (state.authStatus === AUTH_STATUS.LOCKED && !state.isSoftLocked) {
+ observedInvalidStates.push(state.authStatus);
+ }
+ });
+
+ await act(async () => {
+ const status = await result.current.getAuthStatus();
+ expect(status).toBe(AUTH_STATUS.LOCKED);
+ });
+
+ unsubscribe();
+ expect(observedInvalidStates).toHaveLength(0);
+ expect(result.current.authStatus).toBe(AUTH_STATUS.LOCKED);
+ expect(result.current.isSoftLocked).toBe(true);
+ });
+
+ it("should make navigateToLockScreen a no-op while soft-locked", () => {
+ const { result } = renderHook(() => useAuthenticationStore());
+
+ const mockResetRoot = jest.fn();
+ const softLockedNavigationRef = {
+ isReady: jest.fn().mockReturnValue(true),
+ getCurrentRoute: jest.fn().mockReturnValue({ name: "Home" }),
+ resetRoot: mockResetRoot,
+ } as unknown as NavigationContainerRef;
+
+ act(() => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.LOCKED,
+ isSoftLocked: true,
+ navigationRef: softLockedNavigationRef,
+ });
+ });
+
+ act(() => {
+ result.current.navigateToLockScreen();
+ });
+
+ expect(mockResetRoot).not.toHaveBeenCalled();
+ });
+ });
+
describe("signIn from LOCKED state", () => {
it("should authenticate from LOCKED state and refresh TTL on the existing hash key", async () => {
// Update the keyManager mock to return the correct account
diff --git a/__tests__/ducks/preferences.test.ts b/__tests__/ducks/preferences.test.ts
new file mode 100644
index 000000000..abd4e86dc
--- /dev/null
+++ b/__tests__/ducks/preferences.test.ts
@@ -0,0 +1,92 @@
+import { act, renderHook } from "@testing-library/react-hooks";
+import { AUTO_LOCK_TIMER, DEFAULT_AUTO_LOCK_TIMER } from "config/constants";
+import { usePreferencesStore } from "ducks/preferences";
+import {
+ applyAutoLockTimerToHashKey,
+ persistAutoLockTimer,
+} from "services/autoLock";
+
+jest.mock("services/autoLock", () => ({
+ persistAutoLockTimer: jest.fn().mockResolvedValue(undefined),
+ applyAutoLockTimerToHashKey: jest.fn().mockResolvedValue(undefined),
+ getAutoLockTimer: jest
+ .fn()
+ .mockResolvedValue(
+ jest.requireActual("config/constants").DEFAULT_AUTO_LOCK_TIMER,
+ ),
+}));
+
+// setAutoLockTimer sequences the persist + hash-TTL writes in an async IIFE,
+// so let those microtasks settle before asserting.
+const flushMicrotasks = async () => {
+ for (let i = 0; i < 5; i += 1) {
+ // eslint-disable-next-line no-await-in-loop
+ await Promise.resolve();
+ }
+};
+
+describe("preferences store", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("defaults the auto-lock timer to 12 hours", () => {
+ const { result } = renderHook(() => usePreferencesStore());
+
+ expect(result.current.autoLockTimer).toBe(DEFAULT_AUTO_LOCK_TIMER);
+ expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.TWELVE_HOURS);
+ });
+
+ it("updates the auto-lock timer and writes through to the mirror", async () => {
+ const { result } = renderHook(() => usePreferencesStore());
+
+ await act(async () => {
+ result.current.setAutoLockTimer(AUTO_LOCK_TIMER.FIFTEEN_MINUTES);
+ await flushMicrotasks();
+ });
+
+ expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.FIFTEEN_MINUTES);
+ expect(persistAutoLockTimer).toHaveBeenCalledWith(
+ AUTO_LOCK_TIMER.FIFTEEN_MINUTES,
+ );
+ // The hash-key TTL re-anchor is sequenced after the mirror write succeeds
+ expect(applyAutoLockTimerToHashKey).toHaveBeenCalledWith(
+ AUTO_LOCK_TIMER.FIFTEEN_MINUTES,
+ );
+ });
+
+ it("reverts the auto-lock timer when the mirror write fails", async () => {
+ const { result } = renderHook(() => usePreferencesStore());
+
+ await act(async () => {
+ result.current.setAutoLockTimer(AUTO_LOCK_TIMER.ONE_HOUR);
+ await flushMicrotasks();
+ });
+ expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.ONE_HOUR);
+
+ (persistAutoLockTimer as jest.Mock).mockRejectedValueOnce(
+ new Error("storage unavailable"),
+ );
+
+ await act(async () => {
+ result.current.setAutoLockTimer(AUTO_LOCK_TIMER.ONE_MINUTE);
+ // Allow the rejected persist promise to settle and trigger the revert
+ await flushMicrotasks();
+ });
+
+ // The displayed selection must never disagree with the enforced mirror
+ expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.ONE_HOUR);
+ });
+
+ it("hydrates the auto-lock timer from the secure mirror", async () => {
+ const { getAutoLockTimer } = jest.requireMock("services/autoLock");
+ getAutoLockTimer.mockResolvedValueOnce(AUTO_LOCK_TIMER.FIFTEEN_MINUTES);
+ const { result } = renderHook(() => usePreferencesStore());
+
+ await act(async () => {
+ await result.current.hydrateAutoLockTimer();
+ });
+
+ expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.FIFTEEN_MINUTES);
+ });
+});
diff --git a/__tests__/hooks/useAuthCheck.test.tsx b/__tests__/hooks/useAuthCheck.test.tsx
new file mode 100644
index 000000000..2ce6d39d9
--- /dev/null
+++ b/__tests__/hooks/useAuthCheck.test.tsx
@@ -0,0 +1,346 @@
+import { renderHook, act } from "@testing-library/react-hooks";
+import { AUTO_LOCK_TIMER } from "config/constants";
+import { AUTH_STATUS } from "config/types";
+import { useAuthenticationStore } from "ducks/auth";
+import useAuthCheck from "hooks/useAuthCheck";
+import { AppState } from "react-native";
+import {
+ getAutoLockTimer,
+ hasPersistedSession,
+ recordBackgroundedAt,
+} from "services/autoLock";
+
+jest.mock("services/autoLock", () => ({
+ getAutoLockTimer: jest.fn(),
+ hasPersistedSession: jest.fn().mockResolvedValue(false),
+ recordBackgroundedAt: jest.fn().mockResolvedValue(undefined),
+}));
+
+// components/App pulls in the full app tree (RootNavigator → native-only
+// modules); stub it to just the navigationRef the hook subscribes to.
+const mockNavUnsubscribe = jest.fn();
+const mockNavAddListener = jest.fn<() => void, [string, () => void]>(
+ () => mockNavUnsubscribe,
+);
+jest.mock("components/App", () => ({
+ navigationRef: {
+ addListener: (event: string, callback: () => void) =>
+ mockNavAddListener(event, callback),
+ },
+}));
+
+const flushMicrotasks = async () => {
+ // Several rounds so the async background handler (session check → record →
+ // timer read → soft lock) fully settles.
+ for (let i = 0; i < 6; i += 1) {
+ // eslint-disable-next-line no-await-in-loop
+ await Promise.resolve();
+ }
+};
+
+// Real durations from config/constants: ONE_MINUTE is the shortest timed
+// preset, so idle tests step across the 60s boundary.
+const ONE_MINUTE_MS = 60000;
+
+describe("useAuthCheck", () => {
+ const mockGetAuthStatus = jest
+ .fn()
+ .mockResolvedValue(AUTH_STATUS.AUTHENTICATED);
+ const mockSoftLock = jest.fn().mockResolvedValue(undefined);
+ const previousAppState = AppState.currentState;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+
+ // The RN jest mock leaves currentState as a jest.fn; the hook tracks it
+ // as a string
+ (AppState as { currentState: string }).currentState = "active";
+
+ (getAutoLockTimer as jest.Mock).mockResolvedValue(
+ AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS,
+ );
+ (hasPersistedSession as jest.Mock).mockResolvedValue(false);
+
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.AUTHENTICATED,
+ isSoftLocked: false,
+ getAuthStatus: mockGetAuthStatus,
+ softLock: mockSoftLock,
+ });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ afterAll(() => {
+ (AppState as { currentState: typeof previousAppState }).currentState =
+ previousAppState;
+ });
+
+ const getAppStateHandlers = () =>
+ (AppState.addEventListener as jest.Mock).mock.calls
+ .filter(([eventName]) => eventName === "change")
+ .map(([, handler]) => handler as (state: string) => void);
+
+ const renderAuthCheck = () => {
+ const rendered = renderHook(() => useAuthCheck());
+ const handlers = getAppStateHandlers();
+ return { ...rendered, handlers };
+ };
+
+ it("records the backgrounded-at timestamp when the app goes to the background while authenticated", async () => {
+ const { handlers, unmount } = renderAuthCheck();
+
+ await act(async () => {
+ handlers.forEach((handler) => handler("background"));
+ await flushMicrotasks();
+ });
+
+ expect(recordBackgroundedAt).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it("does NOT record a timestamp on inactive transitions", async () => {
+ const { handlers, unmount } = renderAuthCheck();
+
+ await act(async () => {
+ handlers.forEach((handler) => handler("inactive"));
+ await flushMicrotasks();
+ });
+
+ expect(recordBackgroundedAt).not.toHaveBeenCalled();
+ expect(mockSoftLock).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it("does NOT record a timestamp when not authenticated", async () => {
+ useAuthenticationStore.setState({ authStatus: AUTH_STATUS.LOCKED });
+ const { handlers, unmount } = renderAuthCheck();
+
+ await act(async () => {
+ handlers.forEach((handler) => handler("background"));
+ await flushMicrotasks();
+ });
+
+ expect(recordBackgroundedAt).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it("records the timestamp when a session is persisted but the status has not hydrated yet", async () => {
+ // Cold launch into an existing session, backgrounded before getAuthStatus
+ // runs: zustand still holds the initial NOT_AUTHENTICATED status.
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.NOT_AUTHENTICATED,
+ });
+ (hasPersistedSession as jest.Mock).mockResolvedValue(true);
+ const { handlers, unmount } = renderAuthCheck();
+
+ await act(async () => {
+ handlers.forEach((handler) => handler("background"));
+ await flushMicrotasks();
+ });
+
+ expect(recordBackgroundedAt).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it("does NOT record when not authenticated and no session is persisted", async () => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.NOT_AUTHENTICATED,
+ });
+ (hasPersistedSession as jest.Mock).mockResolvedValue(false);
+ const { handlers, unmount } = renderAuthCheck();
+
+ await act(async () => {
+ handlers.forEach((handler) => handler("background"));
+ await flushMicrotasks();
+ });
+
+ expect(recordBackgroundedAt).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it("soft-locks immediately on backgrounding when the timer is IMMEDIATELY", async () => {
+ (getAutoLockTimer as jest.Mock).mockResolvedValue(
+ AUTO_LOCK_TIMER.IMMEDIATELY,
+ );
+ const { handlers, unmount } = renderAuthCheck();
+
+ await act(async () => {
+ handlers.forEach((handler) => handler("background"));
+ await flushMicrotasks();
+ });
+
+ expect(recordBackgroundedAt).toHaveBeenCalledTimes(1);
+ expect(mockSoftLock).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it("does NOT soft-lock on backgrounding for timed options", async () => {
+ (getAutoLockTimer as jest.Mock).mockResolvedValue(AUTO_LOCK_TIMER.ONE_HOUR);
+ const { handlers, unmount } = renderAuthCheck();
+
+ await act(async () => {
+ handlers.forEach((handler) => handler("background"));
+ await flushMicrotasks();
+ });
+
+ expect(recordBackgroundedAt).toHaveBeenCalledTimes(1);
+ expect(mockSoftLock).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it("delegates lock transitions to the store getAuthStatus funnel on foreground return", async () => {
+ const { handlers, unmount } = renderAuthCheck();
+
+ await act(async () => {
+ // background → active triggers the delayed auth check; advance past
+ // MIN_CHECK_INTERVAL and the active check interval so checkAuth runs
+ handlers.forEach((handler) => handler("background"));
+ handlers.forEach((handler) => handler("active"));
+ jest.advanceTimersByTime(6000);
+ await flushMicrotasks();
+ });
+
+ expect(mockGetAuthStatus).toHaveBeenCalled();
+ unmount();
+ });
+
+ it("idle-locks while foregrounded after the timer with no interaction", async () => {
+ (getAutoLockTimer as jest.Mock).mockResolvedValue(
+ AUTO_LOCK_TIMER.ONE_MINUTE,
+ );
+ const { unmount } = renderAuthCheck();
+
+ await act(async () => {
+ jest.advanceTimersByTime(1000); // let the periodic check interval set up
+ await flushMicrotasks();
+ });
+ await act(async () => {
+ // No interaction; advance past the 1-minute idle timeout so a periodic
+ // check observes the idle and soft-locks
+ jest.advanceTimersByTime(ONE_MINUTE_MS + 5000);
+ await flushMicrotasks();
+ });
+
+ expect(mockSoftLock).toHaveBeenCalled();
+ unmount();
+ });
+
+ it("does NOT idle-lock before the timer elapses", async () => {
+ (getAutoLockTimer as jest.Mock).mockResolvedValue(
+ AUTO_LOCK_TIMER.ONE_MINUTE,
+ );
+ const { unmount } = renderAuthCheck();
+
+ await act(async () => {
+ jest.advanceTimersByTime(1000);
+ await flushMicrotasks();
+ });
+ await act(async () => {
+ // Half the timeout — still under a minute, so no idle-lock
+ jest.advanceTimersByTime(ONE_MINUTE_MS / 2);
+ await flushMicrotasks();
+ });
+
+ expect(mockSoftLock).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it("resets the idle clock on unlock so it does not immediately re-lock", async () => {
+ // The lock screen / overlay sits outside this provider's PanResponder, so
+ // its touches don't update the idle clock — unlock must reset it or a
+ // fresh session would re-lock at once.
+ (getAutoLockTimer as jest.Mock).mockResolvedValue(
+ AUTO_LOCK_TIMER.ONE_MINUTE,
+ );
+ const { unmount } = renderAuthCheck();
+
+ // Idle most of the way to the timeout while authenticated
+ await act(async () => {
+ jest.advanceTimersByTime(ONE_MINUTE_MS - 10000);
+ await flushMicrotasks();
+ });
+
+ // Lock, then unlock — the unlock-reset effect must restart the idle clock
+ await act(async () => {
+ useAuthenticationStore.setState({ authStatus: AUTH_STATUS.LOCKED });
+ await flushMicrotasks();
+ });
+ await act(async () => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.AUTHENTICATED,
+ });
+ await flushMicrotasks();
+ });
+ mockSoftLock.mockClear();
+
+ // Another 50s: ~100s since mount but only ~50s since the unlock reset
+ // (under the minute) → must NOT lock
+ await act(async () => {
+ jest.advanceTimersByTime(ONE_MINUTE_MS - 10000);
+ await flushMicrotasks();
+ });
+
+ expect(mockSoftLock).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it("subscribes to navigation changes as an interaction signal", () => {
+ const { unmount } = renderAuthCheck();
+
+ expect(mockNavAddListener).toHaveBeenCalledWith(
+ "state",
+ expect.any(Function),
+ );
+ unmount();
+ });
+
+ it("resets the idle clock on a navigation change so a multi-screen flow is not idle-locked", async () => {
+ (getAutoLockTimer as jest.Mock).mockResolvedValue(
+ AUTO_LOCK_TIMER.ONE_MINUTE,
+ );
+ const { unmount } = renderAuthCheck();
+
+ // The hook registers a "state" listener; grab the callback the same way
+ // the navigation container would invoke it on a route change.
+ const navListener = mockNavAddListener.mock.calls.find(
+ ([eventName]) => eventName === "state",
+ )?.[1] as () => void;
+
+ await act(async () => {
+ // Idle most of the way to the timeout, then navigate just before it
+ jest.advanceTimersByTime(ONE_MINUTE_MS - 10000);
+ navListener();
+ await flushMicrotasks();
+ });
+ await act(async () => {
+ // Another ~50s: well over a minute since mount, but only ~50s since the
+ // navigation reset → no idle-lock
+ jest.advanceTimersByTime(ONE_MINUTE_MS - 10000);
+ await flushMicrotasks();
+ });
+
+ expect(mockSoftLock).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it("does NOT idle-lock for the NONE preset", async () => {
+ (getAutoLockTimer as jest.Mock).mockResolvedValue(AUTO_LOCK_TIMER.NONE);
+ const { unmount } = renderAuthCheck();
+
+ await act(async () => {
+ jest.advanceTimersByTime(1000);
+ await flushMicrotasks();
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(ONE_MINUTE_MS * 2);
+ await flushMicrotasks();
+ });
+
+ expect(mockSoftLock).not.toHaveBeenCalled();
+ unmount();
+ });
+});
diff --git a/__tests__/hooks/useGetActiveAccountSigningGuard.test.tsx b/__tests__/hooks/useGetActiveAccountSigningGuard.test.tsx
new file mode 100644
index 000000000..a8063d12f
--- /dev/null
+++ b/__tests__/hooks/useGetActiveAccountSigningGuard.test.tsx
@@ -0,0 +1,96 @@
+import {
+ Account,
+ Keypair,
+ TransactionBuilder,
+ Networks,
+} from "@stellar/stellar-sdk";
+import { renderHook } from "@testing-library/react-hooks";
+import { AUTH_STATUS } from "config/types";
+import { useAuthenticationStore } from "ducks/auth";
+
+// Use the REAL hook (it is globally mocked in jest.setup.js)
+jest.unmock("hooks/useGetActiveAccount");
+
+// The real hook imports the navigation ref from the App entry; stub it so we
+// don't pull in the whole app tree
+jest.mock("components/App", () => ({
+ navigationRef: { isReady: () => false },
+}));
+
+// eslint-disable-next-line import/first, @typescript-eslint/no-var-requires
+const useGetActiveAccount = require("hooks/useGetActiveAccount").default;
+
+const testKeypair = Keypair.random();
+
+const buildUnsignedTransaction = () => {
+ const source = new Account(testKeypair.publicKey(), "1");
+ return new TransactionBuilder(source, {
+ fee: "100",
+ networkPassphrase: Networks.TESTNET,
+ })
+ .setTimeout(30)
+ .build();
+};
+
+describe("useGetActiveAccount signing guard", () => {
+ beforeEach(() => {
+ // Neutralise the mount-time side effects of the real hook
+ useAuthenticationStore.setState({
+ account: {
+ publicKey: testKeypair.publicKey(),
+ privateKey: testKeypair.secret(),
+ accountName: "Test",
+ id: "test-id",
+ subentryCount: 0,
+ } as never,
+ isLoadingAccount: false,
+ accountError: null,
+ fetchActiveAccount: jest.fn().mockResolvedValue(null),
+ refreshActiveAccount: jest.fn(),
+ setNavigationRef: jest.fn(),
+ });
+ });
+
+ it("refuses to sign while soft-locked even though the account is still in the store", () => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.LOCKED,
+ isSoftLocked: true,
+ });
+
+ const { result } = renderHook(() => useGetActiveAccount());
+
+ expect(
+ result.current.signTransaction(buildUnsignedTransaction()),
+ ).toBeNull();
+ expect(result.current.signMessage("hello")).toBeNull();
+ expect(result.current.signAuthEntry("deadbeef")).toBeNull();
+ });
+
+ it("refuses to sign when authStatus is not AUTHENTICATED", () => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.HASH_KEY_EXPIRED,
+ isSoftLocked: false,
+ });
+
+ const { result } = renderHook(() => useGetActiveAccount());
+
+ expect(
+ result.current.signTransaction(buildUnsignedTransaction()),
+ ).toBeNull();
+ });
+
+ it("signs when fully unlocked (AUTHENTICATED and not soft-locked)", () => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.AUTHENTICATED,
+ isSoftLocked: false,
+ });
+
+ const { result } = renderHook(() => useGetActiveAccount());
+
+ const signedXdr = result.current.signTransaction(
+ buildUnsignedTransaction(),
+ );
+ expect(typeof signedXdr).toBe("string");
+ expect(signedXdr).toBeTruthy();
+ });
+});
diff --git a/__tests__/providers/ToastProvider.test.tsx b/__tests__/providers/ToastProvider.test.tsx
new file mode 100644
index 000000000..ba9780475
--- /dev/null
+++ b/__tests__/providers/ToastProvider.test.tsx
@@ -0,0 +1,98 @@
+import { act, render } from "@testing-library/react-native";
+import { UNLOCK_ERROR_TOAST_ID } from "config/constants";
+import { useAuthenticationStore } from "ducks/auth";
+import { ToastProvider, useToast } from "providers/ToastProvider";
+import React, { useEffect } from "react";
+import {
+ Animated,
+ PanResponder,
+ type PanResponderInstance,
+} from "react-native";
+
+const SHOWN_TITLE = "Background validation";
+const ALLOWED_TITLE = "Unlock failed";
+
+const ToastEmitter: React.FC<{
+ title: string;
+ toastId?: string;
+}> = ({ title, toastId }) => {
+ const { showToast } = useToast();
+
+ useEffect(() => {
+ showToast({ variant: "error", title, toastId });
+ }, [showToast, title, toastId]);
+
+ return null;
+};
+
+describe("ToastProvider soft-lock suppression", () => {
+ beforeEach(() => {
+ // The rendered Toast runs Animated loops + a duration-based auto-dismiss
+ // setTimeout; mock them (as Toast.test.tsx does) so no real timers/anim
+ // frames leak and hang the worker on exit.
+ jest.useFakeTimers();
+ const startMock = (callback?: () => void) => {
+ if (callback) {
+ callback();
+ }
+ };
+ jest.spyOn(Animated, "timing").mockReturnValue({
+ start: startMock,
+ } as unknown as Animated.CompositeAnimation);
+ jest.spyOn(Animated, "spring").mockReturnValue({
+ start: startMock,
+ } as unknown as Animated.CompositeAnimation);
+ jest.spyOn(Animated, "parallel").mockReturnValue({
+ start: startMock,
+ } as unknown as Animated.CompositeAnimation);
+ jest
+ .spyOn(PanResponder, "create")
+ .mockReturnValue({ panHandlers: {} } as unknown as PanResponderInstance);
+
+ useAuthenticationStore.setState({ isSoftLocked: false });
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ });
+
+ it("renders toasts normally when not soft-locked", () => {
+ const { queryByText } = render(
+
+
+ ,
+ );
+
+ expect(queryByText(SHOWN_TITLE)).toBeTruthy();
+ });
+
+ it("suppresses background toasts while soft-locked", () => {
+ act(() => {
+ useAuthenticationStore.setState({ isSoftLocked: true });
+ });
+
+ const { queryByText } = render(
+
+
+ ,
+ );
+
+ expect(queryByText(SHOWN_TITLE)).toBeNull();
+ });
+
+ it("still shows the lock overlay's own unlock-error toast while soft-locked", () => {
+ act(() => {
+ useAuthenticationStore.setState({ isSoftLocked: true });
+ });
+
+ const { queryByText } = render(
+
+
+ ,
+ );
+
+ expect(queryByText(ALLOWED_TITLE)).toBeTruthy();
+ });
+});
diff --git a/__tests__/services/autoLock.test.ts b/__tests__/services/autoLock.test.ts
new file mode 100644
index 000000000..c5ec308f8
--- /dev/null
+++ b/__tests__/services/autoLock.test.ts
@@ -0,0 +1,206 @@
+import {
+ AUTO_LOCK_TIMER,
+ DEFAULT_AUTO_LOCK_TIMER,
+ HASH_KEY_EXPIRATION_MS,
+ NEVER_EXPIRE_HASH_KEY_MS,
+ SENSITIVE_STORAGE_KEYS,
+} from "config/constants";
+import {
+ applyAutoLockTimerToHashKey,
+ clearBackgroundedAt,
+ getAutoLockTimer,
+ getBackgroundedAt,
+ getHashKeyExpirationMs,
+ persistAutoLockTimer,
+ recordBackgroundedAt,
+} from "services/autoLock";
+import { getHashKey } from "services/storage/helpers";
+import { secureDataStorage } from "services/storage/storageFactory";
+
+jest.mock("services/storage/storageFactory", () => ({
+ dataStorage: {
+ getItem: jest.fn(),
+ setItem: jest.fn().mockResolvedValue(undefined),
+ remove: jest.fn().mockResolvedValue(undefined),
+ },
+ secureDataStorage: {
+ getItem: jest.fn(),
+ setItem: jest.fn().mockResolvedValue(undefined),
+ remove: jest.fn().mockResolvedValue(undefined),
+ },
+}));
+
+jest.mock("services/storage/helpers", () => ({
+ getHashKey: jest.fn(),
+}));
+
+describe("autoLock service", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("getAutoLockTimer", () => {
+ it("returns the persisted timer when it is a valid option", async () => {
+ (secureDataStorage.getItem as jest.Mock).mockResolvedValue(
+ AUTO_LOCK_TIMER.FIFTEEN_MINUTES,
+ );
+
+ const timer = await getAutoLockTimer();
+
+ expect(secureDataStorage.getItem).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING,
+ );
+ expect(timer).toBe(AUTO_LOCK_TIMER.FIFTEEN_MINUTES);
+ });
+
+ it("falls back to the default when no timer is persisted", async () => {
+ (secureDataStorage.getItem as jest.Mock).mockResolvedValue(null);
+
+ const timer = await getAutoLockTimer();
+
+ expect(timer).toBe(DEFAULT_AUTO_LOCK_TIMER);
+ });
+
+ it("falls back to the default when the persisted value is invalid", async () => {
+ (secureDataStorage.getItem as jest.Mock).mockResolvedValue("not-a-timer");
+
+ const timer = await getAutoLockTimer();
+
+ expect(timer).toBe(DEFAULT_AUTO_LOCK_TIMER);
+ });
+ });
+
+ describe("persistAutoLockTimer", () => {
+ it("writes the timer to the secure-storage mirror", async () => {
+ await persistAutoLockTimer(AUTO_LOCK_TIMER.ONE_HOUR);
+
+ expect(secureDataStorage.setItem).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING,
+ AUTO_LOCK_TIMER.ONE_HOUR,
+ );
+ });
+ });
+
+ describe("getHashKeyExpirationMs", () => {
+ it("returns the default hash key TTL for regular timers", () => {
+ expect(getHashKeyExpirationMs(AUTO_LOCK_TIMER.ONE_MINUTE)).toBe(
+ HASH_KEY_EXPIRATION_MS,
+ );
+ expect(getHashKeyExpirationMs(AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS)).toBe(
+ HASH_KEY_EXPIRATION_MS,
+ );
+ });
+
+ it("returns the never-expire TTL for NONE", () => {
+ expect(getHashKeyExpirationMs(AUTO_LOCK_TIMER.NONE)).toBe(
+ NEVER_EXPIRE_HASH_KEY_MS,
+ );
+ });
+ });
+
+ describe("applyAutoLockTimerToHashKey", () => {
+ it("re-anchors the stored hash key expiration", async () => {
+ const mockHashKey = {
+ hashKey: "mock-hash-key",
+ salt: "mock-salt",
+ expiresAt: Date.now(),
+ };
+ (getHashKey as jest.Mock).mockResolvedValue(mockHashKey);
+
+ const before = Date.now();
+ await applyAutoLockTimerToHashKey(AUTO_LOCK_TIMER.NONE);
+
+ expect(secureDataStorage.setItem).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.HASH_KEY,
+ expect.any(String),
+ );
+
+ const [, storedValue] = (secureDataStorage.setItem as jest.Mock).mock
+ .calls[0];
+ const storedHashKey = JSON.parse(storedValue);
+ expect(storedHashKey.hashKey).toBe(mockHashKey.hashKey);
+ expect(storedHashKey.salt).toBe(mockHashKey.salt);
+ expect(storedHashKey.expiresAt).toBeGreaterThanOrEqual(
+ before + NEVER_EXPIRE_HASH_KEY_MS,
+ );
+ });
+
+ it("is a no-op when no hash key is stored", async () => {
+ (getHashKey as jest.Mock).mockResolvedValue(null);
+
+ await applyAutoLockTimerToHashKey(AUTO_LOCK_TIMER.ONE_HOUR);
+
+ expect(secureDataStorage.setItem).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("backgrounded-at timestamp", () => {
+ it("records the current time when the app backgrounds", async () => {
+ const before = Date.now();
+ await recordBackgroundedAt();
+
+ expect(secureDataStorage.setItem).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
+ expect.any(String),
+ );
+
+ const [, storedValue] = (secureDataStorage.setItem as jest.Mock).mock
+ .calls[0];
+ expect(Number(storedValue)).toBeGreaterThanOrEqual(before);
+ });
+
+ it("returns the persisted timestamp as a number", async () => {
+ (secureDataStorage.getItem as jest.Mock).mockResolvedValue("1234567890");
+
+ const backgroundedAt = await getBackgroundedAt();
+
+ expect(secureDataStorage.getItem).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
+ );
+ expect(backgroundedAt).toBe(1234567890);
+ });
+
+ it("returns null when no timestamp is persisted", async () => {
+ (secureDataStorage.getItem as jest.Mock).mockResolvedValue(null);
+
+ const backgroundedAt = await getBackgroundedAt();
+
+ expect(backgroundedAt).toBeNull();
+ });
+
+ it("cleans up and returns null for a corrupt timestamp", async () => {
+ (secureDataStorage.getItem as jest.Mock).mockResolvedValue(
+ "not-a-number",
+ );
+
+ const backgroundedAt = await getBackgroundedAt();
+
+ expect(backgroundedAt).toBeNull();
+ expect(secureDataStorage.remove).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
+ );
+ });
+
+ it("forces a lock for a future-dated timestamp (clock anomaly)", async () => {
+ // A future-dated timestamp means the device clock moved backward; rather
+ // than skip the lock, treat it as an epoch-old background so any positive
+ // timer elapses and the wallet locks conservatively.
+ const oneHourAhead = Date.now() + 3600000;
+ (secureDataStorage.getItem as jest.Mock).mockResolvedValue(
+ String(oneHourAhead),
+ );
+
+ const backgroundedAt = await getBackgroundedAt();
+
+ expect(backgroundedAt).toBe(0);
+ });
+
+ it("clears the persisted timestamp", async () => {
+ await clearBackgroundedAt();
+
+ expect(secureDataStorage.remove).toHaveBeenCalledWith(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
+ );
+ });
+ });
+});
diff --git a/android/app/src/main/java/com/freightermobile/MainActivity.kt b/android/app/src/main/java/com/freightermobile/MainActivity.kt
index 9f660439e..c7e0e57b4 100644
--- a/android/app/src/main/java/com/freightermobile/MainActivity.kt
+++ b/android/app/src/main/java/com/freightermobile/MainActivity.kt
@@ -2,6 +2,15 @@ package org.stellar.freighterwallet
// this import is needed for the onCreate override function
import android.os.Bundle;
+import android.os.Handler
+import android.os.Looper
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.widget.FrameLayout
+import android.widget.ImageView
+import androidx.core.content.ContextCompat
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
@@ -11,6 +20,24 @@ import com.zoontek.rnbootsplash.RNBootSplash
class MainActivity : ReactActivity() {
+ // Privacy shield: a BootSplash-styled overlay added when the app is
+ // backgrounded (onStop, so it does NOT trigger for the biometric prompt /
+ // transient pauses) and kept up after return until JS finishes the
+ // auto-lock decision and calls PrivacyShield.hide() — so a soft-lock
+ // overlay can mount before the wallet is revealed. A fallback removes it if
+ // JS never calls. (FLAG_SECURE already blanks the recents thumbnail; this
+ // covers the brief on-return flash.)
+ private var privacyOverlay: View? = null
+ private val privacyHandler = Handler(Looper.getMainLooper())
+ // Tracks whether the activity is currently resumed so a JS-triggered hide()
+ // that arrives after a re-background doesn't tear down a freshly-raised
+ // shield.
+ private var isActivityResumed = false
+
+ companion object {
+ private const val PRIVACY_SHIELD_FALLBACK_MS = 1000L
+ }
+
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
@@ -32,6 +59,67 @@ class MainActivity : ReactActivity() {
*/
override fun onCreate(savedInstanceState: Bundle?) {
RNBootSplash.init(this, R.style.BootTheme)
+ // Blank wallet content in the recents/app-switcher thumbnail (also blocks
+ // screenshots) when backgrounded
+ window.setFlags(
+ WindowManager.LayoutParams.FLAG_SECURE,
+ WindowManager.LayoutParams.FLAG_SECURE,
+ )
super.onCreate(null)
}
+
+ override fun onPause() {
+ isActivityResumed = false
+ super.onPause()
+ }
+
+ override fun onStop() {
+ showPrivacyShield()
+ super.onStop()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ isActivityResumed = true
+ // Fallback: ensure the shield can't get stuck if JS never calls hide()
+ privacyHandler.removeCallbacksAndMessages(null)
+ privacyHandler.postDelayed({ hidePrivacyShield() }, PRIVACY_SHIELD_FALLBACK_MS)
+ }
+
+ fun showPrivacyShield() {
+ if (privacyOverlay != null) return
+ val decor = window.decorView as? ViewGroup ?: return
+
+ val overlay = FrameLayout(this).apply {
+ setBackgroundColor(
+ ContextCompat.getColor(this@MainActivity, R.color.bootsplash_background),
+ )
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ }
+ val logo = ImageView(this).apply {
+ setImageResource(R.drawable.bootsplash_logo)
+ layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ Gravity.CENTER,
+ )
+ }
+ overlay.addView(logo)
+ decor.addView(overlay)
+ privacyOverlay = overlay
+ }
+
+ fun hidePrivacyShield() {
+ // Only lift while resumed: a JS hide() arriving after a re-background
+ // (onStop re-raised the shield) would briefly expose wallet content. The
+ // next onResume reschedules the fallback.
+ if (!isActivityResumed) return
+ privacyHandler.removeCallbacksAndMessages(null)
+ val overlay = privacyOverlay ?: return
+ (overlay.parent as? ViewGroup)?.removeView(overlay)
+ privacyOverlay = null
+ }
}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/freightermobile/MainApplication.kt b/android/app/src/main/java/com/freightermobile/MainApplication.kt
index 91ff763ed..e19e975b9 100644
--- a/android/app/src/main/java/com/freightermobile/MainApplication.kt
+++ b/android/app/src/main/java/com/freightermobile/MainApplication.kt
@@ -22,6 +22,7 @@ class MainApplication : Application(), ReactApplication {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(org.stellar.freighterwallet.SecureClipboardPackage())
+ add(org.stellar.freighterwallet.PrivacyShieldPackage())
}
override fun getJSMainModuleName(): String = "index"
diff --git a/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldModule.kt b/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldModule.kt
new file mode 100644
index 000000000..bd3fb9b40
--- /dev/null
+++ b/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldModule.kt
@@ -0,0 +1,26 @@
+package org.stellar.freighterwallet
+
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReactMethod
+
+/**
+ * Lets JS dismiss the Android privacy shield (managed by MainActivity) once
+ * the auto-lock decision has finished, so a soft-lock overlay can mount before
+ * the wallet is revealed on return from the background.
+ */
+class PrivacyShieldModule(private val reactContext: ReactApplicationContext) :
+ ReactContextBaseJavaModule(reactContext) {
+
+ override fun getName(): String = "PrivacyShield"
+
+ @ReactMethod
+ fun hide(promise: Promise) {
+ val activity = reactContext.currentActivity
+ if (activity is MainActivity) {
+ activity.runOnUiThread { activity.hidePrivacyShield() }
+ }
+ promise.resolve(null)
+ }
+}
diff --git a/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldPackage.kt b/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldPackage.kt
new file mode 100644
index 000000000..86d45d4e3
--- /dev/null
+++ b/android/app/src/main/java/org/stellar/freighterwallet/PrivacyShieldPackage.kt
@@ -0,0 +1,16 @@
+package org.stellar.freighterwallet
+
+import com.facebook.react.ReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.uimanager.ViewManager
+
+class PrivacyShieldPackage : ReactPackage {
+ override fun createNativeModules(
+ reactContext: ReactApplicationContext,
+ ): List = listOf(PrivacyShieldModule(reactContext))
+
+ override fun createViewManagers(
+ reactContext: ReactApplicationContext,
+ ): List> = emptyList()
+}
diff --git a/ios/Podfile b/ios/Podfile
index 06b6df974..cc908362b 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -54,6 +54,10 @@ target 'freighter-mobile' do
# SecureClipboard is now part of the main project
pod 'SecureClipboard', :path => './'
+ # PrivacyShield native module (local pod) — JS dismisses the native privacy
+ # shield once the auto-lock decision has finished
+ pod 'PrivacyShield', :path => './'
+
use_react_native!(
:path => config[:reactNativePath],
# An absolute path to your application root.
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index a28d80c06..34f2a8b6d 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -66,6 +66,8 @@ PODS:
- SocketRocket
- Yoga
- OpenSSL-Universal (3.6.2000)
+ - PrivacyShield (1.0.0):
+ - React-Core
- QuickCrypto (1.1.5):
- boost
- DoubleConversion
@@ -3446,6 +3448,7 @@ DEPENDENCIES:
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- jail-monkey (from `../node_modules/jail-monkey`)
- NitroModules (from `../node_modules/react-native-nitro-modules`)
+ - PrivacyShield (from `./`)
- QuickCrypto (from `../node_modules/react-native-quick-crypto`)
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
@@ -3591,6 +3594,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/jail-monkey"
NitroModules:
:path: "../node_modules/react-native-nitro-modules"
+ PrivacyShield:
+ :path: "./"
QuickCrypto:
:path: "../node_modules/react-native-quick-crypto"
RCT-Folly:
@@ -3815,6 +3820,7 @@ SPEC CHECKSUMS:
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
NitroModules: 1ef0796714251dbdaea49af187e291d8b6a7c5ea
OpenSSL-Universal: ecee7b138fa75a74ecf00d7ffd248fb584739b9e
+ PrivacyShield: df1c5e513b672f8725b5a4637726ee75ad5611e4
QuickCrypto: fbb50daf9313753153af0e4f67a9f14ec1a26979
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
RCTDeprecation: c0ed3249a97243002615517dff789bf4666cf585
@@ -3924,6 +3930,6 @@ SPEC CHECKSUMS:
VisionCamera: 891edb31806dd3a239c8a9d6090d6ec78e11ee80
Yoga: a3ed390a19db0459bd6839823a6ac6d9c6db198d
-PODFILE CHECKSUM: aa3474b4795f465757c119bd3e4b6cd20a1526f0
+PODFILE CHECKSUM: a88f7c96ae72d2da0c705ccdb51d43ec1391b5a7
COCOAPODS: 1.15.2
diff --git a/ios/PrivacyShield.m b/ios/PrivacyShield.m
new file mode 100644
index 000000000..c207fbd1b
--- /dev/null
+++ b/ios/PrivacyShield.m
@@ -0,0 +1,8 @@
+#import
+
+@interface RCT_EXTERN_MODULE(PrivacyShield, NSObject)
+
+RCT_EXTERN_METHOD(hide:(RCTPromiseResolveBlock)resolve
+ rejecter:(RCTPromiseRejectBlock)reject)
+
+@end
diff --git a/ios/PrivacyShield.podspec b/ios/PrivacyShield.podspec
new file mode 100644
index 000000000..ec3165fef
--- /dev/null
+++ b/ios/PrivacyShield.podspec
@@ -0,0 +1,13 @@
+Pod::Spec.new do |s|
+ s.name = "PrivacyShield"
+ s.version = "1.0.0"
+ s.summary = "Privacy shield control for React Native"
+ s.description = "A React Native module that lets JS dismiss the native privacy shield once the auto-lock decision has finished"
+ s.homepage = "https://github.com/stellar/freighter-mobile"
+ s.license = "MIT"
+ s.author = { "Stellar Development Foundation" => "hello@stellar.org" }
+ s.platforms = { :ios => "11.0" }
+ s.source = { :git => "https://github.com/stellar/freighter-mobile.git" }
+ s.source_files = "PrivacyShield.{swift,m}"
+ s.dependency "React-Core"
+end
diff --git a/ios/PrivacyShield.swift b/ios/PrivacyShield.swift
new file mode 100644
index 000000000..500b1b275
--- /dev/null
+++ b/ios/PrivacyShield.swift
@@ -0,0 +1,35 @@
+import Foundation
+import React
+
+// PrivacyShield native module — lets JS dismiss the iOS privacy shield once
+// the auto-lock decision has finished, so a soft-lock overlay can mount
+// before the wallet is revealed on return from the background.
+//
+// Packaged as a local pod (separate compilation module), so it can't
+// reference the app target's AppDelegate directly. It decouples via
+// NotificationCenter: AppDelegate owns the shield window (it's alive from
+// launch and shows the shield reliably on every background) and observes this
+// notification to dismiss it.
+@objc(PrivacyShield)
+class PrivacyShield: NSObject {
+
+ // Must match the name AppDelegate observes.
+ static let hideNotificationName = Notification.Name("FreighterPrivacyShieldHide")
+
+ @objc
+ static func requiresMainQueueSetup() -> Bool {
+ return false
+ }
+
+ @objc
+ func hide(
+ _ resolver: @escaping RCTPromiseResolveBlock,
+ rejecter: @escaping RCTPromiseRejectBlock
+ ) {
+ NotificationCenter.default.post(
+ name: PrivacyShield.hideNotificationName,
+ object: nil
+ )
+ resolver(nil)
+ }
+}
diff --git a/ios/freighter-mobile/AppDelegate.swift b/ios/freighter-mobile/AppDelegate.swift
index 4462086bf..7ac657938 100644
--- a/ios/freighter-mobile/AppDelegate.swift
+++ b/ios/freighter-mobile/AppDelegate.swift
@@ -30,12 +30,85 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
launchOptions: launchOptions
)
+ // The PrivacyShield native module (local pod) can't reference this class
+ // directly, so it requests dismissal via this notification.
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(handlePrivacyShieldHideRequest),
+ name: Notification.Name("FreighterPrivacyShieldHide"),
+ object: nil
+ )
+
return true
}
+
+ @objc private func handlePrivacyShieldHideRequest() {
+ // hidePrivacyShield no-ops unless the app is active, so a hide() that
+ // lands during a background bounce won't expose the wallet.
+ DispatchQueue.main.async { [weak self] in
+ self?.hidePrivacyShield()
+ }
+ }
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
return RCTLinkingManager.application(app, open: url, options: options)
}
+
+ // Privacy shield: cover wallet content with the BootSplash when the app is
+ // backgrounded, so the OS app-switcher snapshot never reveals it. Uses
+ // didEnterBackground (not willResignActive) so it does NOT trigger for the
+ // Face ID prompt / control center, which only make the app inactive — the
+ // snapshot is still taken after didEnterBackground, so it's in time.
+ //
+ // The shield stays up after the app becomes active until JS finishes the
+ // auto-lock decision and calls PrivacyShield.hide() (the PrivacyShield pod
+ // posts the FreighterPrivacyShieldHide notification observed above), so a
+ // soft-lock overlay can mount before the wallet is revealed (no flash of
+ // the unlocked screen). The fallback timer guarantees it can never get
+ // stuck if JS doesn't run, and degrades to a bounded splash if the
+ // PrivacyShield pod isn't installed (run `pod install`).
+ private var privacyWindow: UIWindow?
+ private var privacyShieldFallbackTimer: Timer?
+ private static let privacyShieldFallbackTimeout: TimeInterval = 1.0
+
+ func applicationDidEnterBackground(_ application: UIApplication) {
+ // Cancel any fallback timer pending from a previous resume so it can't fire
+ // during this new background period and tear down the shield we just want
+ // to keep up.
+ privacyShieldFallbackTimer?.invalidate()
+ privacyShieldFallbackTimer = nil
+
+ guard privacyWindow == nil else { return }
+ let overlay = UIWindow(frame: UIScreen.main.bounds)
+ overlay.windowLevel = .alert + 1
+ overlay.rootViewController = UIStoryboard(name: "BootSplash", bundle: nil)
+ .instantiateInitialViewController()
+ overlay.isHidden = false
+ privacyWindow = overlay
+ }
+
+ func applicationDidBecomeActive(_ application: UIApplication) {
+ guard privacyWindow != nil else { return }
+ privacyShieldFallbackTimer?.invalidate()
+ privacyShieldFallbackTimer = Timer.scheduledTimer(
+ withTimeInterval: Self.privacyShieldFallbackTimeout,
+ repeats: false
+ ) { [weak self] _ in
+ self?.hidePrivacyShield()
+ }
+ }
+
+ // Called from the PrivacyShield native module once JS has settled the lock
+ // decision (and any lock overlay has mounted). Must run on the main thread.
+ func hidePrivacyShield() {
+ // Only reveal while the app is actually active: a JS hide() or fallback
+ // timer that lands during a background bounce must not expose the wallet.
+ guard UIApplication.shared.applicationState == .active else { return }
+ privacyShieldFallbackTimer?.invalidate()
+ privacyShieldFallbackTimer = nil
+ privacyWindow?.isHidden = true
+ privacyWindow = nil
+ }
}
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
diff --git a/jest.config.js b/jest.config.js
index 714244a37..526325f77 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -27,6 +27,7 @@ module.exports = {
"react-native-reanimated",
"@gorhom/bottom-sheet",
"react-native-worklets",
+ "react-native-keyboard-controller",
"react-native-qrcode-svg",
"stellar-hd-wallet",
// v16 ships ESM-only deps (@noble/*, smol-toml, uint8array-extras,
diff --git a/jest.setup.js b/jest.setup.js
index b2b7a80c4..59d009ce7 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -475,8 +475,15 @@ jest.mock("hooks/useGetActiveAccount", () => ({
refreshAccount: jest.fn(),
signTransaction: jest.fn(),
})),
+ // Named guard used at direct signing sites; default to "unlocked" in tests
+ isWalletUnlocked: jest.fn(() => true),
}));
+// react-native-keyboard-controller ships a jest mock for its native bindings
+jest.mock("react-native-keyboard-controller", () =>
+ require("react-native-keyboard-controller/jest"),
+);
+
jest.mock("hooks/useBalancesList", () => ({
useBalancesList: jest.fn(() => ({
balanceItems: [],
diff --git a/src/components/App.tsx b/src/components/App.tsx
index 823e9fed4..8c0f1631b 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -5,6 +5,7 @@ import {
} from "@react-navigation/native";
import * as Sentry from "@sentry/react-native";
import { AuthErrorToastListener } from "components/AuthErrorToastListener";
+import { LockScreenOverlay } from "components/LockScreenOverlay";
import { initializeSentryLogger } from "config/logger";
import { NAVIGATION_THEME } from "config/navigationTheme";
import { RootStackParamList } from "config/routes";
@@ -88,6 +89,12 @@ export const App = (): React.JSX.Element => {
+ {/* Soft-lock overlay: rendered after the bottom sheet provider so
+ it covers open sheets, keeping the navigation tree mounted
+ underneath for after the unlock. */}
+
+
+
diff --git a/src/components/LockScreenOverlay.tsx b/src/components/LockScreenOverlay.tsx
new file mode 100644
index 000000000..c742bab8a
--- /dev/null
+++ b/src/components/LockScreenOverlay.tsx
@@ -0,0 +1,56 @@
+import { LockScreenContent } from "components/screens/LockScreen";
+import { THEME } from "config/theme";
+import { useAuthenticationStore } from "ducks/auth";
+import React, { useEffect } from "react";
+import { BackHandler, StyleSheet, View } from "react-native";
+
+const styles = StyleSheet.create({
+ container: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: THEME.colors.background.default,
+ },
+});
+
+/**
+ * Full-screen overlay for the in-process soft lock (auto-lock timer or the
+ * IMMEDIATELY option). Rendered after the navigation + bottom-sheet providers
+ * so it covers them while the screens underneath keep their state for after
+ * the unlock; unlocking clears isSoftLocked and unmounts it. Swallows the
+ * Android back button and hides the tree from accessibility. Cold starts use
+ * the LockScreen route instead.
+ */
+export const LockScreenOverlay: React.FC = () => {
+ // Narrow selector: avoid re-rendering on unrelated auth-store churn
+ const isSoftLocked = useAuthenticationStore((state) => state.isSoftLocked);
+
+ // Swallow the Android back button while locked so it can't pop the hidden
+ // tree or exit the app
+ useEffect(() => {
+ if (!isSoftLocked) {
+ return undefined;
+ }
+
+ const subscription = BackHandler.addEventListener(
+ "hardwareBackPress",
+ () => true,
+ );
+
+ return () => subscription.remove();
+ }, [isSoftLocked]);
+
+ if (!isSoftLocked) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default LockScreenOverlay;
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
index 13917eb53..45389f80e 100644
--- a/src/components/Modal.tsx
+++ b/src/components/Modal.tsx
@@ -1,4 +1,5 @@
-import React from "react";
+import { useAuthenticationStore } from "ducks/auth";
+import React, { useEffect } from "react";
import {
type StyleProp,
View,
@@ -28,41 +29,54 @@ const Modal: React.FC = ({
contentClassName,
contentStyle,
testID,
-}) => (
- {
+}) => {
+ // Dismiss on soft lock: a native RN Modal renders above the in-tree lock
+ // overlay, so an open one would sit on top of the lock screen. Gated on
+ // isSoftLocked (not a raw background event) so a brief glance keeps state.
+ const isSoftLocked = useAuthenticationStore((state) => state.isSoftLocked);
+ useEffect(() => {
+ if (visible && isSoftLocked) {
onClose();
- }}
- >
-
- {
- if (closeOnOverlayPress) {
- onClose();
- }
- }}
- >
-
-
+ }
+ }, [visible, isSoftLocked, onClose]);
-
- {
+ onClose();
+ }}
+ >
+
+ {
+ if (closeOnOverlayPress) {
+ onClose();
+ }
+ }}
>
- {children}
+
+
+
+
+
+ {children}
+
-
-
-
-);
+
+
+ );
+};
export default Modal;
diff --git a/src/components/screens/LockScreen.tsx b/src/components/screens/LockScreen.tsx
index ba7433fa8..a78632e55 100644
--- a/src/components/screens/LockScreen.tsx
+++ b/src/components/screens/LockScreen.tsx
@@ -1,18 +1,50 @@
import { NativeStackScreenProps } from "@react-navigation/native-stack";
import ForgotPasswordWarningModal from "components/screens/ForgotPasswordWarningModal";
import InputPasswordTemplate from "components/templates/InputPasswordTemplate";
+import {
+ ERROR_TOAST_DURATION,
+ LoginType,
+ UNLOCK_ERROR_TOAST_ID,
+} from "config/constants";
import { ROOT_NAVIGATOR_ROUTES, RootStackParamList } from "config/routes";
import { AUTH_STATUS } from "config/types";
import { useAuthenticationStore, getActiveAccountPublicKey } from "ducks/auth";
+import { usePreferencesStore } from "ducks/preferences";
+import {
+ isPrivacyShieldVisible,
+ onPrivacyShieldHidden,
+} from "helpers/privacyShield";
import useAppTranslation from "hooks/useAppTranslation";
+import { useToast } from "providers/ToastProvider";
import React, { useCallback, useEffect, useRef, useState } from "react";
+import { AppState } from "react-native";
type LockScreenProps = NativeStackScreenProps<
RootStackParamList,
typeof ROOT_NAVIGATOR_ROUTES.LOCK_SCREEN
>;
-export const LockScreen: React.FC = ({ navigation }) => {
+// Small delay to ensure state is settled before navigating after unlock
+const UNLOCK_NAVIGATION_DELAY_MS = 100;
+
+interface LockScreenContentProps {
+ /**
+ * Called once the wallet is unlocked. The lock screen route uses this to
+ * replace itself with the main tab stack; the soft-lock overlay omits it
+ * because it simply unmounts when the unlock flips the auth status.
+ */
+ onUnlocked?: () => void;
+}
+
+/**
+ * Lock UI shared by the LockScreen route (hard lock / cold start) and the
+ * soft-lock overlay (in-process auto-lock with the navigation tree preserved
+ * underneath). Handles password unlock, the biometric auto-prompt, and the
+ * forgot-password flow.
+ */
+export const LockScreenContent: React.FC = ({
+ onUnlocked,
+}) => {
const {
signIn,
isLoading: isSigningIn,
@@ -20,8 +52,12 @@ export const LockScreen: React.FC = ({ navigation }) => {
authStatus,
logout,
clearError,
+ signInMethod,
+ verifyActionWithBiometrics,
} = useAuthenticationStore();
+ const { isBiometricsEnabled } = usePreferencesStore();
const { t } = useAppTranslation();
+ const { showToast } = useToast();
const [publicKey, setPublicKey] = useState(null);
const [isForgotPasswordModalVisible, setIsForgotPasswordModalVisible] =
useState(false);
@@ -37,20 +73,32 @@ export const LockScreen: React.FC = ({ navigation }) => {
// for invalid-password, or app-wide by AuthErrorToastListener).
const hasInitialError = useRef(Boolean(error));
+ // Auto-prompt biometrics at most once per arrival on this screen; reset when
+ // the app returns from the background so the user is prompted again.
+ const hasAutoPromptedRef = useRef(false);
+ // Whether the app went to the real background while this screen was mounted
+ // (or was mounted while backgrounded, e.g. an "Immediately" auto-lock).
+ // "inactive" is intentionally ignored: on iOS the biometric overlay itself
+ // triggers inactive→active transitions, which would re-prompt on cancel.
+ const wasBackgroundedRef = useRef(AppState.currentState !== "active");
+ // True when a prompt was requested while the privacy shield was still up; it
+ // fires once the shield drops so the lock screen is visible behind Face ID.
+ const pendingPromptRef = useRef(false);
+
// Monitor auth status changes to navigate when unlocked
useEffect(() => {
- if (authStatus === AUTH_STATUS.AUTHENTICATED) {
+ if (authStatus === AUTH_STATUS.AUTHENTICATED && onUnlocked) {
// Add a small delay to ensure state is settled before navigation
const navigationTimeout = setTimeout(() => {
- navigation.replace(ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK);
- }, 100);
+ onUnlocked();
+ }, UNLOCK_NAVIGATION_DELAY_MS);
return () => {
clearTimeout(navigationTimeout);
};
}
return undefined;
- }, [authStatus, navigation]);
+ }, [authStatus, onUnlocked]);
useEffect(() => {
const fetchActiveAccountPublicKey = async () => {
@@ -71,17 +119,138 @@ export const LockScreen: React.FC = ({ navigation }) => {
}, [clearError]);
const handleUnlock = useCallback(
- (password: string) => {
+ (password: string): Promise | undefined => {
// Disable other navigation attempts while signing in
- if (isSigningIn) return;
+ if (isSigningIn) return undefined;
- // Try to sign in - error handling is in the auth store
- signIn({ password });
- // Navigation will happen automatically through the authStatus effect
+ // Try to sign in - error handling is in the auth store. The promise is
+ // returned so the biometric auto-unlock can await/catch a rejection
+ // (e.g. a stale stored password) instead of leaving it unhandled.
+ // Navigation happens automatically through the authStatus effect.
+ return signIn({ password });
},
[signIn, isSigningIn],
);
+ /**
+ * Prompts for biometrics and unlocks the wallet with the stored password,
+ * so users with biometrics enabled don't need to tap the unlock button.
+ */
+ const attemptBiometricUnlock = useCallback(() => {
+ if (isSigningIn || isForgotPasswordModalVisible) return;
+ // The guards below must run before the auto-prompt flag is set: the
+ // sign-in method resolves asynchronously after mount (PASSWORD until
+ // biometrics availability is checked), and we still want to prompt then.
+ if (!isBiometricsEnabled || signInMethod === LoginType.PASSWORD) return;
+ if (hasAutoPromptedRef.current) return;
+
+ hasAutoPromptedRef.current = true;
+ let didAttemptUnlock = false;
+ verifyActionWithBiometrics((password?: string) => {
+ if (password) {
+ didAttemptUnlock = true;
+ // Return the sign-in promise so a rejection is handled by the
+ // .catch() below rather than surfacing as an unhandled rejection.
+ return handleUnlock(password) ?? Promise.resolve();
+ }
+ return Promise.resolve();
+ }).catch(() => {
+ if (!didAttemptUnlock) {
+ // Biometric prompt cancelled/failed before any unlock attempt — the
+ // user can still unlock manually; we re-prompt on the next foreground.
+ return;
+ }
+ // Unlock failed after a successful biometric scan (e.g. a stale stored
+ // password). Clear the store error so a biometric-derived invalidPassword
+ // doesn't bleed into the inline password field, and surface the unlock-
+ // error toast. Uses UNLOCK_ERROR_TOAST_ID because it's the only id
+ // SOFT_LOCK_ALLOWED_TOAST_IDS lets through while the soft-lock overlay is
+ // up — otherwise the failure would be silently suppressed.
+ clearError();
+ showToast({
+ toastId: UNLOCK_ERROR_TOAST_ID,
+ variant: "error",
+ title: t("lockScreen.errorUnlockingWalletTitle"),
+ message: t("lockScreen.errorUnlockingWalletMessage"),
+ duration: ERROR_TOAST_DURATION,
+ });
+ });
+ }, [
+ isSigningIn,
+ isForgotPasswordModalVisible,
+ isBiometricsEnabled,
+ signInMethod,
+ verifyActionWithBiometrics,
+ handleUnlock,
+ clearError,
+ showToast,
+ t,
+ ]);
+
+ /**
+ * Requests the biometric prompt, but holds it while the native privacy
+ * shield is still covering the app (return from background) so Face ID
+ * appears over the visible lock screen rather than the shield. When the
+ * shield is down it prompts immediately.
+ */
+ const requestBiometricPrompt = useCallback(() => {
+ if (isPrivacyShieldVisible()) {
+ pendingPromptRef.current = true;
+ return;
+ }
+ attemptBiometricUnlock();
+ }, [attemptBiometricUnlock]);
+
+ // Fire a held prompt once the privacy shield drops.
+ useEffect(() => {
+ const unsubscribe = onPrivacyShieldHidden(() => {
+ if (pendingPromptRef.current) {
+ pendingPromptRef.current = false;
+ attemptBiometricUnlock();
+ }
+ });
+
+ return unsubscribe;
+ }, [attemptBiometricUnlock]);
+
+ // Auto-prompt biometrics when landing on this screen with the app active
+ // (cold start or a lock that happened while backgrounded). Skipped when the
+ // user was actively present — a foreground-idle timeout or a manual
+ // lock/logout — since popping an unprompted Face ID is jarring there; they
+ // can tap to unlock, and the return-from-background effect below re-prompts
+ // on the next foreground.
+ useEffect(() => {
+ if (
+ AppState.currentState === "active" &&
+ !useAuthenticationStore.getState().suppressBiometricAutoPrompt
+ ) {
+ requestBiometricPrompt();
+ }
+ }, [requestBiometricPrompt]);
+
+ // Re-prompt biometrics when the app returns from the background while this
+ // screen is showing — like banking apps do
+ useEffect(() => {
+ const subscription = AppState.addEventListener("change", (nextAppState) => {
+ if (nextAppState === "background") {
+ wasBackgroundedRef.current = true;
+ return;
+ }
+
+ if (nextAppState === "active" && wasBackgroundedRef.current) {
+ // Re-prompting on EVERY return from the background is intentional
+ // (banking-app behavior): the user landing on a locked wallet wants
+ // to get in, and a cancelled prompt stays cancelled until they leave.
+ // Held until the privacy shield drops so the lock screen is visible.
+ wasBackgroundedRef.current = false;
+ hasAutoPromptedRef.current = false;
+ requestBiometricPrompt();
+ }
+ });
+
+ return () => subscription.remove();
+ }, [requestBiometricPrompt]);
+
const handleForgotPassword = useCallback(() => {
setIsForgotPasswordModalVisible(true);
}, []);
@@ -115,3 +284,11 @@ export const LockScreen: React.FC = ({ navigation }) => {
>
);
};
+
+export const LockScreen: React.FC = ({ navigation }) => {
+ const handleUnlocked = useCallback(() => {
+ navigation.replace(ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK);
+ }, [navigation]);
+
+ return ;
+};
diff --git a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx
index 4f2fe2817..18205d150 100644
--- a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx
+++ b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx
@@ -51,7 +51,9 @@ import { isMuxedAccount } from "helpers/stellar";
import { useBlockaidTransaction } from "hooks/blockaid/useBlockaidTransaction";
import useAppTranslation from "hooks/useAppTranslation";
import useColors from "hooks/useColors";
-import useGetActiveAccount from "hooks/useGetActiveAccount";
+import useGetActiveAccount, {
+ isWalletUnlocked,
+} from "hooks/useGetActiveAccount";
import { useInitialRecommendedFee } from "hooks/useInitialRecommendedFee";
import { useNetworkFees } from "hooks/useNetworkFees";
import { useRightHeaderButton } from "hooks/useRightHeader";
@@ -361,6 +363,13 @@ const SendCollectibleReviewScreen: React.FC<
throw new Error("Missing account or collectible information");
}
+ // Refuse to sign if the wallet locked between opening the review sheet
+ // and confirming (e.g. an IMMEDIATELY auto-lock fired) — every other
+ // signing path enforces the same guard.
+ if (!isWalletUnlocked()) {
+ throw new Error("Wallet is locked");
+ }
+
const { privateKey } = account;
signTransaction({
diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx
index d498557a6..169985ec2 100644
--- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx
+++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx
@@ -65,7 +65,9 @@ import { useBlockaidTransaction } from "hooks/blockaid/useBlockaidTransaction";
import useAppTranslation from "hooks/useAppTranslation";
import { useBalancesList } from "hooks/useBalancesList";
import useColors from "hooks/useColors";
-import useGetActiveAccount from "hooks/useGetActiveAccount";
+import useGetActiveAccount, {
+ isWalletUnlocked,
+} from "hooks/useGetActiveAccount";
import { useInitialRecommendedFee } from "hooks/useInitialRecommendedFee";
import { useNetworkFees } from "hooks/useNetworkFees";
import { useRightHeaderButton } from "hooks/useRightHeader";
@@ -317,7 +319,7 @@ const TransactionAmountScreen: React.FC = ({
});
};
- const { balanceItems } = useBalancesList({
+ const { balanceItems, isLoading: isLoadingBalances } = useBalancesList({
publicKey: publicKey ?? "",
network,
});
@@ -507,6 +509,19 @@ const TransactionAmountScreen: React.FC = ({
};
useEffect(() => {
+ // Skip until balances AND the active account are loaded. After the app
+ // returns from background, balances refetch and the account reloads
+ // (briefly null during signIn); spendableBalance is 0 without them, which
+ // would flash a false "amount too high" / "insufficient XLM" error + toast.
+ if (
+ isLoadingBalances ||
+ balanceItems.length === 0 ||
+ !account ||
+ !selectedBalance
+ ) {
+ return;
+ }
+
const currentTokenAmount = BigNumber(tokenAmount);
if (!hasXLMForFees(balanceItems, transactionFee)) {
@@ -559,6 +574,8 @@ const TransactionAmountScreen: React.FC = ({
tokenAmount,
spendableBalance,
balanceItems,
+ isLoadingBalances,
+ account,
transactionFee,
transactionHash,
isCustomToken,
@@ -733,6 +750,11 @@ const TransactionAmountScreen: React.FC = ({
throw new Error("Missing account or balance information");
}
+ // Block signing if an auto-lock engaged while on the review sheet
+ if (!isWalletUnlocked()) {
+ throw new Error("Wallet is locked");
+ }
+
const { privateKey } = account;
signTransaction({
diff --git a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx
new file mode 100644
index 000000000..7531d6840
--- /dev/null
+++ b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/AutoLockTimerScreen.tsx
@@ -0,0 +1,72 @@
+import { NativeStackScreenProps } from "@react-navigation/native-stack";
+import { List } from "components/List";
+import { BaseLayout } from "components/layout/BaseLayout";
+import Icon from "components/sds/Icon";
+import { Text } from "components/sds/Typography";
+import { AUTO_LOCK_TIMER } from "config/constants";
+import { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes";
+import { usePreferencesStore } from "ducks/preferences";
+import useAppTranslation from "hooks/useAppTranslation";
+import useColors from "hooks/useColors";
+import React from "react";
+import { View } from "react-native";
+
+interface AutoLockTimerScreenProps
+ extends NativeStackScreenProps<
+ SettingsStackParamList,
+ typeof SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN
+ > {}
+
+const AutoLockTimerScreen: React.FC = () => {
+ const { t } = useAppTranslation();
+ const { themeColors } = useColors();
+ const { autoLockTimer, setAutoLockTimer } = usePreferencesStore();
+
+ const timerLabels: Record = {
+ [AUTO_LOCK_TIMER.IMMEDIATELY]: t("autoLockTimerScreen.options.immediately"),
+ [AUTO_LOCK_TIMER.ONE_MINUTE]: t("autoLockTimerScreen.options.oneMinute"),
+ [AUTO_LOCK_TIMER.FIFTEEN_MINUTES]: t(
+ "autoLockTimerScreen.options.fifteenMinutes",
+ ),
+ [AUTO_LOCK_TIMER.THIRTY_MINUTES]: t(
+ "autoLockTimerScreen.options.thirtyMinutes",
+ ),
+ [AUTO_LOCK_TIMER.ONE_HOUR]: t("autoLockTimerScreen.options.oneHour"),
+ [AUTO_LOCK_TIMER.TWELVE_HOURS]: t(
+ "autoLockTimerScreen.options.twelveHours",
+ ),
+ [AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS]: t(
+ "autoLockTimerScreen.options.twentyFourHours",
+ ),
+ [AUTO_LOCK_TIMER.NONE]: t("autoLockTimerScreen.options.none"),
+ };
+
+ const handleSelectOption = (option: AUTO_LOCK_TIMER) => {
+ setAutoLockTimer(option);
+ };
+
+ const listItems = Object.values(AUTO_LOCK_TIMER).map((option) => ({
+ title: timerLabels[option],
+ titleColor: themeColors.text.primary,
+ onPress: () => handleSelectOption(option),
+ trailingContent: (
+
+ ),
+ testID: `auto-lock-option-${option}`,
+ }));
+
+ return (
+
+
+
+
+ {t("autoLockTimerScreen.footer")}
+
+
+
+ );
+};
+
+export default AutoLockTimerScreen;
diff --git a/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/index.ts b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/index.ts
new file mode 100644
index 000000000..8accffc0d
--- /dev/null
+++ b/src/components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen/index.ts
@@ -0,0 +1 @@
+export { default } from "./AutoLockTimerScreen";
diff --git a/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx b/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx
index 44d3cf14e..dae64fffd 100644
--- a/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx
+++ b/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx
@@ -63,6 +63,16 @@ const SecurityScreen: React.FC = ({ navigation }) => {
testID: "face-id-settings-button",
});
}
+ listItems.push({
+ icon: ,
+ title: t("securityScreen.autoLock"),
+ titleColor: themeColors.text.primary,
+ onPress: () => navigation.navigate(SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN),
+ trailingContent: (
+
+ ),
+ testID: "auto-lock-button",
+ });
return (
diff --git a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts
index 7f3d175d0..ee3c4f1c6 100644
--- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts
+++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts
@@ -21,6 +21,7 @@ import { useSwapSettingsStore } from "ducks/swapSettings";
import { useTransactionBuilderStore } from "ducks/transactionBuilder";
import { useBlockaidTransaction } from "hooks/blockaid/useBlockaidTransaction";
import useAppTranslation from "hooks/useAppTranslation";
+import { isWalletUnlocked } from "hooks/useGetActiveAccount";
import { useToast } from "providers/ToastProvider";
import { useCallback, useEffect, useRef, useState } from "react";
import { analytics } from "services/analytics";
@@ -184,6 +185,14 @@ export const useSwapTransaction = ({
setIsProcessing(true);
try {
+ // Block signing if an auto-lock engaged after the swap was prepared.
+ // Inside the try so the terminal catch runs its analytics/toast/error
+ // path — executeSwap is invoked fire-and-forget, so a throw before the
+ // try would surface as an unhandled rejection.
+ if (!isWalletUnlocked()) {
+ throw new Error("Wallet is locked");
+ }
+
const signedXDR = signTransaction({
secretKey: account.privateKey,
network,
diff --git a/src/config/constants.ts b/src/config/constants.ts
index 536d24a9f..a6aedc956 100644
--- a/src/config/constants.ts
+++ b/src/config/constants.ts
@@ -89,9 +89,75 @@ export const PASSWORD_MAX_LENGTH = 2048;
export const ACCOUNT_NAME_MIN_LENGTH = 1;
export const ACCOUNT_NAME_MAX_LENGTH = 24;
export const ACCOUNTS_TO_VERIFY_ON_EXISTING_MNEMONIC_PHRASE = 6;
-export const HASH_KEY_EXPIRATION_MS = 24 * 60 * 60 * 1000; // 24 hours
+// Hard-expiry backstop: after this the session fully re-authenticates
+// (HASH_KEY_EXPIRED). Must stay greater than the largest AUTO_LOCK_TIMER
+// preset, or that preset's soft-lock fast path can never run (hard expiry
+// fires first).
+export const HASH_KEY_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export const VISUAL_DELAY_MS = 500;
+const SECOND_IN_MS = 1000;
+const MINUTE_IN_MS = 60 * SECOND_IN_MS;
+const HOUR_IN_MS = 60 * MINUTE_IN_MS;
+const YEAR_IN_MS = 365 * 24 * HOUR_IN_MS;
+
+/**
+ * Auto-lock timer options.
+ *
+ * The timer starts counting when the app goes to the background; returning to
+ * the app after the selected duration soft-locks the wallet (AUTH_STATUS.LOCKED,
+ * fast unlock path). Declaration order is the display order on the
+ * Auto-Lock Timer settings screen.
+ */
+export enum AUTO_LOCK_TIMER {
+ IMMEDIATELY = "immediately",
+ ONE_MINUTE = "oneMinute",
+ FIFTEEN_MINUTES = "fifteenMinutes",
+ THIRTY_MINUTES = "thirtyMinutes",
+ ONE_HOUR = "oneHour",
+ TWELVE_HOURS = "twelveHours",
+ TWENTY_FOUR_HOURS = "twentyFourHours",
+ NONE = "none",
+}
+
+// 12h matches the extension's default (DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES=720)
+// so both platforms ship the same cadence.
+export const DEFAULT_AUTO_LOCK_TIMER = AUTO_LOCK_TIMER.TWELVE_HOURS;
+
+/** Toast ID used by the lock screen / overlay to surface unlock errors. */
+export const UNLOCK_ERROR_TOAST_ID = "unlock-wallet-error";
+
+/**
+ * Toast IDs allowed to surface while the wallet is soft-locked — the lock
+ * overlay's own messaging. Every other toast is suppressed so the still-
+ * mounted screens underneath the overlay can't surface errors or validation
+ * messages over the lock until the app is usable again.
+ */
+export const SOFT_LOCK_ALLOWED_TOAST_IDS: string[] = [UNLOCK_ERROR_TOAST_ID];
+
+/**
+ * Background duration (in ms) after which each AUTO_LOCK_TIMER option locks
+ * the wallet. `0` locks as soon as the app is backgrounded; `null` never
+ * auto-locks by timer.
+ */
+export const AUTO_LOCK_TIMER_MS: Record = {
+ [AUTO_LOCK_TIMER.IMMEDIATELY]: 0,
+ [AUTO_LOCK_TIMER.ONE_MINUTE]: MINUTE_IN_MS,
+ [AUTO_LOCK_TIMER.FIFTEEN_MINUTES]: 15 * MINUTE_IN_MS,
+ [AUTO_LOCK_TIMER.THIRTY_MINUTES]: 30 * MINUTE_IN_MS,
+ [AUTO_LOCK_TIMER.ONE_HOUR]: HOUR_IN_MS,
+ [AUTO_LOCK_TIMER.TWELVE_HOURS]: 12 * HOUR_IN_MS,
+ [AUTO_LOCK_TIMER.TWENTY_FOUR_HOURS]: 24 * HOUR_IN_MS,
+ [AUTO_LOCK_TIMER.NONE]: null,
+};
+
+/**
+ * Hash key TTL used when the auto-lock timer is NONE: the user explicitly
+ * opted out of auto-lock, so the 24h hard-expiry backstop must never force
+ * a re-auth. Effectively "never" while remaining a valid timestamp.
+ */
+export const NEVER_EXPIRE_HASH_KEY_MS = 100 * YEAR_IN_MS;
+
// Recovery phrase validation constants
export const VALIDATION_WORDS_PER_ROW: number = 3;
export const VALIDATION_EXTRA_USER_WORDS: number = 2;
@@ -331,6 +397,8 @@ export enum STORAGE_KEYS {
* HASH_KEY The hash key and salt in an JSON stryngified object. This is used to encrypt and decrypt the temporary store.
* HASH_KEY format: { hashKey: string, salt: string, expiresAt: number }
* AUTH_STATUS The authentication status is stored securely to prevent tampering on rooted/jailbroken devices.
+ * AUTO_LOCK_TIMER_SETTING / AUTO_LOCK_BACKGROUNDED_AT The auto-lock policy inputs are stored
+ * securely for the same reason: tampering them from unencrypted storage must not weaken the lock.
* */
export enum SENSITIVE_STORAGE_KEYS {
TEMPORARY_STORE = "temporaryStore",
@@ -339,6 +407,8 @@ export enum SENSITIVE_STORAGE_KEYS {
// Persisted symmetric encryption key (scrypt output). Derivable from HASH_KEY, so no extra
// attack surface. Stored so cold app-opens from LOCKED skip the second scrypt.
DERIVED_KEY = "derivedKey",
+ AUTO_LOCK_TIMER_SETTING = "autoLockTimer",
+ AUTO_LOCK_BACKGROUNDED_AT = "autoLockBackgroundedAt",
}
/**
diff --git a/src/config/routes.ts b/src/config/routes.ts
index 4800557cf..c620cdc8e 100644
--- a/src/config/routes.ts
+++ b/src/config/routes.ts
@@ -94,6 +94,7 @@ export const SETTINGS_ROUTES = {
SHOW_RECOVERY_PHRASE_SCREEN: "ShowRecoveryPhraseScreen",
YOUR_RECOVERY_PHRASE_SCREEN: "YourRecoveryPhraseScreen",
BIOMETRICS_SETTINGS_SCREEN: "BiometricsSettingsScreen",
+ AUTO_LOCK_TIMER_SCREEN: "AutoLockTimerScreen",
} as const;
export const MANAGE_WALLETS_ROUTES = {
@@ -210,6 +211,7 @@ export type SettingsStackParamList = {
[SETTINGS_ROUTES.SHOW_RECOVERY_PHRASE_SCREEN]: undefined;
[SETTINGS_ROUTES.YOUR_RECOVERY_PHRASE_SCREEN]: undefined;
[SETTINGS_ROUTES.BIOMETRICS_SETTINGS_SCREEN]: undefined;
+ [SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN]: undefined;
};
export type ManageWalletsStackParamList = {
diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts
index 61546263d..65bb4c93e 100644
--- a/src/ducks/auth.ts
+++ b/src/ducks/auth.ts
@@ -12,10 +12,11 @@ import {
import { AnalyticsEvent } from "config/analyticsConfig";
import {
ACCOUNTS_TO_VERIFY_ON_EXISTING_MNEMONIC_PHRASE,
+ AUTO_LOCK_TIMER_MS,
BIOMETRIC_STORAGE_KEYS,
+ DEFAULT_AUTO_LOCK_TIMER,
FACE_ID_BIOMETRY_TYPES,
FINGERPRINT_BIOMETRY_TYPES,
- HASH_KEY_EXPIRATION_MS,
LoginType,
NETWORKS,
SENSITIVE_STORAGE_KEYS,
@@ -51,9 +52,17 @@ import { clearScreenshotDek } from "helpers/screenshotCrypto";
import { scrubStrKeys } from "helpers/stellarStrKey";
import { clearWalletKitStorage } from "helpers/walletKitUtil";
import { t } from "i18next";
+import { AppState, Keyboard } from "react-native";
import ReactNativeBiometrics from "react-native-biometrics";
import * as Keychain from "react-native-keychain";
import { analytics } from "services/analytics";
+import {
+ clearBackgroundedAt,
+ getAutoLockTimer,
+ getBackgroundedAt,
+ getHashKeyExpirationMs,
+ persistAutoLockTimer,
+} from "services/autoLock";
import { getAccount } from "services/stellar";
import {
clearNonSensitiveData,
@@ -207,6 +216,16 @@ interface AuthState {
isSwitchingAccount: boolean;
error: string | null;
authStatus: AuthStatus;
+ // True when the wallet was soft-locked in-process (auto-lock timer or
+ // IMMEDIATELY on backgrounding): the navigation tree stays mounted under a
+ // lock overlay so the user resumes exactly where they were after unlocking.
+ isSoftLocked: boolean;
+ // True when the lock happened while the user was actively present in the
+ // app — a foreground-idle timeout or a manual lock/logout. The lock screen
+ // uses this to suppress the biometric auto-prompt: an unprompted Face ID is
+ // jarring when the user just locked it themselves or stepped away. The
+ // prompt still fires on cold start and on return from the background.
+ suppressBiometricAutoPrompt: boolean;
allAccounts: Account[];
// Active account state
@@ -217,7 +236,6 @@ interface AuthState {
// Biometric authentication state
signInMethod: LoginType;
- hasTriggeredAppOpenBiometricsLogin: boolean;
}
/**
@@ -280,6 +298,7 @@ interface ImportSecretKeyParams {
*/
interface AuthActions {
logout: (shouldWipeAllData?: boolean) => void;
+ softLock: (options?: { suppressBiometricPrompt?: boolean }) => Promise;
signUp: (params: SignUpParams) => Promise;
signIn: (params: SignInParams) => Promise;
importWallet: (params: ImportWalletParams) => Promise;
@@ -320,7 +339,6 @@ interface AuthActions {
// Biometric authentication actions
setSignInMethod: (method: LoginType) => void;
- setHasTriggeredAppOpenBiometricsLogin: (hasTriggered: boolean) => void;
}
/**
@@ -343,6 +361,8 @@ const initialState: Omit = {
isSwitchingAccount: false,
error: null,
authStatus: AUTH_STATUS.NOT_AUTHENTICATED,
+ isSoftLocked: false,
+ suppressBiometricAutoPrompt: false,
allAccounts: [],
// Active account initial state
account: null,
@@ -351,7 +371,6 @@ const initialState: Omit = {
navigationRef: null,
// Biometric authentication initial state
signInMethod: LoginType.PASSWORD,
- hasTriggeredAppOpenBiometricsLogin: false,
};
/**
@@ -491,11 +510,58 @@ const getAuthStatus = async (): Promise => {
return AUTH_STATUS.HASH_KEY_EXPIRED;
}
- // Check if hash key is expired (only relevant when not LOCKED)
+ // Hard expiry wins over the soft timer lock: checked before the timer so a
+ // session backgrounded past the hash-key TTL forces a full re-auth instead
+ // of a fast-path LOCKED that would just refresh the expired key. (The
+ // persisted-LOCKED branch above keeps the fast path — there it's intended.)
if (hashKey && isHashKeyExpired(hashKey)) {
return AUTH_STATUS.HASH_KEY_EXPIRED;
}
+ // Auto-lock timer: useAuthCheck records a timestamp on background; on
+ // return (warm or cold start), elapsed >= the selected duration soft-locks
+ // via the fast unlock path. Uses wall-clock time, so a clock rollback can
+ // dodge it — the hash-key expiry above still bounds the session.
+ const backgroundedAt = await getBackgroundedAt();
+ // Explicit null check, not truthiness: a clock-rollback returns 0 (epoch)
+ // to force a conservative lock, and 0 is falsey — `if (backgroundedAt)`
+ // would skip it and leave the session AUTHENTICATED.
+ if (backgroundedAt !== null) {
+ const autoLockTimer = await getAutoLockTimer();
+ const autoLockTimerMs = AUTO_LOCK_TIMER_MS[autoLockTimer];
+ const elapsedInBackground = Date.now() - backgroundedAt;
+
+ // Only POSITIVE timed durations lock here. IMMEDIATELY (0) is
+ // background-only: it's locked proactively on the background transition
+ // (useAuthCheck) and persisted, so it must NOT be re-derived from a
+ // lingering timestamp — otherwise elapsed >= 0 would re-lock a fresh
+ // foreground session and make the app unusable. NONE (null) never locks.
+ if (
+ autoLockTimerMs !== null &&
+ autoLockTimerMs > 0 &&
+ elapsedInBackground >= autoLockTimerMs &&
+ temporaryStore
+ ) {
+ await secureDataStorage.setItem(
+ SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
+ AUTH_STATUS.LOCKED,
+ );
+ await clearBackgroundedAt();
+ return AUTH_STATUS.LOCKED;
+ }
+
+ if (AppState.currentState === "active") {
+ // Returned within the timer: consume the timestamp so the foreground
+ // interval can't lock mid-use. The hash-key TTL is deliberately NOT
+ // refreshed here — it's only anchored at credential-verified moments
+ // (signIn / generateHashKey / applyAutoLockTimerToHashKey) so key
+ // material stays bounded however often the app is reopened.
+ await clearBackgroundedAt();
+ }
+ // Still backgrounded (periodic background check): leave the timestamp
+ // intact so the timer keeps counting from the original moment.
+ }
+
// All conditions for authentication are met
return AUTH_STATUS.AUTHENTICATED;
} catch (error) {
@@ -804,8 +870,9 @@ const generateHashKey = async (password: string): Promise => {
// Convert to base64 for storage
const hashKey = base64Encode(hashKeyBytes);
- // Calculate the expiration timestamp
- const expirationTime = Date.now() + HASH_KEY_EXPIRATION_MS;
+ // Calculate the expiration timestamp (timer-aware: "None" never expires)
+ const autoLockTimer = await getAutoLockTimer();
+ const expirationTime = Date.now() + getHashKeyExpirationMs(autoLockTimer);
// Return the hash key object (caller will store it)
return {
@@ -1037,6 +1104,23 @@ const clearAllData = async (): Promise => {
]);
};
+/**
+ * Resets the auto-lock setting to the default for a freshly established wallet
+ * (first install, sign up, import, or wipe). Writes the default to both the
+ * zustand store and the secure mirror and clears any stale background
+ * timestamp, so a new wallet never inherits the previous one's policy — e.g. a
+ * prior "None" would otherwise bake a never-expiring hash key. The secure
+ * write is awaited so generateHashKey reads the default expiry, and so first
+ * install lands on 24h even though the mirror was never written manually.
+ */
+const resetAutoLockForNewWallet = async (): Promise => {
+ usePreferencesStore.setState({ autoLockTimer: DEFAULT_AUTO_LOCK_TIMER });
+ await Promise.all([
+ persistAutoLockTimer(DEFAULT_AUTO_LOCK_TIMER),
+ clearBackgroundedAt(),
+ ]);
+};
+
const getKeyFromKeyManager = async (
password: string,
activeAccountId?: string | null,
@@ -1397,12 +1481,14 @@ const signIn = async ({
SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE,
);
if (existingHashKey && existingTempStore) {
- // Fast path: temp store is intact — just refresh TTL.
+ // Fast path: temp store is intact — just refresh TTL
+ // (timer-aware: "None" never expires).
+ const autoLockTimer = await getAutoLockTimer();
await secureDataStorage.setItem(
SENSITIVE_STORAGE_KEYS.HASH_KEY,
JSON.stringify({
...existingHashKey,
- expiresAt: Date.now() + HASH_KEY_EXPIRATION_MS,
+ expiresAt: Date.now() + getHashKeyExpirationMs(autoLockTimer),
}),
);
} else {
@@ -2059,6 +2145,10 @@ export const useAuthenticationStore = create()((set, get) => ({
isLoadingAccount: false,
accountError: null,
authStatus: AUTH_STATUS.LOCKED,
+ // The user locked the app themselves — don't auto-prompt
+ // biometrics on the lock screen; that only fires on cold start
+ // or return from the background.
+ suppressBiometricAutoPrompt: true,
isLoading: false,
});
@@ -2118,6 +2208,13 @@ export const useAuthenticationStore = create()((set, get) => ({
await clearNonSensitiveData();
await dataStorage.remove(STORAGE_KEYS.COLLECTIBLES_LIST);
+ // Reset auto-lock so the next wallet on this device doesn't
+ // inherit the previous user's timer. The awaited secure-mirror
+ // write (source of truth for getAuthStatus / generateHashKey)
+ // means an interrupted wipe can't leave a weaker policy — e.g.
+ // NONE's never-expire — behind for the next wallet.
+ await resetAutoLockForNewWallet();
+
await clearBiometricsData();
set({ isLoading: false });
@@ -2134,6 +2231,53 @@ export const useAuthenticationStore = create()((set, get) => ({
}, 0);
},
+ /**
+ * Soft-locks the wallet in-process (auto-lock timer / IMMEDIATELY option).
+ *
+ * Unlike logout(), keeps the navigation tree mounted under the lock overlay
+ * so the user resumes where they were after unlocking. Sensitive reads stay
+ * gated while LOCKED (getActiveAccount, WalletKit). The active account is
+ * left in the store so the mounted screens don't re-run validations and flash
+ * errors; signing is independently blocked while LOCKED. Persisting LOCKED
+ * covers cold starts (which fall back to the LockScreen route).
+ */
+ softLock: async (options?: { suppressBiometricPrompt?: boolean }) => {
+ Keyboard.dismiss();
+
+ // Atomic update: RootNavigator must never see LOCKED && !isSoftLocked
+ // (it would unmount the preserved tree). Clear account so the decrypted
+ // private key isn't held while locked — it re-populates on unlock.
+ set({
+ authStatus: AUTH_STATUS.LOCKED,
+ isSoftLocked: true,
+ account: null,
+ // A foreground-idle lock suppresses the lock screen's biometric
+ // auto-prompt; background / IMMEDIATELY / cold-start locks still prompt.
+ suppressBiometricAutoPrompt: options?.suppressBiometricPrompt ?? false,
+ isLoading: false,
+ });
+
+ // Persist LOCKED (covers tampering + cold starts). Retry once and rethrow
+ // on failure: a swallowed write would leave the wallet auto-unlockable on
+ // the next cold launch.
+ try {
+ await secureDataStorage.setItem(
+ SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
+ AUTH_STATUS.LOCKED,
+ );
+ } catch (firstError) {
+ logger.error(
+ "softLock",
+ "Failed to persist LOCKED status, retrying",
+ firstError,
+ );
+ await secureDataStorage.setItem(
+ SENSITIVE_STORAGE_KEYS.AUTH_STATUS,
+ AUTH_STATUS.LOCKED,
+ );
+ }
+ },
+
/**
* Signs up a new user with the provided credentials
*
@@ -2143,9 +2287,13 @@ export const useAuthenticationStore = create()((set, get) => ({
signUp: async (params): Promise => {
set((state) => ({ ...state, isLoading: true, error: null }));
try {
+ // Fresh wallet: reset auto-lock to the default before the hash key is
+ // generated so it doesn't inherit a previous wallet's timer.
+ await resetAutoLockForNewWallet();
await signUp(params);
set({
...initialState,
+ navigationRef: get().navigationRef,
isLoading: false,
authStatus: AUTH_STATUS.AUTHENTICATED,
});
@@ -2177,12 +2325,27 @@ export const useAuthenticationStore = create()((set, get) => ({
await signIn({ ...params, shouldCreateTempStore });
+ // Clear the persisted LOCKED marker and stale backgrounded-at timestamp
+ // before flipping to AUTHENTICATED — awaited so the auto-lock funnel
+ // can't observe the old values and re-lock the fresh session.
+ await Promise.all([
+ secureDataStorage.remove(SENSITIVE_STORAGE_KEYS.AUTH_STATUS),
+ clearBackgroundedAt(),
+ ]);
+
// Password verified — unblock navigation immediately.
// getActiveAccount is slow (derivePrivateKeyFromMnemonic) so we run it
// in the background and let the home screen render with isLoadingAccount=true.
analytics.trackReAuthSuccess();
set({
...initialState,
+ // Preserve navigationRef: nothing remounts to re-set it after a soft
+ // unlock, so later lock navigations would otherwise no-op
+ navigationRef: get().navigationRef,
+ // Preserve signInMethod: resetting it to PASSWORD would make the
+ // still-mounted biometric buttons drop biometric enforcement after a
+ // soft unlock, letting transactions confirm without verification
+ signInMethod: get().signInMethod,
authStatus: AUTH_STATUS.AUTHENTICATED,
isLoading: false,
isLoadingAccount: true,
@@ -2191,11 +2354,14 @@ export const useAuthenticationStore = create()((set, get) => ({
getActiveAccount(AUTH_STATUS.AUTHENTICATED)
.then((activeAccount) => {
if (!activeAccount) {
- // Sign-in succeeded but no active account was found — lock and
- // surface an error so the user knows something went wrong.
+ // No active account after sign-in: lock and surface an error.
+ // isSoftLocked must accompany LOCKED (RootNavigator unmounts the
+ // tree on LOCKED && !isSoftLocked).
set({
isLoadingAccount: false,
authStatus: AUTH_STATUS.LOCKED,
+ isSoftLocked: true,
+ account: null,
error: t("authStore.error.failedToLoadAccount"),
});
get().navigateToLockScreen();
@@ -2212,17 +2378,12 @@ export const useAuthenticationStore = create()((set, get) => ({
set({
isLoadingAccount: false,
authStatus: AUTH_STATUS.LOCKED,
+ isSoftLocked: true,
+ account: null,
error: t("authStore.error.failedToLoadAccount"),
});
get().navigateToLockScreen();
});
-
- // Clear persisted auth status in background (non-blocking)
- secureDataStorage
- .remove(SENSITIVE_STORAGE_KEYS.AUTH_STATUS)
- .catch((e) =>
- logger.debug("signIn", "Failed to clear persisted auth status", e),
- );
} catch (error) {
analytics.trackReAuthFail();
logger.error("useAuthenticationStore.signIn", "Sign in failed", error);
@@ -2563,9 +2724,13 @@ export const useAuthenticationStore = create()((set, get) => ({
set({ isLoading: true, error: null });
try {
+ // Re-import replaces the wallet: reset auto-lock to the default before
+ // the hash key is generated so it doesn't inherit the previous timer.
+ await resetAutoLockForNewWallet();
await importWallet(params);
set({
...initialState,
+ navigationRef: get().navigationRef,
authStatus: AUTH_STATUS.AUTHENTICATED,
isLoading: false,
});
@@ -2591,14 +2756,49 @@ export const useAuthenticationStore = create()((set, get) => ({
},
/**
- * Gets the current authentication status
+ * Gets the current authentication status.
*
- * @returns {Promise} The current authentication status
+ * Single funnel for lock transitions: an AUTHENTICATED→LOCKED transition
+ * (auto-lock timer) goes through softLock for every caller (periodic checks,
+ * fetchActiveAccount, selectAccount), keeping the navigation tree mounted
+ * instead of racing it with navigation resets.
*/
getAuthStatus: async () => {
- // Always re-validate auth status to ensure consistency
- // Don't rely on cached status as it may be stale after app updates
+ // Re-validate from storage; cached status may be stale after app updates
+ const previousAuthStatus = get().authStatus;
const authStatus = await getAuthStatus();
+
+ if (
+ authStatus === AUTH_STATUS.LOCKED &&
+ previousAuthStatus === AUTH_STATUS.AUTHENTICATED
+ ) {
+ // Auto-lock timer fired: soft lock atomically so the tree never unmounts
+ await get().softLock();
+ return authStatus;
+ }
+
+ // Don't let a stale read (resolved just before the LOCKED persist landed)
+ // downgrade an active soft lock to AUTHENTICATED. Only signIn clears
+ // isSoftLocked; disk already holds LOCKED, so the next check self-heals.
+ if (get().isSoftLocked && authStatus === AUTH_STATUS.AUTHENTICATED) {
+ return get().authStatus;
+ }
+
+ // A soft lock is only valid over an authenticated session. If the session
+ // is gone (wipe) or hard-expired, clear isSoftLocked too — otherwise the
+ // overlay stays stranded over an accountless/expired app until kill.
+ if (
+ get().isSoftLocked &&
+ (authStatus === AUTH_STATUS.NOT_AUTHENTICATED ||
+ authStatus === AUTH_STATUS.HASH_KEY_EXPIRED)
+ ) {
+ set({ authStatus, isSoftLocked: false });
+ if (authStatus === AUTH_STATUS.HASH_KEY_EXPIRED) {
+ get().navigateToLockScreen();
+ }
+ return authStatus;
+ }
+
set({ authStatus });
// If the hash key is expired, navigate to lock screen
@@ -2624,6 +2824,12 @@ export const useAuthenticationStore = create()((set, get) => ({
* Used when authentication expires or user needs to re-authenticate
*/
navigateToLockScreen: () => {
+ // Soft-locked: the lock overlay is already covering the app and the
+ // navigation tree must stay intact so the user resumes where they were.
+ if (get().isSoftLocked) {
+ return;
+ }
+
const { navigationRef } = get();
if (navigationRef && navigationRef.isReady()) {
// Check if we're already on the lock screen to prevent navigation loops
@@ -2652,22 +2858,15 @@ export const useAuthenticationStore = create()((set, get) => ({
set({ isLoadingAccount: true, accountError: null });
try {
- // Check auth status first
- const authStatus = await getAuthStatus();
+ // Via the store funnel so an auto-lock here soft-locks atomically
+ // rather than racing a navigation reset
+ const authStatus = await get().getAuthStatus();
- // Security: Block access when hash key is expired or when locked
- // LOCKED state requires password re-entry before accessing sensitive data
+ // Block sensitive data while expired/locked (funnel already navigated)
if (
authStatus === AUTH_STATUS.HASH_KEY_EXPIRED ||
authStatus === AUTH_STATUS.LOCKED
) {
- set({
- authStatus,
- });
-
- // Navigate to lock screen
- get().navigateToLockScreen();
-
set({ isLoadingAccount: false });
return null;
}
@@ -2792,15 +2991,12 @@ export const useAuthenticationStore = create()((set, get) => ({
selectAccount: async (publicKey: string) => {
set({ isSwitchingAccount: true, error: null });
try {
- // Security: Check auth status before allowing account switching
- // Block access when hash key is expired or when locked
- const authStatus = await getAuthStatus();
+ // Via the store funnel so an auto-lock here soft-locks atomically
+ const authStatus = await get().getAuthStatus();
if (
authStatus === AUTH_STATUS.HASH_KEY_EXPIRED ||
authStatus === AUTH_STATUS.LOCKED
) {
- set({ authStatus });
- get().navigateToLockScreen();
set({ isSwitchingAccount: false });
return;
}
@@ -2889,8 +3085,4 @@ export const useAuthenticationStore = create()((set, get) => ({
setSignInMethod: (method: LoginType) => {
set({ signInMethod: method });
},
-
- setHasTriggeredAppOpenBiometricsLogin: (hasTriggered: boolean) => {
- set({ hasTriggeredAppOpenBiometricsLogin: hasTriggered });
- },
}));
diff --git a/src/ducks/preferences.ts b/src/ducks/preferences.ts
index 1bad5b477..39d2ed3b1 100644
--- a/src/ducks/preferences.ts
+++ b/src/ducks/preferences.ts
@@ -1,4 +1,11 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
+import { AUTO_LOCK_TIMER, DEFAULT_AUTO_LOCK_TIMER } from "config/constants";
+import { logger } from "config/logger";
+import {
+ applyAutoLockTimerToHashKey,
+ getAutoLockTimer,
+ persistAutoLockTimer,
+} from "services/autoLock";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
@@ -9,17 +16,21 @@ interface PreferencesState {
setIsMemoValidationEnabled: (isMemoValidationEnabled: boolean) => void;
isBiometricsEnabled: boolean | undefined;
setIsBiometricsEnabled: (isBiometricsEnabled: boolean) => void;
+ autoLockTimer: AUTO_LOCK_TIMER;
+ setAutoLockTimer: (autoLockTimer: AUTO_LOCK_TIMER) => void;
+ hydrateAutoLockTimer: () => Promise;
}
const INITIAL_PREFERENCES_STATE = {
isHideDustEnabled: true,
isMemoValidationEnabled: true,
isBiometricsEnabled: undefined,
+ autoLockTimer: DEFAULT_AUTO_LOCK_TIMER,
};
export const usePreferencesStore = create()(
persist(
- (set) => ({
+ (set, get) => ({
...INITIAL_PREFERENCES_STATE,
setIsHideDustEnabled: (isHideDustEnabled: boolean) =>
set({ isHideDustEnabled }),
@@ -27,10 +38,60 @@ export const usePreferencesStore = create()(
set({ isMemoValidationEnabled }),
setIsBiometricsEnabled: (isBiometricsEnabled: boolean) =>
set({ isBiometricsEnabled }),
+ setAutoLockTimer: (autoLockTimer: AUTO_LOCK_TIMER) => {
+ const previousAutoLockTimer = get().autoLockTimer;
+ set({ autoLockTimer });
+
+ // Persist to the secure mirror, then re-anchor the hash-key TTL —
+ // sequenced, not raced. On failure, roll back the UI, mirror, and TTL
+ // together so the selection, enforcement, and expiry can't disagree.
+ (async () => {
+ try {
+ await persistAutoLockTimer(autoLockTimer);
+ await applyAutoLockTimerToHashKey(autoLockTimer);
+ } catch (error) {
+ logger.error(
+ "setAutoLockTimer",
+ "Failed to apply auto-lock timer; rolling back",
+ error,
+ );
+ set({ autoLockTimer: previousAutoLockTimer });
+ await Promise.allSettled([
+ persistAutoLockTimer(previousAutoLockTimer),
+ applyAutoLockTimerToHashKey(previousAutoLockTimer),
+ ]);
+ }
+ })();
+ },
+ // Load autoLockTimer from the secure mirror (its single source of truth).
+ // Called once on app init since the value is intentionally not persisted
+ // to the unencrypted zustand store (see partialize below).
+ hydrateAutoLockTimer: async () => {
+ try {
+ const autoLockTimer = await getAutoLockTimer();
+ set({ autoLockTimer });
+ } catch (error) {
+ logger.error(
+ "hydrateAutoLockTimer",
+ "Failed to hydrate auto-lock timer from secure storage",
+ error,
+ );
+ }
+ },
}),
{
name: "preferences-storage",
storage: createJSONStorage(() => AsyncStorage),
+ // autoLockTimer is intentionally NOT persisted here: the secure-storage
+ // mirror (read by getAuthStatus for enforcement) is its single source of
+ // truth. Keeping a second copy in unencrypted AsyncStorage would let the
+ // UI disagree with enforcement and expose the policy to tampering. It's
+ // hydrated from the mirror on app init via hydrateAutoLockTimer().
+ partialize: (state) => ({
+ isHideDustEnabled: state.isHideDustEnabled,
+ isMemoValidationEnabled: state.isMemoValidationEnabled,
+ isBiometricsEnabled: state.isBiometricsEnabled,
+ }),
},
),
);
diff --git a/src/helpers/privacyShield.ts b/src/helpers/privacyShield.ts
new file mode 100644
index 000000000..102b1fe11
--- /dev/null
+++ b/src/helpers/privacyShield.ts
@@ -0,0 +1,67 @@
+import { NativeModules } from "react-native";
+
+interface PrivacyShieldModule {
+ hide?: () => Promise;
+}
+
+const getModule = (): PrivacyShieldModule | undefined =>
+ NativeModules.PrivacyShield as PrivacyShieldModule | undefined;
+
+// JS mirror of whether the native shield is currently covering the app. The
+// native side shows it deterministically when the app backgrounds; callers
+// mark it here so they can tell whether the wallet is still hidden (e.g. to
+// hold a biometric prompt until the lock screen is actually visible).
+let shieldVisible = false;
+const shieldHiddenListeners = new Set<() => void>();
+
+/**
+ * Records that the native shield was raised (call on backgrounding). No-op
+ * when the native module is unavailable so platforms without a shield don't
+ * report a phantom cover.
+ */
+export const markPrivacyShieldVisible = (): void => {
+ if (getModule()?.hide) {
+ shieldVisible = true;
+ }
+};
+
+/** Whether the native shield is believed to be covering the app right now. */
+export const isPrivacyShieldVisible = (): boolean => shieldVisible;
+
+/**
+ * Subscribes to the moment the shield is dismissed. Returns an unsubscribe
+ * function. Fires once per hidePrivacyShield call.
+ */
+export const onPrivacyShieldHidden = (listener: () => void): (() => void) => {
+ shieldHiddenListeners.add(listener);
+ return () => {
+ shieldHiddenListeners.delete(listener);
+ };
+};
+
+/**
+ * Dismisses the native privacy shield (iOS overlay window / Android overlay
+ * view) once the JS auto-lock decision has settled, so the wallet is revealed
+ * only after a soft-lock overlay (if any) has mounted. No-op if the native
+ * module isn't available — the native side has its own fallback timer.
+ * Notifies onPrivacyShieldHidden listeners once the shield is gone.
+ */
+export const hidePrivacyShield = (): void => {
+ const finish = () => {
+ shieldVisible = false;
+ shieldHiddenListeners.forEach((listener) => listener());
+ };
+
+ const privacyShield = getModule();
+ if (!privacyShield?.hide) {
+ finish();
+ return;
+ }
+
+ privacyShield
+ .hide()
+ .catch(() => {
+ // Best effort — the native fallback timer removes the shield regardless
+ })
+ .finally(finish);
+};
diff --git a/src/helpers/userActivity.ts b/src/helpers/userActivity.ts
new file mode 100644
index 000000000..871a24273
--- /dev/null
+++ b/src/helpers/userActivity.ts
@@ -0,0 +1,23 @@
+/**
+ * Bridge for recording user activity from outside the AuthCheckProvider tree.
+ *
+ * The app-wide PanResponder can't see touches consumed by
+ * react-native-gesture-handler or system-keyboard keystrokes. Components
+ * handling those call `recordUserActivity()` to keep the foreground-idle clock
+ * fresh; useAuthCheck registers the actual recorder so the source stays there.
+ */
+type ActivityRecorder = () => void;
+
+let activityRecorder: ActivityRecorder | null = null;
+
+/** Registered by useAuthCheck; pass null on unmount. */
+export const setActivityRecorder = (
+ recorder: ActivityRecorder | null,
+): void => {
+ activityRecorder = recorder;
+};
+
+/** Marks "now" as user activity, resetting the foreground-idle auto-lock clock. */
+export const recordUserActivity = (): void => {
+ activityRecorder?.();
+};
diff --git a/src/hooks/useAppOpenBiometricsLogin.ts b/src/hooks/useAppOpenBiometricsLogin.ts
deleted file mode 100644
index 258bd3543..000000000
--- a/src/hooks/useAppOpenBiometricsLogin.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { ERROR_TOAST_DURATION } from "config/constants";
-import { AUTH_STATUS } from "config/types";
-import { useAuthenticationStore } from "ducks/auth";
-import useAppTranslation from "hooks/useAppTranslation";
-import { AUTH_ERROR_TOAST_ID } from "hooks/useAuthErrorToast";
-import { useBiometrics } from "hooks/useBiometrics";
-import { useToast } from "providers/ToastProvider";
-import { useEffect } from "react";
-
-export const useAppOpenBiometricsLogin = (initializing: boolean) => {
- const {
- authStatus,
- verifyActionWithBiometrics,
- signIn,
- clearError,
- hasTriggeredAppOpenBiometricsLogin,
- setHasTriggeredAppOpenBiometricsLogin,
- } = useAuthenticationStore();
- const { isBiometricsEnabled } = useBiometrics();
- const { showToast } = useToast();
- const { t } = useAppTranslation();
-
- useEffect(() => {
- if (initializing) {
- return;
- }
-
- if (authStatus === AUTH_STATUS.AUTHENTICATED || !isBiometricsEnabled) {
- setHasTriggeredAppOpenBiometricsLogin(true);
- return;
- }
-
- if (
- (authStatus === AUTH_STATUS.HASH_KEY_EXPIRED ||
- authStatus === AUTH_STATUS.LOCKED) &&
- !hasTriggeredAppOpenBiometricsLogin
- ) {
- setHasTriggeredAppOpenBiometricsLogin(true);
-
- verifyActionWithBiometrics(async (biometricPassword?: string) => {
- if (biometricPassword) {
- await signIn({ password: biometricPassword });
- }
- }).catch(() => {
- // Clear any store error set by the underlying signIn so the global
- // AuthErrorToastListener doesn't race this toast on the shared id (and
- // a biometric-derived invalidPassword doesn't bleed into LockScreen's
- // inline field). This biometric toast is the single authoritative one.
- clearError();
- showToast({
- toastId: AUTH_ERROR_TOAST_ID,
- variant: "error",
- title: t("lockScreen.errorUnlockingWalletTitle"),
- message: t("lockScreen.errorUnlockingWalletMessage"),
- duration: ERROR_TOAST_DURATION,
- });
- });
- }
- }, [
- authStatus,
- isBiometricsEnabled,
- initializing,
- hasTriggeredAppOpenBiometricsLogin,
- setHasTriggeredAppOpenBiometricsLogin,
- verifyActionWithBiometrics,
- signIn,
- clearError,
- showToast,
- t,
- ]);
-};
diff --git a/src/hooks/useAuthCheck.ts b/src/hooks/useAuthCheck.ts
index f6c8c44c0..361ff8eaf 100644
--- a/src/hooks/useAuthCheck.ts
+++ b/src/hooks/useAuthCheck.ts
@@ -1,13 +1,24 @@
+import { navigationRef } from "components/App";
+import { AUTO_LOCK_TIMER, AUTO_LOCK_TIMER_MS } from "config/constants";
import { logger } from "config/logger";
import { AUTH_STATUS } from "config/types";
import { useAuthenticationStore } from "ducks/auth";
-import { useEffect, useRef, useState, useCallback } from "react";
import {
- AppState,
- AppStateStatus,
- PanResponder,
- PanResponderInstance,
-} from "react-native";
+ hidePrivacyShield,
+ markPrivacyShieldVisible,
+} from "helpers/privacyShield";
+import { setActivityRecorder } from "helpers/userActivity";
+import { useEffect, useRef, useState, useCallback, useMemo } from "react";
+import { AppState, AppStateStatus, Keyboard, PanResponder } from "react-native";
+import {
+ getAutoLockTimer,
+ hasPersistedSession,
+ recordBackgroundedAt,
+} from "services/autoLock";
+
+// Delay before lifting the native privacy shield on foreground, giving a
+// soft-lock overlay a frame to paint so the unlocked wallet never flashes
+const SHIELD_REVEAL_DELAY = 50;
// Constants for interval timings (in milliseconds)
const BACKGROUND_CHECK_INTERVAL = 60000; // Check every minute when in background
@@ -24,16 +35,50 @@ const INITIAL_SETUP_DELAY = 500; // Delay to prevent race conditions during setu
* It adjusts the check frequency based on the app state and user activity.
*/
const useAuthCheck = () => {
- const { getAuthStatus, authStatus, navigateToLockScreen } =
- useAuthenticationStore();
+ const { getAuthStatus, authStatus } = useAuthenticationStore();
const [isActive, setIsActive] = useState(true);
- // Refs to track app state, last interaction, auth check intervals, and pan responder instance
+ // Refs to track app state, last interaction, and auth check intervals
const appState = useRef(AppState.currentState);
const checkIntervalRef = useRef(null);
const lastInteractionRef = useRef(Date.now());
const lastCheckRef = useRef(Date.now());
- const panResponderRef = useRef(null);
+
+ /**
+ * Mark "now" as the last user interaction. Called from every activity
+ * signal (touches, navigation, unlock) so the foreground-idle timer only
+ * fires after a genuinely idle stretch.
+ */
+ const recordInteraction = useCallback(() => {
+ lastInteractionRef.current = Date.now();
+ setIsActive(true);
+ }, []);
+
+ /**
+ * PanResponder to observe touch interactions. Built during render (not in an
+ * effect) so panHandlers are attached to the provider's View on the very
+ * first render — otherwise touches wouldn't reset the idle clock until some
+ * unrelated re-render populated the handlers, letting a freshly active user
+ * be idle-locked from a stale timestamp. The *Capture variants run in the
+ * capture phase (root → target) so the root sees EVERY touch start/move,
+ * including taps on buttons/list items that would otherwise claim the
+ * responder first; returning false observes without stealing the gesture.
+ */
+ const panResponder = useMemo(
+ () =>
+ PanResponder.create({
+ onStartShouldSetPanResponderCapture: () => {
+ recordInteraction();
+ return false;
+ },
+ onMoveShouldSetPanResponderCapture: () => {
+ recordInteraction();
+ return false;
+ },
+ onPanResponderTerminationRequest: () => true,
+ }),
+ [recordInteraction],
+ );
/**
* Check the authentication status and navigate to the lock screen if the auth hash is expired.
@@ -52,9 +97,36 @@ const useAuthCheck = () => {
lastCheckRef.current = now;
try {
+ // The store action is the single funnel for lock transitions: it
+ // soft-locks atomically when the background auto-lock timer fired
+ // (preserving the mounted screens under the overlay) and navigates to
+ // the lock screen when the hash key hard-expired.
const status = await getAuthStatus();
- if (status === AUTH_STATUS.HASH_KEY_EXPIRED) {
- navigateToLockScreen();
+
+ // Foreground-idle auto-lock: while the app is active, lock after the
+ // configured duration with no user interaction (touches, navigation and
+ // keyboard reset lastInteractionRef via recordInteraction). Background
+ // time is handled by getAuthStatus above; here we cover an open-but-idle
+ // session. Only timed presets idle-lock — IMMEDIATELY (0, background-
+ // only) and NONE (null) are skipped.
+ if (
+ status === AUTH_STATUS.AUTHENTICATED &&
+ AppState.currentState === "active"
+ ) {
+ const autoLockTimer = await getAutoLockTimer();
+ const timerMs = AUTO_LOCK_TIMER_MS[autoLockTimer];
+
+ if (
+ timerMs !== null &&
+ timerMs > 0 &&
+ Date.now() - lastInteractionRef.current >= timerMs
+ ) {
+ // The user stayed in the app and idled out — suppress the lock
+ // screen's biometric auto-prompt for this case.
+ await useAuthenticationStore
+ .getState()
+ .softLock({ suppressBiometricPrompt: true });
+ }
}
} catch (error) {
logger.error(
@@ -63,7 +135,7 @@ const useAuthCheck = () => {
error,
);
}
- }, [getAuthStatus, navigateToLockScreen, authStatus]);
+ }, [getAuthStatus, authStatus]);
/**
* Setup a periodic interval to check authentication status based on the current app state and user activity.
@@ -99,11 +171,91 @@ const useAuthCheck = () => {
*/
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
- // When returning to active state, allow a slight delay before checking auth
+ // Mirror the native shield: it's raised whenever the app backgrounds, so
+ // the lock screen can hold its biometric prompt until the shield drops.
+ if (nextAppState === "background") {
+ markPrivacyShieldVisible();
+ }
+
+ // Record when the app goes to the background so the auto-lock timer can
+ // be evaluated on the next foreground or cold start. Intentionally NOT
+ // on "inactive": iOS fires it for control center, app-switcher peeks,
+ // permission dialogs and biometric prompts. NOTE: some Android OEMs
+ // emit "background" when their BiometricPrompt appears — worth keeping
+ // in mind if IMMEDIATELY ever misbehaves around in-app biometric
+ // confirmations on specific devices.
+ const { authStatus: currentAuthStatus, softLock } =
+ useAuthenticationStore.getState();
+ if (nextAppState === "background") {
+ // Gate on persisted session data, not only the zustand status: a cold
+ // launch into an existing session can be backgrounded before
+ // RootNavigator's async getAuthStatus sets AUTHENTICATED (the store is
+ // still on its initial NOT_AUTHENTICATED). Treat that unhydrated window
+ // as authenticated when a session exists on disk so the timestamp /
+ // IMMEDIATELY lock still fire. A hydrated LOCKED / HASH_KEY_EXPIRED
+ // status is respected (we don't re-record for an already-locked app).
+ (async () => {
+ const isSessionActive =
+ currentAuthStatus === AUTH_STATUS.AUTHENTICATED ||
+ (currentAuthStatus === AUTH_STATUS.NOT_AUTHENTICATED &&
+ (await hasPersistedSession()));
+
+ if (!isSessionActive) {
+ return;
+ }
+
+ await recordBackgroundedAt();
+
+ // Read from the secure-storage mirror (not the zustand store) so the
+ // IMMEDIATELY lock also fires when backgrounding happens before
+ // zustand rehydration completes.
+ const autoLockTimer = await getAutoLockTimer();
+
+ if (autoLockTimer === AUTO_LOCK_TIMER.IMMEDIATELY) {
+ // Soft-lock right away: the overlay renders while the app is
+ // backgrounded (no wallet content flashes on return) and the
+ // navigation tree is preserved for after the unlock.
+ await softLock();
+ }
+ })().catch((err) =>
+ logger.error(
+ "handleAppStateChange",
+ "Error handling background auto-lock",
+ err,
+ ),
+ );
+ }
+
+ // When returning to active state, resolve the auto-lock decision and
+ // then dismiss the native privacy shield. Running getAuthStatus right
+ // away (instead of only the delayed check below) lets a soft-lock
+ // overlay mount before the shield lifts, so the unlocked wallet never
+ // flashes; the brief delay gives that overlay a frame to paint.
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
+ useAuthenticationStore
+ .getState()
+ .getAuthStatus()
+ .catch((err) =>
+ logger.error(
+ "handleAppStateChange",
+ "Error checking auth on foreground",
+ err,
+ ),
+ )
+ .finally(() => {
+ setTimeout(() => {
+ // If re-backgrounded before this resolved, the native side
+ // re-showed the shield — don't lift it, or the next resume flashes
+ // the unlocked tree.
+ if (AppState.currentState === "active") {
+ hidePrivacyShield();
+ }
+ }, SHIELD_REVEAL_DELAY);
+ });
+
setTimeout(() => {
checkAuth().catch((err) =>
logger.error("handleAppStateChange", "Error checking auth", err),
@@ -130,6 +282,49 @@ const useAuthCheck = () => {
};
}, [setupCheckInterval, checkAuth]);
+ /**
+ * Reset the idle clock whenever the wallet becomes unlocked. The user is
+ * actively present at unlock, but the lock screen / overlay sits outside
+ * this provider's PanResponder, so its touches don't update
+ * lastInteractionRef — without this the idle timer would still hold its
+ * pre-lock (often already-elapsed) value and immediately re-lock.
+ */
+ useEffect(() => {
+ if (authStatus === AUTH_STATUS.AUTHENTICATED) {
+ recordInteraction();
+ }
+ }, [authStatus, recordInteraction]);
+
+ /**
+ * Treat navigation as user activity. Some controls (gesture-handler-based
+ * buttons, swipeables, bottom sheets) bypass the JS responder system and
+ * never reach the PanResponder below, so a route change is often the only
+ * reliable signal that the user acted — without this a user could move
+ * through several screens and still be idle-locked mid-flow.
+ */
+ useEffect(() => {
+ const unsubscribe = navigationRef.addListener("state", recordInteraction);
+ return unsubscribe;
+ }, [recordInteraction]);
+
+ /**
+ * Keyboard and gesture-handler input don't reach the PanResponder, so count
+ * keyboard show/hide as interaction (otherwise a user entering an amount
+ * could be idle-locked mid-input) and expose recordInteraction via the
+ * userActivity bridge for components outside this tree (e.g. amount inputs).
+ */
+ useEffect(() => {
+ const showSub = Keyboard.addListener("keyboardDidShow", recordInteraction);
+ const hideSub = Keyboard.addListener("keyboardDidHide", recordInteraction);
+ setActivityRecorder(recordInteraction);
+
+ return () => {
+ showSub.remove();
+ hideSub.remove();
+ setActivityRecorder(null);
+ };
+ }, [recordInteraction]);
+
/**
* Monitor user interaction and update active status accordingly.
*/
@@ -146,30 +341,13 @@ const useAuthCheck = () => {
}, []);
/**
- * Initialize PanResponder to capture touch interactions and update the last interaction timestamp.
+ * Perform an initial auth check after a short delay to avoid navigation race
+ * conditions during setup.
*/
useEffect(() => {
- const updateLastInteraction = () => {
- lastInteractionRef.current = Date.now();
- setIsActive(true);
- };
-
- panResponderRef.current = PanResponder.create({
- onStartShouldSetPanResponder: () => {
- updateLastInteraction();
- return false;
- },
- onMoveShouldSetPanResponder: () => {
- updateLastInteraction();
- return false;
- },
- onPanResponderTerminationRequest: () => true,
- });
-
- // Perform an initial auth check after a short delay to avoid navigation race conditions
const initialCheckTimeout = setTimeout(() => {
checkAuth().catch((err) =>
- logger.error("initPanResponder", "Error checking auth", err),
+ logger.error("initialCheck", "Error checking auth", err),
);
}, INITIAL_SETUP_DELAY);
@@ -189,7 +367,7 @@ const useAuthCheck = () => {
checkAuthNow,
isActive,
authStatus,
- panHandlers: panResponderRef.current?.panHandlers,
+ panHandlers: panResponder.panHandlers,
};
};
diff --git a/src/hooks/useGetActiveAccount.ts b/src/hooks/useGetActiveAccount.ts
index bc9b24dae..b6a88154a 100644
--- a/src/hooks/useGetActiveAccount.ts
+++ b/src/hooks/useGetActiveAccount.ts
@@ -1,6 +1,7 @@
import { FeeBumpTransaction, Keypair, Transaction } from "@stellar/stellar-sdk";
import { navigationRef } from "components/App";
import { logger } from "config/logger";
+import { AUTH_STATUS } from "config/types";
import { useAuthenticationStore } from "ducks/auth";
import {
signMessage as signMessageHelper,
@@ -9,6 +10,18 @@ import {
import { useCallback, useEffect } from "react";
import { analytics } from "services/analytics";
+/**
+ * Defense-in-depth: signing must only ever happen while the wallet is fully
+ * unlocked. The soft-lock overlay blocks the UI, but the navigation tree
+ * (and these signing callbacks) stay mounted underneath, so guard the key
+ * material directly — never rely on the overlay alone. Read live from the
+ * store so a lock that engaged after the callback was created is respected.
+ */
+export const isWalletUnlocked = (): boolean => {
+ const { authStatus, isSoftLocked } = useAuthenticationStore.getState();
+ return authStatus === AUTH_STATUS.AUTHENTICATED && !isSoftLocked;
+};
+
/**
* Hook that provides access to the active account with loading state
* Uses the auth store to manage the active account state
@@ -43,7 +56,7 @@ const useGetActiveAccount = () => {
const signTransaction = useCallback(
(transaction: Transaction | FeeBumpTransaction): string | null => {
- if (!account) return null;
+ if (!account || !isWalletUnlocked()) return null;
const keyPair = Keypair.fromSecret(account.privateKey);
@@ -56,7 +69,7 @@ const useGetActiveAccount = () => {
const signMessage = useCallback(
(message: string): string | null => {
- if (!account) return null;
+ if (!account || !isWalletUnlocked()) return null;
try {
return signMessageHelper(message, account.privateKey);
@@ -73,7 +86,7 @@ const useGetActiveAccount = () => {
(
preimageXdr: string,
): { signedAuthEntry: string; signerAddress: string } | null => {
- if (!account) return null;
+ if (!account || !isWalletUnlocked()) return null;
try {
return signAuthEntryHelper(preimageXdr, account.privateKey);
diff --git a/src/hooks/useManageTokens.ts b/src/hooks/useManageTokens.ts
index 5993476e8..a2d7ad262 100644
--- a/src/hooks/useManageTokens.ts
+++ b/src/hooks/useManageTokens.ts
@@ -15,6 +15,7 @@ import {
import { ActiveAccount } from "ducks/auth";
import { formatTokenIdentifier } from "helpers/balances";
import useAppTranslation from "hooks/useAppTranslation";
+import { isWalletUnlocked } from "hooks/useGetActiveAccount";
import { ToastOptions, useToast } from "providers/ToastProvider";
import { useState } from "react";
import { analytics } from "services/analytics";
@@ -25,6 +26,8 @@ import {
} from "services/stellar";
import { dataStorage } from "services/storage/storageFactory";
+const WALLET_LOCKED_ERROR = "Wallet is locked";
+
interface UseManageTokensProps {
network: NETWORKS;
account: ActiveAccount | null;
@@ -165,6 +168,12 @@ export const useManageTokens = ({
publicKey,
});
+ // Re-check auth after the async build: an auto-lock may have engaged
+ // mid-await, and this path signs with a captured privateKey
+ if (!isWalletUnlocked()) {
+ throw new Error(WALLET_LOCKED_ERROR);
+ }
+
const signedTx = signTransaction({
tx: addTokenTrustlineTx,
secretKey: privateKey,
@@ -287,6 +296,12 @@ export const useManageTokens = ({
isRemove: true,
});
+ // Re-check auth after the async build: an auto-lock may have engaged
+ // mid-await, and this path signs with a captured privateKey
+ if (!isWalletUnlocked()) {
+ throw new Error(WALLET_LOCKED_ERROR);
+ }
+
const signedTx = signTransaction({
tx: removeTokenTrustlineTx,
secretKey: privateKey,
diff --git a/src/hooks/useTokenFiatConverter/index.ts b/src/hooks/useTokenFiatConverter/index.ts
index 62cd21607..8695b93f3 100644
--- a/src/hooks/useTokenFiatConverter/index.ts
+++ b/src/hooks/useTokenFiatConverter/index.ts
@@ -3,6 +3,7 @@ import { CLASSIC_TOKEN_MAX_AMOUNT, DEFAULT_DECIMALS } from "config/constants";
import { PricedBalance } from "config/types";
import { hasDecimals } from "helpers/balances";
import { formatBigNumberForDisplay } from "helpers/formatAmount";
+import { recordUserActivity } from "helpers/userActivity";
import {
createTokenFiatConverterReducer,
initialState,
@@ -269,6 +270,10 @@ export const useTokenFiatConverter = ({
}, []);
const setDisplayAmountFromText = useCallback((text: string) => {
+ // System-keyboard keystrokes don't reach the app-wide PanResponder, so
+ // record activity here to keep the foreground-idle auto-lock from firing
+ // while the user is actively entering a send/swap amount.
+ recordUserActivity();
dispatch({
type: TokenFiatConverterActionType.SET_DISPLAY_AMOUNT_FROM_TEXT,
payload: { text },
diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index a3a3ce6b8..80f6d0c27 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -963,6 +963,7 @@
"securityScreen": {
"title": "Security",
"showRecoveryPhrase": "Show recovery phrase",
+ "autoLock": "Auto-lock",
"faceId": {
"title": "Face ID authentication",
"description": "Enable Face ID for an added layer of security. Use biometric authentication to unlock your wallet and approve transactions quickly and securely.",
@@ -1006,6 +1007,20 @@
"disableAlertMessage": "Are you sure you want to disable Iris?"
}
},
+ "autoLockTimerScreen": {
+ "title": "Auto-Lock Timer",
+ "options": {
+ "immediately": "Immediately",
+ "oneMinute": "1 minute",
+ "fifteenMinutes": "15 minutes",
+ "thirtyMinutes": "30 minutes",
+ "oneHour": "1 hour",
+ "twelveHours": "12 hours",
+ "twentyFourHours": "24 hours",
+ "none": "None"
+ },
+ "footer": "After a set time, you will be prompted for your password again as an extra security measure."
+ },
"showRecoveryPhraseScreen": {
"title": "Show Recovery Phrase",
"keepSafe": "Keep your recovery phrase in a safe and secure place.",
diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json
index dc2a3a518..303c4d7e4 100644
--- a/src/i18n/locales/pt/translations.json
+++ b/src/i18n/locales/pt/translations.json
@@ -963,6 +963,7 @@
"securityScreen": {
"title": "Segurança",
"showRecoveryPhrase": "Mostrar frase de recuperação",
+ "autoLock": "Bloqueio automático",
"faceId": {
"title": "Autenticação Face ID",
"description": "Habilite a autenticação Face ID para uma camada adicional de segurança. Use a autenticação biométrica para desbloquear sua carteira e aprovar transações rapidamente e com segurança.",
@@ -1006,6 +1007,20 @@
"disableAlertMessage": "Tem certeza que deseja desabilitar Iris?"
}
},
+ "autoLockTimerScreen": {
+ "title": "Temporizador de Bloqueio Automático",
+ "options": {
+ "immediately": "Imediatamente",
+ "oneMinute": "1 minuto",
+ "fifteenMinutes": "15 minutos",
+ "thirtyMinutes": "30 minutos",
+ "oneHour": "1 hora",
+ "twelveHours": "12 horas",
+ "twentyFourHours": "24 horas",
+ "none": "Nenhum"
+ },
+ "footer": "Após um período definido, sua senha será solicitada novamente como medida extra de segurança."
+ },
"showRecoveryPhraseScreen": {
"title": "Mostrar Frase de Recuperação",
"keepSafe": "Mantenha sua frase de recuperação em um local seguro.",
diff --git a/src/navigators/RootNavigator.tsx b/src/navigators/RootNavigator.tsx
index 732a8e812..d9fd354ee 100644
--- a/src/navigators/RootNavigator.tsx
+++ b/src/navigators/RootNavigator.tsx
@@ -31,6 +31,7 @@ import {
} from "config/routes";
import { AUTH_STATUS } from "config/types";
import { useAuthenticationStore } from "ducks/auth";
+import { usePreferencesStore } from "ducks/preferences";
import { useRemoteConfigStore } from "ducks/remoteConfig";
import { isDeviceJailbroken } from "helpers/deviceSecurity";
import {
@@ -41,7 +42,6 @@ import {
} from "helpers/navigationOptions";
import { triggerFaceIdOnboarding } from "helpers/postOnboardingBiometrics";
import { useAnalyticsPermissions } from "hooks/useAnalyticsPermissions";
-import { useAppOpenBiometricsLogin } from "hooks/useAppOpenBiometricsLogin";
import useAppTranslation from "hooks/useAppTranslation";
import { useAppUpdate } from "hooks/useAppUpdate";
import { useBiometrics } from "hooks/useBiometrics";
@@ -57,9 +57,14 @@ import {
} from "navigators";
import { TabNavigator } from "navigators/TabNavigator";
import React, { useEffect, useMemo, useState } from "react";
+import { StyleSheet, View } from "react-native";
import RNBootSplash from "react-native-bootsplash";
import { isInitialized as isAnalyticsInitialized } from "services/analytics/core";
+const styles = StyleSheet.create({
+ flex: { flex: 1 },
+});
+
const RootStack = createNativeStackNavigator<
RootStackParamList &
ManageTokensStackParamList &
@@ -75,7 +80,7 @@ export const RootNavigator = () => {
useNavigation<
NativeStackNavigationProp
>();
- const { authStatus, getAuthStatus, initializeNetwork } =
+ const { authStatus, isSoftLocked, getAuthStatus, initializeNetwork } =
useAuthenticationStore();
const remoteConfigInitialized = useRemoteConfigStore(
(state) => state.isInitialized,
@@ -94,8 +99,6 @@ export const RootNavigator = () => {
previousState: initializing ? undefined : "none",
});
- useAppOpenBiometricsLogin(initializing);
-
// Run once on mount: check jailbreak, fetch auth status from storage, trigger
// face-id onboarding if needed. We intentionally omit deps so this only fires
// once — subsequent auth status changes are handled by the RootStack's
@@ -115,6 +118,10 @@ export const RootNavigator = () => {
// so components always read the correct network from the start.
await initializeNetwork();
+ // Hydrate the auto-lock timer from its secure-storage source of truth
+ // (it's intentionally not kept in the unencrypted preferences store).
+ await usePreferencesStore.getState().hydrateAutoLockTimer();
+
// Fetch the real auth status from storage and overwrite the initial
// NOT_AUTHENTICATED default before any navigation decision is made.
const freshAuthStatus = await getAuthStatus();
@@ -152,9 +159,14 @@ export const RootNavigator = () => {
}
}, [showFullScreenUpdateNotice]);
+ // Soft lock keeps the authenticated tree mounted (covered by the lock
+ // overlay) so navigation history and in-progress inputs survive the lock.
+ const showAuthenticatedStack =
+ authStatus === AUTH_STATUS.AUTHENTICATED || isSoftLocked;
+
// Make the stack re-render when auth status changes
const initialRouteName = useMemo(() => {
- if (authStatus === AUTH_STATUS.AUTHENTICATED) {
+ if (showAuthenticatedStack) {
return ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK;
}
@@ -166,7 +178,7 @@ export const RootNavigator = () => {
}
return ROOT_NAVIGATOR_ROUTES.AUTH_STACK;
- }, [authStatus]);
+ }, [authStatus, showAuthenticatedStack]);
if (isJailbroken) {
return ;
@@ -194,112 +206,124 @@ export const RootNavigator = () => {
}
return (
-
- {authStatus === AUTH_STATUS.AUTHENTICATED ? (
-
-
-
-
-
-
-
-
- withTransitionOverride(
- getScreenBottomNavigateOptions(t("accountQRCodeScreen.title")),
- route,
- )
- }
- />
-
- withTransitionOverride(getScreenOptionsNoHeader(), route)
- }
- />
-
-
-
-
-
+
+ {showAuthenticatedStack ? (
+
+
+
+
+
+
+
+
+ withTransitionOverride(
+ getScreenBottomNavigateOptions(
+ t("accountQRCodeScreen.title"),
+ ),
+ route,
+ )
+ }
+ />
+
+ withTransitionOverride(getScreenOptionsNoHeader(), route)
+ }
+ />
+
+
+
+
+
+
+
+
+ ) : authStatus === AUTH_STATUS.HASH_KEY_EXPIRED ||
+ authStatus === AUTH_STATUS.LOCKED ? (
+ ) : (
-
- ) : authStatus === AUTH_STATUS.HASH_KEY_EXPIRED ||
- authStatus === AUTH_STATUS.LOCKED ? (
-
- ) : (
-
- )}
-
+ )}
+
+
);
};
diff --git a/src/navigators/SettingsNavigator.tsx b/src/navigators/SettingsNavigator.tsx
index 9707e884d..038bf3ae4 100644
--- a/src/navigators/SettingsNavigator.tsx
+++ b/src/navigators/SettingsNavigator.tsx
@@ -7,6 +7,7 @@ import SettingsScreen from "components/screens/SettingsScreen";
import AboutScreen from "components/screens/SettingsScreen/AboutScreen";
import PreferencesScreen from "components/screens/SettingsScreen/PreferencesScreen";
import SecurityScreen from "components/screens/SettingsScreen/SecurityScreen";
+import AutoLockTimerScreen from "components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen";
import BiometricsSettingsScreen from "components/screens/SettingsScreen/SecurityScreen/BiometricsSettingsScreen";
import ShowRecoveryPhraseScreen from "components/screens/SettingsScreen/SecurityScreen/ShowRecoveryPhraseScreen";
import YourRecoveryPhraseScreen from "components/screens/SettingsScreen/SecurityScreen/YourRecoveryPhraseScreen";
@@ -110,6 +111,13 @@ export const SettingsStackNavigator = () => {
headerTitle: biometryTitle[biometryType!],
}}
/>
+
);
};
diff --git a/src/providers/ToastProvider.tsx b/src/providers/ToastProvider.tsx
index c1719fa38..1e037e55a 100644
--- a/src/providers/ToastProvider.tsx
+++ b/src/providers/ToastProvider.tsx
@@ -1,5 +1,6 @@
import { Toast, ToastProps, ToastVariant } from "components/sds/Toast";
-import { DEFAULT_PADDING } from "config/constants";
+import { DEFAULT_PADDING, SOFT_LOCK_ALLOWED_TOAST_IDS } from "config/constants";
+import { useAuthenticationStore } from "ducks/auth";
import { px, pxValue } from "helpers/dimensions";
import React, {
createContext,
@@ -103,6 +104,24 @@ export const ToastProvider: React.FC = ({ children }) => {
const insets = useSafeAreaInsets();
const showToast = useCallback((options: ToastOptions) => {
+ // While soft-locked, the navigation tree stays mounted under the lock
+ // overlay and may still emit toasts (stale network errors, re-run
+ // validations). Suppress everything except the lock overlay's own
+ // messaging so nothing surfaces over the lock until the app is usable.
+ // Read imperatively (not via a reactive selector) to avoid coupling this
+ // low-level provider's render cycle to the auth store; the optional chain
+ // keeps it safe under test mocks that stub the store without getState.
+ const isSoftLocked =
+ useAuthenticationStore.getState?.()?.isSoftLocked ?? false;
+ if (
+ isSoftLocked &&
+ !(
+ options.toastId && SOFT_LOCK_ALLOWED_TOAST_IDS.includes(options.toastId)
+ )
+ ) {
+ return;
+ }
+
const newToast: ToastPropsWithId = {
...options,
toastId: options.toastId ?? Date.now().toString(),
diff --git a/src/services/autoLock.ts b/src/services/autoLock.ts
new file mode 100644
index 000000000..366abc67b
--- /dev/null
+++ b/src/services/autoLock.ts
@@ -0,0 +1,153 @@
+import {
+ AUTO_LOCK_TIMER,
+ DEFAULT_AUTO_LOCK_TIMER,
+ HASH_KEY_EXPIRATION_MS,
+ NEVER_EXPIRE_HASH_KEY_MS,
+ SENSITIVE_STORAGE_KEYS,
+} from "config/constants";
+import { getHashKey } from "services/storage/helpers";
+import { secureDataStorage } from "services/storage/storageFactory";
+
+/**
+ * Reads the persisted auto-lock timer preference.
+ *
+ * This is a secure-storage mirror of the zustand preferences store so that
+ * non-React code (e.g. getAuthStatus on cold start) can read the value
+ * without depending on zustand-persist rehydration timing — and so the
+ * lock policy can't be weakened by editing unencrypted AsyncStorage.
+ */
+const getAutoLockTimer = async (): Promise => {
+ const storedTimer = await secureDataStorage.getItem(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING,
+ );
+
+ if (
+ storedTimer &&
+ Object.values(AUTO_LOCK_TIMER).includes(storedTimer as AUTO_LOCK_TIMER)
+ ) {
+ return storedTimer as AUTO_LOCK_TIMER;
+ }
+
+ return DEFAULT_AUTO_LOCK_TIMER;
+};
+
+/**
+ * Persists the auto-lock timer preference to the secure-storage mirror
+ */
+const persistAutoLockTimer = async (timer: AUTO_LOCK_TIMER): Promise => {
+ await secureDataStorage.setItem(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_TIMER_SETTING,
+ timer,
+ );
+};
+
+/**
+ * Returns the hash key TTL for the given auto-lock timer. With NONE the user
+ * explicitly opted out of auto-lock, so the 24h hard-expiry backstop is
+ * replaced by an effectively-never expiration.
+ */
+const getHashKeyExpirationMs = (timer: AUTO_LOCK_TIMER): number =>
+ timer === AUTO_LOCK_TIMER.NONE
+ ? NEVER_EXPIRE_HASH_KEY_MS
+ : HASH_KEY_EXPIRATION_MS;
+
+/**
+ * Re-anchors the stored hash key expiration based on the given auto-lock
+ * timer. Called when the user changes the timer so switching to/from NONE
+ * takes effect immediately rather than on the next unlock. Only reachable
+ * from an unlocked session (the settings screen), so this is a
+ * credential-verified moment.
+ */
+const applyAutoLockTimerToHashKey = async (
+ timer: AUTO_LOCK_TIMER,
+): Promise => {
+ const hashKey = await getHashKey();
+
+ if (!hashKey) {
+ return;
+ }
+
+ await secureDataStorage.setItem(
+ SENSITIVE_STORAGE_KEYS.HASH_KEY,
+ JSON.stringify({
+ ...hashKey,
+ expiresAt: Date.now() + getHashKeyExpirationMs(timer),
+ }),
+ );
+};
+
+/**
+ * Records the moment the app went to the background so the auto-lock timer
+ * can be evaluated on the next foreground or cold start
+ */
+const recordBackgroundedAt = async (): Promise => {
+ await secureDataStorage.setItem(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
+ String(Date.now()),
+ );
+};
+
+/**
+ * Clears the persisted backgrounded-at timestamp
+ */
+const clearBackgroundedAt = async (): Promise => {
+ await secureDataStorage.remove(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
+ );
+};
+
+/**
+ * Returns the persisted backgrounded-at timestamp, or null when none exists.
+ * A corrupt (non-numeric) value is cleared and treated as absent. A
+ * future-dated value (clock moved backward) returns 0 so any positive timer
+ * elapses and the wallet locks, rather than trusting it to skip the lock.
+ */
+const getBackgroundedAt = async (): Promise => {
+ const backgroundedAt = await secureDataStorage.getItem(
+ SENSITIVE_STORAGE_KEYS.AUTO_LOCK_BACKGROUNDED_AT,
+ );
+
+ if (!backgroundedAt) {
+ return null;
+ }
+
+ const parsedBackgroundedAt = Number(backgroundedAt);
+
+ if (Number.isNaN(parsedBackgroundedAt)) {
+ await clearBackgroundedAt();
+ return null;
+ }
+
+ if (parsedBackgroundedAt > Date.now()) {
+ return 0;
+ }
+
+ return parsedBackgroundedAt;
+};
+
+/**
+ * Whether an unlockable session is persisted on device (a hash key and a
+ * temporary store both exist). Lets the background handler decide whether to
+ * record/lock from disk state rather than the zustand auth status, which may
+ * not be hydrated yet — a cold launch into an existing session can be
+ * backgrounded before getAuthStatus runs.
+ */
+const hasPersistedSession = async (): Promise => {
+ const [hashKey, temporaryStore] = await Promise.all([
+ getHashKey(),
+ secureDataStorage.getItem(SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE),
+ ]);
+
+ return Boolean(hashKey && temporaryStore);
+};
+
+export {
+ getAutoLockTimer,
+ persistAutoLockTimer,
+ getHashKeyExpirationMs,
+ applyAutoLockTimerToHashKey,
+ recordBackgroundedAt,
+ getBackgroundedAt,
+ clearBackgroundedAt,
+ hasPersistedSession,
+};