Skip to content

Commit 41f924b

Browse files
fix(LWM): prevent retry button from redirecting to wallet page in receive flow
1 parent 5b01280 commit 41f924b

File tree

9 files changed

+174
-21
lines changed

9 files changed

+174
-21
lines changed

.changeset/ninety-bags-taste.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"live-mobile": minor
3+
---
4+
5+
prevent retry button from redirecting to wallet page in receive flow
Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import React from "react";
22
import { View } from "react-native";
33

4-
const ReanimatedSwipeable = ({ children, renderRightActions, ...props }) => {
5-
return <View {...props}>{children}</View>;
6-
};
4+
const ReanimatedSwipeable = React.forwardRef(({ children, renderRightActions, ...props }, ref) => {
5+
return (
6+
<View ref={ref} {...props}>
7+
{children}
8+
</View>
9+
);
10+
});
11+
12+
ReanimatedSwipeable.displayName = "ReanimatedSwipeable";
713

814
export default ReanimatedSwipeable;

apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export type AddAccountContextType = `${AddAccountContexts}`;
1111
type CommonParams = {
1212
context?: AddAccountContextType;
1313
onCloseNavigation?: () => void;
14+
// Number of navigators to pop when closing the flow (calculated at entry point)
15+
navigationDepth?: number;
1416
currency: CryptoOrTokenCurrency;
1517
sourceScreenName?: string;
1618
};

apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function ScanDeviceAccounts() {
6060
onModalHide,
6161
onPressAccount,
6262
quitFlow,
63-
restartSubscription,
63+
handleRetry,
6464
scannedAccounts,
6565
scanning,
6666
sections,
@@ -187,7 +187,7 @@ function ScanDeviceAccounts() {
187187
(!scanning && scannedAccounts.length === 0)
188188
}
189189
canDone={!scanning && cantCreateAccount && noImportableAccounts}
190-
onRetry={restartSubscription}
190+
onRetry={handleRetry}
191191
onStop={stopSubscription}
192192
onDone={quitFlow}
193193
onContinue={importAccounts}
@@ -204,7 +204,7 @@ function ScanDeviceAccounts() {
204204
<CancelButton containerStyle={styles.button} onPress={onCancel} />
205205
<RetryButton
206206
containerStyle={[styles.button, styles.buttonRight]}
207-
onPress={restartSubscription}
207+
onPress={handleRetry}
208208
/>
209209
</>
210210
}

apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/useScanDeviceAccountsViewModel.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export default function useScanDeviceAccountsViewModel({
5858
inline,
5959
returnToSwap,
6060
onCloseNavigation,
61+
navigationDepth,
6162
context,
6263
} = route.params || {};
6364

@@ -103,6 +104,7 @@ export default function useScanDeviceAccountsViewModel({
103104
},
104105
});
105106
}, [blacklistedTokenIds, currency, deviceId]);
107+
106108
const restartSubscription = useCallback(() => {
107109
setScanning(true);
108110
setScannedAccounts([]);
@@ -111,6 +113,18 @@ export default function useScanDeviceAccountsViewModel({
111113
setCancelled(false);
112114
startSubscription();
113115
}, [startSubscription]);
116+
117+
const handleRetry = useCallback(() => {
118+
// In inline flows (e.g., Receive), navigate back to device selection
119+
// to allow user to reconnect/unlock device instead of retrying on same screen
120+
if (inline && error) {
121+
// Simply go back to SelectDevice which is already in the stack
122+
navigation.goBack();
123+
return;
124+
}
125+
126+
restartSubscription();
127+
}, [inline, error, navigation, restartSubscription]);
114128
const stopSubscription = useCallback(
115129
(syncUI = true) => {
116130
if (scanSubscription.current) {
@@ -169,6 +183,16 @@ export default function useScanDeviceAccountsViewModel({
169183
},
170184
[selectedIds],
171185
);
186+
187+
// Close inline flow: drawer + navigation
188+
const closeInlineFlow = useCallback(() => {
189+
if (onCloseNavigation) {
190+
onCloseNavigation();
191+
}
192+
const parent = navigation.getParent<StackNavigatorNavigation<BaseNavigatorStackParamList>>();
193+
parent?.pop(navigationDepth ?? 2);
194+
}, [navigation, onCloseNavigation, navigationDepth]);
195+
172196
const importAccounts = useCallback(() => {
173197
const accountsToAdd = scannedAccounts.filter(a => selectedIds.includes(a.id));
174198
if (currency.id.includes("canton_network")) {
@@ -191,7 +215,8 @@ export default function useScanDeviceAccountsViewModel({
191215
const { onSuccess } = route.params;
192216

193217
if (inline) {
194-
navigation.goBack();
218+
closeInlineFlow();
219+
195220
if (onSuccess) {
196221
onSuccess({
197222
scannedAccounts,
@@ -227,6 +252,7 @@ export default function useScanDeviceAccountsViewModel({
227252
scannedAccounts,
228253
selectedIds,
229254
dispatch,
255+
closeInlineFlow,
230256
analyticsMetadata?.AccountsFound?.onContinue,
231257
analyticsMetadata?.AccountsFound?.onAccountsAdded,
232258
]);
@@ -235,11 +261,17 @@ export default function useScanDeviceAccountsViewModel({
235261
setError(null);
236262
setCancelled(true);
237263
}, []);
264+
238265
const onModalHide = useCallback(() => {
239266
if (cancelled) {
240-
navigation.getParent<StackNavigatorNavigation<BaseNavigatorStackParamList>>().pop();
267+
if (inline) {
268+
closeInlineFlow();
269+
} else {
270+
navigation.getParent<StackNavigatorNavigation<BaseNavigatorStackParamList>>()?.pop();
271+
}
241272
}
242-
}, [cancelled, navigation]);
273+
}, [cancelled, inline, closeInlineFlow, navigation]);
274+
243275
const viewAllCreatedAccounts = useCallback(() => setShowAllCreatedAccounts(true), []);
244276

245277
const onAccountNameChange = useCallback(
@@ -350,7 +382,7 @@ export default function useScanDeviceAccountsViewModel({
350382
onModalHide,
351383
onPressAccount,
352384
quitFlow,
353-
restartSubscription,
385+
handleRetry,
354386
scannedAccounts,
355387
scanning,
356388
sections: sanitizedSections,

apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/useSelectDeviceViewModel.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,20 @@ export default function useSelectDeviceViewModel(
4040
(meta: AppResult) => {
4141
setDevice(null);
4242

43-
const { inline } = route.params;
4443
const params = {
4544
...route.params,
4645
...meta,
4746
context,
4847
sourceScreenName: ScreenName.SelectDevice,
4948
};
50-
if (inline) {
51-
navigation.replace(NavigatorName.AddAccounts, {
52-
screen: ScreenName.ScanDeviceAccounts,
53-
params,
54-
});
55-
} else
56-
navigation.navigate(NavigatorName.AddAccounts, {
57-
screen: ScreenName.ScanDeviceAccounts,
58-
params,
59-
});
49+
50+
// Always use navigate instead of replace to keep SelectDevice in the stack.
51+
// This allows retry navigation when device errors occur (e.g., device locked).
52+
// Previously, inline flows used replace which prevented retry navigation.
53+
navigation.navigate(NavigatorName.AddAccounts, {
54+
screen: ScreenName.ScanDeviceAccounts,
55+
params,
56+
});
6057
},
6158
[navigation, route, context],
6259
);

apps/ledger-live-mobile/src/newArch/features/DeviceSelection/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type CommonParams = {
1212
context?: AddAccountContextType;
1313
onSuccess?: (res?: { scannedAccounts: Account[]; selected: Account[] }) => void;
1414
onCloseNavigation?: () => void;
15+
navigationDepth?: number;
1516
sourceScreenName?: string;
1617
};
1718

apps/ledger-live-mobile/src/newArch/features/ModularDrawer/__integrations__/addAccountFlow.test.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ jest.mock("~/hooks/useIsDeviceLockedPolling/useIsDeviceLockedPolling", () => {
8585

8686
let triggerNext: (accounts: Account[]) => void = () => null;
8787
let triggerComplete: () => void = () => null;
88+
let triggerError: ((error: Error) => void) | null = null;
8889

8990
jest.mock("@ledgerhq/live-common/bridge/index", () => ({
9091
__esModule: true,
@@ -93,15 +94,20 @@ jest.mock("@ledgerhq/live-common/bridge/index", () => ({
9394
new Observable<{ account: Account }>(subscriber => {
9495
const originalNext = triggerNext;
9596
const originalComplete = triggerComplete;
97+
const originalError = triggerError;
9698
triggerNext = (accounts: Account[]) => {
9799
accounts.forEach(account => subscriber.next({ account }));
98100
};
99101
triggerComplete = () => {
100102
subscriber.complete();
101103
};
104+
triggerError = (error: Error) => {
105+
subscriber.error(error);
106+
};
102107
return () => {
103108
triggerNext = originalNext;
104109
triggerComplete = originalComplete;
110+
triggerError = originalError;
105111
};
106112
}),
107113
preload: () => Promise.resolve(true),
@@ -212,4 +218,102 @@ describe("AddAccountFlow with MAD", () => {
212218
await user.press(getByTestId(/NavigationHeaderCloseButton/i));
213219
expect(getByText(/add funds to my account/i)).toBeVisible();
214220
});
221+
222+
it("should return to device selection on retry when device is locked in inline flow", async () => {
223+
const { user, getByText, queryByText, getByTestId } = render(
224+
<ModularDrawerSharedNavigator flow="not_add_account" />,
225+
);
226+
227+
// Navigate through the add account flow
228+
expect(getByText(WITH_ACCOUNT_SELECTION)).toBeVisible();
229+
await user.press(getByText(WITH_ACCOUNT_SELECTION));
230+
advanceTimers();
231+
232+
expect(getByText(/bitcoin/i)).toBeVisible();
233+
await user.press(getByText(/bitcoin/i));
234+
advanceTimers();
235+
236+
expect(getByText(/add new or existing account/i)).toBeVisible();
237+
await user.press(getByText(/add new or existing account/i));
238+
advanceTimers();
239+
240+
expect(getByText(/connect device/i)).toBeVisible();
241+
advanceTimers();
242+
243+
const deviceItem = getByText(/ledger stax/i);
244+
expect(deviceItem).toBeVisible();
245+
await user.press(deviceItem);
246+
advanceTimers();
247+
248+
// Wait for scanning to start
249+
await waitFor(() => {
250+
expect(getByText(/checking the blockchain/i)).toBeVisible();
251+
});
252+
253+
// Trigger device locked error
254+
await act(() => {
255+
triggerError?.(new Error("Device locked"));
256+
});
257+
258+
// Wait for error modal to appear
259+
await waitFor(() => {
260+
expect(queryByText("Device locked")).toBeVisible();
261+
});
262+
263+
// Click Retry button (only Retry has testID="proceed-button", not Cancel)
264+
const retryButton = getByTestId("proceed-button");
265+
await user.press(retryButton);
266+
267+
// Should return to device selection screen (not to wallet)
268+
await waitFor(() => {
269+
expect(getByText(/connect device/i)).toBeVisible();
270+
expect(queryByText(/checking the blockchain/i)).not.toBeVisible();
271+
});
272+
});
273+
274+
it("should close inline flow and return to initial screen after account creation", async () => {
275+
const { user, getByText, queryByText } = render(
276+
<ModularDrawerSharedNavigator flow="not_add_account" />,
277+
);
278+
279+
// Navigate through the add account flow
280+
expect(getByText(WITH_ACCOUNT_SELECTION)).toBeVisible();
281+
await user.press(getByText(WITH_ACCOUNT_SELECTION));
282+
advanceTimers();
283+
284+
expect(getByText(/bitcoin/i)).toBeVisible();
285+
await user.press(getByText(/bitcoin/i));
286+
advanceTimers();
287+
288+
expect(getByText(/add new or existing account/i)).toBeVisible();
289+
await user.press(getByText(/add new or existing account/i));
290+
advanceTimers();
291+
292+
expect(getByText(/connect device/i)).toBeVisible();
293+
advanceTimers();
294+
295+
const deviceItem = getByText(/ledger stax/i);
296+
expect(deviceItem).toBeVisible();
297+
await user.press(deviceItem);
298+
advanceTimers();
299+
300+
// Wait for scanning to start
301+
await waitFor(() => {
302+
expect(getByText(/checking the blockchain/i)).toBeVisible();
303+
});
304+
305+
// Complete scanning
306+
await mockScanAccountsSubscription([BTC_ACCOUNT]);
307+
expect(getByText(/we found 1 account/i)).toBeVisible();
308+
309+
// Confirm account addition
310+
await user.press(getByText(/confirm/i));
311+
312+
// Should close the entire flow and return to the initial screen
313+
await waitFor(() => {
314+
expect(queryByText(/checking the blockchain/i)).not.toBeVisible();
315+
expect(queryByText(/connect device/i)).not.toBeVisible();
316+
expect(getByText(WITH_ACCOUNT_SELECTION)).toBeVisible();
317+
});
318+
});
215319
});

apps/ledger-live-mobile/src/newArch/features/ModularDrawer/hooks/useDeviceNavigation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export function useDeviceNavigation({
3838
(selectedAsset: CryptoCurrency, createTokenAccount?: boolean) => {
3939
onClose?.();
4040
resetSelection();
41+
42+
// Number of screens in the navigation stack to pop when closing:
43+
// SelectDevice (1) + AddAccounts flow (1) = 2 screens to pop
44+
const navigationDepth = isInline ? 2 : undefined;
45+
4146
navigation.navigate(NavigatorName.DeviceSelection, {
4247
screen: ScreenName.SelectDevice,
4348
params: {
@@ -46,6 +51,7 @@ export function useDeviceNavigation({
4651
context: AddAccountContexts.AddAccounts,
4752
inline: isInline,
4853
onCloseNavigation: onClose,
54+
navigationDepth,
4955
onSuccess,
5056
},
5157
});

0 commit comments

Comments
 (0)