Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/lib/constants/localAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ export const MAX_ATTEMPTS = 6;
export const TIME_TO_LOCK = 30000;

export const DEFAULT_AUTO_LOCK = 1800;

// Delay between modal transitions to prevent iOS UI thread hang
export const MODAL_TRANSITION_DELAY_MS = 300;
85 changes: 85 additions & 0 deletions app/views/ScreenLockConfigView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as localAuthentication from '../lib/methods/helpers/localAuthentication';
import { ScreenLockConfigView } from './ScreenLockConfigView';

// Mock localAuthentication helpers used by the component
jest.mock('../lib/methods/helpers/localAuthentication', () => ({
handleLocalAuthentication: jest.fn(),
changePasscode: jest.fn(),
checkHasPasscode: jest.fn(),
supportedBiometryLabel: jest.fn()
}));

// Mock the database to avoid hitting real DB in constructor.init()
jest.mock('../lib/database', () => ({
servers: {
get: jest.fn(() => ({
find: jest.fn(() => ({ autoLock: true, autoLockTime: null }))
}))
}
}));

// Mock i18n to avoid initialization side-effects in tests
jest.mock('../i18n', () => ({
__esModule: true,
default: { t: jest.fn((k: string) => k) }
}));

describe('ScreenLockConfigView - integration tests', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('should add 300ms delay between authentication and passcode change (real component)', async () => {
const handleLocalAuthenticationMock = (localAuthentication.handleLocalAuthentication as jest.Mock).mockResolvedValue(undefined);
const changePasscodeMock = (localAuthentication.changePasscode as jest.Mock).mockResolvedValue(undefined);

// Create a mock instance of the component
const mockInstance: any = new (ScreenLockConfigView as any)({ theme: 'light', server: '', Force_Screen_Lock: false, Force_Screen_Lock_After: 0 });

// Set the state we want for this test
mockInstance.state = { autoLock: true };

const promise = mockInstance.changePasscode({ force: false });

// microtask flush so the handleLocalAuthentication call happens
await Promise.resolve();
expect(handleLocalAuthenticationMock).toHaveBeenCalledWith(true);

// changePasscode should NOT be called before the delay
expect(changePasscodeMock).not.toHaveBeenCalled();

// advance timers and flush all promises
jest.runAllTimers();
await promise;

expect(changePasscodeMock).toHaveBeenCalledWith({ force: false });
});

it('should return early when authentication is cancelled (real component)', async () => {
const handleLocalAuthenticationMock = (localAuthentication.handleLocalAuthentication as jest.Mock).mockRejectedValue(new Error('cancel'));
const changePasscodeMock = (localAuthentication.changePasscode as jest.Mock).mockResolvedValue(undefined);

const mockInstance: any = new (ScreenLockConfigView as any)({ theme: 'light', server: '', Force_Screen_Lock: false, Force_Screen_Lock_After: 0 });
mockInstance.state = { autoLock: true };

await mockInstance.changePasscode({ force: false }); expect(handleLocalAuthenticationMock).toHaveBeenCalledWith(true);
expect(changePasscodeMock).not.toHaveBeenCalled();
});

it('should proceed directly to passcode change when autoLock is disabled (real component)', async () => {
const handleLocalAuthenticationMock = (localAuthentication.handleLocalAuthentication as jest.Mock);
const changePasscodeMock = (localAuthentication.changePasscode as jest.Mock).mockResolvedValue(undefined);

const mockInstance: any = new (ScreenLockConfigView as any)({ theme: 'light', server: '', Force_Screen_Lock: false, Force_Screen_Lock_After: 0 });
mockInstance.state = { autoLock: false };

await mockInstance.changePasscode({ force: false }); expect(handleLocalAuthenticationMock).not.toHaveBeenCalled();
expect(changePasscodeMock).toHaveBeenCalledWith({ force: false });
});
});

14 changes: 12 additions & 2 deletions app/views/ScreenLockConfigView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
supportedBiometryLabel,
handleLocalAuthentication
} from '../lib/methods/helpers/localAuthentication';
import { BIOMETRY_ENABLED_KEY, DEFAULT_AUTO_LOCK } from '../lib/constants/localAuthentication';
import { BIOMETRY_ENABLED_KEY, DEFAULT_AUTO_LOCK, MODAL_TRANSITION_DELAY_MS } from '../lib/constants/localAuthentication';
import { themes } from '../lib/constants/colors';
import SafeAreaView from '../containers/SafeAreaView';
import { events, logEvent } from '../lib/methods/helpers/log';
Expand Down Expand Up @@ -132,7 +132,15 @@ class ScreenLockConfigView extends React.Component<IScreenLockConfigViewProps, I
changePasscode = async ({ force }: { force: boolean }) => {
const { autoLock } = this.state;
if (autoLock) {
await handleLocalAuthentication(true);
try {
await handleLocalAuthentication(true);
// Add a small delay to ensure the first modal is fully closed before opening the next one
// This prevents the app from hanging on iOS when two modals open back-to-back
await new Promise(resolve => setTimeout(resolve, MODAL_TRANSITION_DELAY_MS));
} catch {
// User cancelled or authentication failed
return;
}
}
logEvent(events.SLC_CHANGE_PASSCODE);
await changePasscode({ force });
Expand Down Expand Up @@ -296,4 +304,6 @@ const mapStateToProps = (state: IApplicationState) => ({
Force_Screen_Lock_After: state.settings.Force_Screen_Lock_After as number
});

export { ScreenLockConfigView };

export default connect(mapStateToProps)(withTheme(ScreenLockConfigView));
10 changes: 9 additions & 1 deletion app/views/SecurityPrivacyView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as List from '../containers/List';
import SafeAreaView from '../containers/SafeAreaView';
import I18n from '../i18n';
import { ANALYTICS_EVENTS_KEY, CRASH_REPORT_KEY } from '../lib/constants/keys';
import { MODAL_TRANSITION_DELAY_MS } from '../lib/constants/localAuthentication';
import { useAppSelector } from '../lib/hooks/useAppSelector';
import useServer from '../lib/methods/useServer';
import { type SettingsStackParamList } from '../stacks/types';
Expand Down Expand Up @@ -59,7 +60,14 @@ const SecurityPrivacyView = ({ navigation }: ISecurityPrivacyViewProps): JSX.Ele

const navigateToScreenLockConfigView = async () => {
if (server?.autoLock) {
await handleLocalAuthentication(true);
try {
await handleLocalAuthentication(true);
// Add a small delay to prevent modal conflicts on iOS
await new Promise(resolve => setTimeout(resolve, MODAL_TRANSITION_DELAY_MS));
} catch {
// User cancelled or authentication failed
return;
}
}
navigateToScreen('ScreenLockConfigView');
};
Expand Down