Skip to content

Commit 4cb54a7

Browse files
committed
fix: App hanging when changing passcode on iOS
- Add 300ms delay between authentication and passcode change modals - Add try-catch to handle authentication cancellation gracefully - Prevents iOS UI thread hang when two modals open back-to-back Fixes #6313
1 parent 3532447 commit 4cb54a7

File tree

4 files changed

+109
-3
lines changed

4 files changed

+109
-3
lines changed

app/lib/constants/localAuthentication.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ export const MAX_ATTEMPTS = 6;
1111
export const TIME_TO_LOCK = 30000;
1212

1313
export const DEFAULT_AUTO_LOCK = 1800;
14+
15+
// Delay between modal transitions to prevent iOS UI thread hang
16+
export const MODAL_TRANSITION_DELAY_MS = 300;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import * as localAuthentication from '../lib/methods/helpers/localAuthentication';
2+
import { ScreenLockConfigView } from './ScreenLockConfigView';
3+
4+
// Mock localAuthentication helpers used by the component
5+
jest.mock('../lib/methods/helpers/localAuthentication', () => ({
6+
handleLocalAuthentication: jest.fn(),
7+
changePasscode: jest.fn(),
8+
checkHasPasscode: jest.fn(),
9+
supportedBiometryLabel: jest.fn()
10+
}));
11+
12+
// Mock the database to avoid hitting real DB in constructor.init()
13+
jest.mock('../lib/database', () => ({
14+
servers: {
15+
get: jest.fn(() => ({
16+
find: jest.fn(() => ({ autoLock: true, autoLockTime: null }))
17+
}))
18+
}
19+
}));
20+
21+
// Mock i18n to avoid initialization side-effects in tests
22+
jest.mock('../i18n', () => ({
23+
__esModule: true,
24+
default: { t: jest.fn((k: string) => k) }
25+
}));
26+
27+
describe('ScreenLockConfigView - integration tests', () => {
28+
beforeEach(() => {
29+
jest.clearAllMocks();
30+
jest.useFakeTimers();
31+
});
32+
33+
afterEach(() => {
34+
jest.useRealTimers();
35+
});
36+
37+
it('should add 300ms delay between authentication and passcode change (real component)', async () => {
38+
const handleLocalAuthenticationMock = (localAuthentication.handleLocalAuthentication as jest.Mock).mockResolvedValue(undefined);
39+
const changePasscodeMock = (localAuthentication.changePasscode as jest.Mock).mockResolvedValue(undefined);
40+
41+
// Create a mock instance of the component
42+
const mockInstance: any = new (ScreenLockConfigView as any)({ theme: 'light', server: '', Force_Screen_Lock: false, Force_Screen_Lock_After: 0 });
43+
44+
// Set the state we want for this test
45+
mockInstance.state = { autoLock: true };
46+
47+
const promise = mockInstance.changePasscode({ force: false });
48+
49+
// microtask flush so the handleLocalAuthentication call happens
50+
await Promise.resolve();
51+
expect(handleLocalAuthenticationMock).toHaveBeenCalledWith(true);
52+
53+
// changePasscode should NOT be called before the delay
54+
expect(changePasscodeMock).not.toHaveBeenCalled();
55+
56+
// advance timers and flush all promises
57+
jest.runAllTimers();
58+
await promise;
59+
60+
expect(changePasscodeMock).toHaveBeenCalledWith({ force: false });
61+
});
62+
63+
it('should return early when authentication is cancelled (real component)', async () => {
64+
const handleLocalAuthenticationMock = (localAuthentication.handleLocalAuthentication as jest.Mock).mockRejectedValue(new Error('cancel'));
65+
const changePasscodeMock = (localAuthentication.changePasscode as jest.Mock).mockResolvedValue(undefined);
66+
67+
const mockInstance: any = new (ScreenLockConfigView as any)({ theme: 'light', server: '', Force_Screen_Lock: false, Force_Screen_Lock_After: 0 });
68+
mockInstance.state = { autoLock: true };
69+
70+
await mockInstance.changePasscode({ force: false }); expect(handleLocalAuthenticationMock).toHaveBeenCalledWith(true);
71+
expect(changePasscodeMock).not.toHaveBeenCalled();
72+
});
73+
74+
it('should proceed directly to passcode change when autoLock is disabled (real component)', async () => {
75+
const handleLocalAuthenticationMock = (localAuthentication.handleLocalAuthentication as jest.Mock);
76+
const changePasscodeMock = (localAuthentication.changePasscode as jest.Mock).mockResolvedValue(undefined);
77+
78+
const mockInstance: any = new (ScreenLockConfigView as any)({ theme: 'light', server: '', Force_Screen_Lock: false, Force_Screen_Lock_After: 0 });
79+
mockInstance.state = { autoLock: false };
80+
81+
await mockInstance.changePasscode({ force: false }); expect(handleLocalAuthenticationMock).not.toHaveBeenCalled();
82+
expect(changePasscodeMock).toHaveBeenCalledWith({ force: false });
83+
});
84+
});
85+

app/views/ScreenLockConfigView.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
supportedBiometryLabel,
1313
handleLocalAuthentication
1414
} from '../lib/methods/helpers/localAuthentication';
15-
import { BIOMETRY_ENABLED_KEY, DEFAULT_AUTO_LOCK } from '../lib/constants/localAuthentication';
15+
import { BIOMETRY_ENABLED_KEY, DEFAULT_AUTO_LOCK, MODAL_TRANSITION_DELAY_MS } from '../lib/constants/localAuthentication';
1616
import { themes } from '../lib/constants/colors';
1717
import SafeAreaView from '../containers/SafeAreaView';
1818
import { events, logEvent } from '../lib/methods/helpers/log';
@@ -132,7 +132,15 @@ class ScreenLockConfigView extends React.Component<IScreenLockConfigViewProps, I
132132
changePasscode = async ({ force }: { force: boolean }) => {
133133
const { autoLock } = this.state;
134134
if (autoLock) {
135-
await handleLocalAuthentication(true);
135+
try {
136+
await handleLocalAuthentication(true);
137+
// Add a small delay to ensure the first modal is fully closed before opening the next one
138+
// This prevents the app from hanging on iOS when two modals open back-to-back
139+
await new Promise(resolve => setTimeout(resolve, MODAL_TRANSITION_DELAY_MS));
140+
} catch {
141+
// User cancelled or authentication failed
142+
return;
143+
}
136144
}
137145
logEvent(events.SLC_CHANGE_PASSCODE);
138146
await changePasscode({ force });
@@ -296,4 +304,6 @@ const mapStateToProps = (state: IApplicationState) => ({
296304
Force_Screen_Lock_After: state.settings.Force_Screen_Lock_After as number
297305
});
298306

307+
export { ScreenLockConfigView };
308+
299309
export default connect(mapStateToProps)(withTheme(ScreenLockConfigView));

app/views/SecurityPrivacyView.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as List from '../containers/List';
66
import SafeAreaView from '../containers/SafeAreaView';
77
import I18n from '../i18n';
88
import { ANALYTICS_EVENTS_KEY, CRASH_REPORT_KEY } from '../lib/constants/keys';
9+
import { MODAL_TRANSITION_DELAY_MS } from '../lib/constants/localAuthentication';
910
import { useAppSelector } from '../lib/hooks/useAppSelector';
1011
import useServer from '../lib/methods/useServer';
1112
import { type SettingsStackParamList } from '../stacks/types';
@@ -59,7 +60,14 @@ const SecurityPrivacyView = ({ navigation }: ISecurityPrivacyViewProps): JSX.Ele
5960

6061
const navigateToScreenLockConfigView = async () => {
6162
if (server?.autoLock) {
62-
await handleLocalAuthentication(true);
63+
try {
64+
await handleLocalAuthentication(true);
65+
// Add a small delay to prevent modal conflicts on iOS
66+
await new Promise(resolve => setTimeout(resolve, MODAL_TRANSITION_DELAY_MS));
67+
} catch {
68+
// User cancelled or authentication failed
69+
return;
70+
}
6371
}
6472
navigateToScreen('ScreenLockConfigView');
6573
};

0 commit comments

Comments
 (0)