Skip to content

Commit a86091f

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 a86091f

File tree

3 files changed

+144
-2
lines changed

3 files changed

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

app/views/ScreenLockConfigView.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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, 300));
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: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,14 @@ const SecurityPrivacyView = ({ navigation }: ISecurityPrivacyViewProps): JSX.Ele
5959

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

0 commit comments

Comments
 (0)