diff --git a/app/lib/constants/localAuthentication.ts b/app/lib/constants/localAuthentication.ts index bd74997a3c..d8c719a7b4 100644 --- a/app/lib/constants/localAuthentication.ts +++ b/app/lib/constants/localAuthentication.ts @@ -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; diff --git a/app/views/ScreenLockConfigView.test.tsx b/app/views/ScreenLockConfigView.test.tsx new file mode 100644 index 0000000000..45cdc86740 --- /dev/null +++ b/app/views/ScreenLockConfigView.test.tsx @@ -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 }); + }); +}); + diff --git a/app/views/ScreenLockConfigView.tsx b/app/views/ScreenLockConfigView.tsx index 31011614b8..552ca42d4a 100644 --- a/app/views/ScreenLockConfigView.tsx +++ b/app/views/ScreenLockConfigView.tsx @@ -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'; @@ -132,7 +132,15 @@ class ScreenLockConfigView extends React.Component { 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 }); @@ -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)); diff --git a/app/views/SecurityPrivacyView.tsx b/app/views/SecurityPrivacyView.tsx index d87ac03a26..2a33f0ce7f 100644 --- a/app/views/SecurityPrivacyView.tsx +++ b/app/views/SecurityPrivacyView.tsx @@ -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'; @@ -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'); };