Skip to content

Commit f23c341

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 f23c341

File tree

4 files changed

+148
-3
lines changed

4 files changed

+148
-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: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import * as localAuthentication from '../lib/methods/helpers/localAuthentication';
2+
import { MODAL_TRANSITION_DELAY_MS } from '../lib/constants/localAuthentication';
3+
4+
// Mock the localAuthentication module
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+
describe('ScreenLockConfigView - Passcode Change with Modal Delay', () => {
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
jest.useFakeTimers();
16+
});
17+
18+
afterEach(() => {
19+
jest.useRealTimers();
20+
});
21+
22+
it('should add 300ms delay between authentication and passcode change', async () => {
23+
const handleLocalAuthenticationMock = localAuthentication.handleLocalAuthentication as jest.Mock;
24+
const changePasscodeMock = localAuthentication.changePasscode as jest.Mock;
25+
26+
handleLocalAuthenticationMock.mockResolvedValue(undefined);
27+
changePasscodeMock.mockResolvedValue(undefined);
28+
29+
// Simulate the flow: authentication -> delay -> passcode change
30+
const simulatePasscodeChange = async (autoLock: boolean) => {
31+
if (autoLock) {
32+
try {
33+
await handleLocalAuthenticationMock(true);
34+
// Add delay using the same constant as the component
35+
await new Promise(resolve => setTimeout(resolve, MODAL_TRANSITION_DELAY_MS));
36+
} catch {
37+
return;
38+
}
39+
}
40+
await changePasscodeMock({ force: false });
41+
};
42+
43+
const promise = simulatePasscodeChange(true);
44+
45+
// Verify handleLocalAuthentication was called
46+
await Promise.resolve(); // Flush microtasks
47+
expect(handleLocalAuthenticationMock).toHaveBeenCalledWith(true);
48+
49+
// Fast-forward timers to simulate the 300ms delay
50+
jest.runAllTimers();
51+
52+
await promise;
53+
54+
// Verify changePasscode was called after the delay
55+
expect(changePasscodeMock).toHaveBeenCalledWith({ force: false });
56+
});
57+
58+
it('should handle authentication cancellation gracefully', async () => {
59+
const handleLocalAuthenticationMock = localAuthentication.handleLocalAuthentication as jest.Mock;
60+
const changePasscodeMock = localAuthentication.changePasscode as jest.Mock;
61+
62+
// Simulate user canceling authentication
63+
handleLocalAuthenticationMock.mockRejectedValue(new Error('Authentication cancelled'));
64+
65+
// Simulate the flow with error handling
66+
const simulatePasscodeChange = async (autoLock: boolean) => {
67+
if (autoLock) {
68+
try {
69+
await handleLocalAuthenticationMock(true);
70+
await new Promise(resolve => setTimeout(resolve, MODAL_TRANSITION_DELAY_MS));
71+
} catch {
72+
// User cancelled - should return early
73+
return;
74+
}
75+
}
76+
await changePasscodeMock({ force: false });
77+
};
78+
79+
await simulatePasscodeChange(true);
80+
81+
// Verify handleLocalAuthentication was called
82+
expect(handleLocalAuthenticationMock).toHaveBeenCalledWith(true);
83+
84+
// Verify that changePasscode was NOT called when authentication fails
85+
expect(changePasscodeMock).not.toHaveBeenCalled();
86+
});
87+
88+
it('should proceed directly to passcode change when autoLock is disabled', async () => {
89+
const handleLocalAuthenticationMock = localAuthentication.handleLocalAuthentication as jest.Mock;
90+
const changePasscodeMock = localAuthentication.changePasscode as jest.Mock;
91+
92+
changePasscodeMock.mockResolvedValue(undefined);
93+
94+
// Simulate the flow without authentication
95+
const simulatePasscodeChange = async (autoLock: boolean) => {
96+
if (autoLock) {
97+
try {
98+
await handleLocalAuthenticationMock(true);
99+
await new Promise(resolve => setTimeout(resolve, MODAL_TRANSITION_DELAY_MS));
100+
} catch {
101+
return;
102+
}
103+
}
104+
await changePasscodeMock({ force: false });
105+
};
106+
107+
await simulatePasscodeChange(false);
108+
109+
// Verify handleLocalAuthentication was NOT called
110+
expect(handleLocalAuthenticationMock).not.toHaveBeenCalled();
111+
112+
// Verify changePasscode was called directly
113+
expect(changePasscodeMock).toHaveBeenCalledWith({ force: false });
114+
});
115+
116+
it('should use correct delay duration from constant', () => {
117+
// Create a promise that resolves after the delay
118+
const delayPromise = new Promise(resolve => setTimeout(resolve, MODAL_TRANSITION_DELAY_MS));
119+
120+
// Advance timers by the constant value
121+
jest.advanceTimersByTime(MODAL_TRANSITION_DELAY_MS);
122+
123+
// The promise should resolve
124+
return expect(delayPromise).resolves.toBeUndefined();
125+
});
126+
});

app/views/ScreenLockConfigView.tsx

Lines changed: 10 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 });

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)