Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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