diff --git a/.changeset/ninety-bags-taste.md b/.changeset/ninety-bags-taste.md
new file mode 100644
index 00000000000..337d2cf9650
--- /dev/null
+++ b/.changeset/ninety-bags-taste.md
@@ -0,0 +1,5 @@
+---
+"live-mobile": minor
+---
+
+prevent retry button from redirecting to wallet page in receive flow
diff --git a/apps/ledger-live-mobile/__mocks__/react-native-gesture-handler/ReanimatedSwipeable.js b/apps/ledger-live-mobile/__mocks__/react-native-gesture-handler/ReanimatedSwipeable.js
index 9a35c670a86..9d253a62619 100644
--- a/apps/ledger-live-mobile/__mocks__/react-native-gesture-handler/ReanimatedSwipeable.js
+++ b/apps/ledger-live-mobile/__mocks__/react-native-gesture-handler/ReanimatedSwipeable.js
@@ -1,8 +1,14 @@
import React from "react";
import { View } from "react-native";
-const ReanimatedSwipeable = ({ children, renderRightActions, ...props }) => {
- return {children};
-};
+const ReanimatedSwipeable = React.forwardRef(({ children, renderRightActions, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+});
+
+ReanimatedSwipeable.displayName = "ReanimatedSwipeable";
export default ReanimatedSwipeable;
diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts
index fa7277e3305..c29cd84f75b 100644
--- a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts
+++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts
@@ -11,6 +11,8 @@ export type AddAccountContextType = `${AddAccountContexts}`;
type CommonParams = {
context?: AddAccountContextType;
onCloseNavigation?: () => void;
+ // Number of navigators to pop when closing the flow (calculated at entry point)
+ navigationDepth?: number;
currency: CryptoOrTokenCurrency;
sourceScreenName?: string;
};
diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/index.tsx
index 2c76d45374c..3212c608f48 100644
--- a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/index.tsx
+++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/index.tsx
@@ -60,7 +60,7 @@ function ScanDeviceAccounts() {
onModalHide,
onPressAccount,
quitFlow,
- restartSubscription,
+ handleRetry,
scannedAccounts,
scanning,
sections,
@@ -187,7 +187,7 @@ function ScanDeviceAccounts() {
(!scanning && scannedAccounts.length === 0)
}
canDone={!scanning && cantCreateAccount && noImportableAccounts}
- onRetry={restartSubscription}
+ onRetry={handleRetry}
onStop={stopSubscription}
onDone={quitFlow}
onContinue={importAccounts}
@@ -201,10 +201,10 @@ function ScanDeviceAccounts() {
onModalHide={onModalHide}
footerButtons={
<>
-
+
>
}
diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/useScanDeviceAccountsViewModel.ts b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/useScanDeviceAccountsViewModel.ts
index 70d29f3a375..6476a1ad881 100644
--- a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/useScanDeviceAccountsViewModel.ts
+++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/useScanDeviceAccountsViewModel.ts
@@ -46,7 +46,7 @@ export default function useScanDeviceAccountsViewModel({
const [onlyNewAccounts, setOnlyNewAccounts] = useState(true);
const [showAllCreatedAccounts, setShowAllCreatedAccounts] = useState(false);
const [selectedIds, setSelectedIds] = useState([]);
- const [cancelled, setCancelled] = useState(false);
+ const cancelledRef = useRef(false);
const scanSubscription = useRef(null);
const [isAddingAccounts, setIsAddinAccounts] = useState(false);
const dispatch = useDispatch();
@@ -58,6 +58,7 @@ export default function useScanDeviceAccountsViewModel({
inline,
returnToSwap,
onCloseNavigation,
+ navigationDepth,
context,
} = route.params || {};
@@ -103,14 +104,27 @@ export default function useScanDeviceAccountsViewModel({
},
});
}, [blacklistedTokenIds, currency, deviceId]);
+
const restartSubscription = useCallback(() => {
setScanning(true);
setScannedAccounts([]);
setSelectedIds([]);
setError(null);
- setCancelled(false);
+ cancelledRef.current = false;
startSubscription();
}, [startSubscription]);
+
+ const handleRetry = useCallback(() => {
+ // In inline flows (e.g., Receive), navigate back to device selection
+ // to allow user to reconnect/unlock device instead of retrying on same screen
+ if (inline && error) {
+ // Simply go back to SelectDevice which is already in the stack
+ navigation.goBack();
+ return;
+ }
+
+ restartSubscription();
+ }, [inline, error, navigation, restartSubscription]);
const stopSubscription = useCallback(
(syncUI = true) => {
if (scanSubscription.current) {
@@ -169,6 +183,16 @@ export default function useScanDeviceAccountsViewModel({
},
[selectedIds],
);
+
+ // Close inline flow: drawer + navigation
+ const closeInlineFlow = useCallback(() => {
+ if (onCloseNavigation) {
+ onCloseNavigation();
+ }
+ const parent = navigation.getParent>();
+ parent?.pop(navigationDepth ?? 2);
+ }, [navigation, onCloseNavigation, navigationDepth]);
+
const importAccounts = useCallback(() => {
const accountsToAdd = scannedAccounts.filter(a => selectedIds.includes(a.id));
if (currency.id.includes("canton_network")) {
@@ -191,7 +215,8 @@ export default function useScanDeviceAccountsViewModel({
const { onSuccess } = route.params;
if (inline) {
- navigation.goBack();
+ closeInlineFlow();
+
if (onSuccess) {
onSuccess({
scannedAccounts,
@@ -227,19 +252,27 @@ export default function useScanDeviceAccountsViewModel({
scannedAccounts,
selectedIds,
dispatch,
+ closeInlineFlow,
analyticsMetadata?.AccountsFound?.onContinue,
analyticsMetadata?.AccountsFound?.onAccountsAdded,
]);
const onCancel = useCallback(() => {
setError(null);
- setCancelled(true);
+ cancelledRef.current = true;
}, []);
+
const onModalHide = useCallback(() => {
- if (cancelled) {
- navigation.getParent>().pop();
+ // Use ref to avoid stale closure issue with cancelled state
+ if (cancelledRef.current) {
+ if (inline) {
+ closeInlineFlow();
+ } else {
+ navigation.getParent>()?.pop();
+ }
}
- }, [cancelled, navigation]);
+ }, [inline, closeInlineFlow, navigation]);
+
const viewAllCreatedAccounts = useCallback(() => setShowAllCreatedAccounts(true), []);
const onAccountNameChange = useCallback(
@@ -350,7 +383,7 @@ export default function useScanDeviceAccountsViewModel({
onModalHide,
onPressAccount,
quitFlow,
- restartSubscription,
+ handleRetry,
scannedAccounts,
scanning,
sections: sanitizedSections,
diff --git a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/useSelectDeviceViewModel.ts b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/useSelectDeviceViewModel.ts
index 07e80b28bae..197da9364d2 100644
--- a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/useSelectDeviceViewModel.ts
+++ b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/useSelectDeviceViewModel.ts
@@ -40,23 +40,20 @@ export default function useSelectDeviceViewModel(
(meta: AppResult) => {
setDevice(null);
- const { inline } = route.params;
const params = {
...route.params,
...meta,
context,
sourceScreenName: ScreenName.SelectDevice,
};
- if (inline) {
- navigation.replace(NavigatorName.AddAccounts, {
- screen: ScreenName.ScanDeviceAccounts,
- params,
- });
- } else
- navigation.navigate(NavigatorName.AddAccounts, {
- screen: ScreenName.ScanDeviceAccounts,
- params,
- });
+
+ // Always use navigate instead of replace to keep SelectDevice in the stack.
+ // This allows retry navigation when device errors occur (e.g., device locked).
+ // Previously, inline flows used replace which prevented retry navigation.
+ navigation.navigate(NavigatorName.AddAccounts, {
+ screen: ScreenName.ScanDeviceAccounts,
+ params,
+ });
},
[navigation, route, context],
);
diff --git a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/types.ts b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/types.ts
index 098767c288a..5befe5a859d 100644
--- a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/types.ts
+++ b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/types.ts
@@ -12,6 +12,7 @@ type CommonParams = {
context?: AddAccountContextType;
onSuccess?: (res?: { scannedAccounts: Account[]; selected: Account[] }) => void;
onCloseNavigation?: () => void;
+ navigationDepth?: number;
sourceScreenName?: string;
};
diff --git a/apps/ledger-live-mobile/src/newArch/features/ModularDrawer/__integrations__/addAccountFlow.test.tsx b/apps/ledger-live-mobile/src/newArch/features/ModularDrawer/__integrations__/addAccountFlow.test.tsx
index 50ad4a383b1..6f87ca2f6e0 100644
--- a/apps/ledger-live-mobile/src/newArch/features/ModularDrawer/__integrations__/addAccountFlow.test.tsx
+++ b/apps/ledger-live-mobile/src/newArch/features/ModularDrawer/__integrations__/addAccountFlow.test.tsx
@@ -85,6 +85,7 @@ jest.mock("~/hooks/useIsDeviceLockedPolling/useIsDeviceLockedPolling", () => {
let triggerNext: (accounts: Account[]) => void = () => null;
let triggerComplete: () => void = () => null;
+let triggerError: ((error: Error) => void) | null = null;
jest.mock("@ledgerhq/live-common/bridge/index", () => ({
__esModule: true,
@@ -93,15 +94,20 @@ jest.mock("@ledgerhq/live-common/bridge/index", () => ({
new Observable<{ account: Account }>(subscriber => {
const originalNext = triggerNext;
const originalComplete = triggerComplete;
+ const originalError = triggerError;
triggerNext = (accounts: Account[]) => {
accounts.forEach(account => subscriber.next({ account }));
};
triggerComplete = () => {
subscriber.complete();
};
+ triggerError = (error: Error) => {
+ subscriber.error(error);
+ };
return () => {
triggerNext = originalNext;
triggerComplete = originalComplete;
+ triggerError = originalError;
};
}),
preload: () => Promise.resolve(true),
@@ -212,4 +218,208 @@ describe("AddAccountFlow with MAD", () => {
await user.press(getByTestId(/NavigationHeaderCloseButton/i));
expect(getByText(/add funds to my account/i)).toBeVisible();
});
+
+ it("should return to device selection on retry when device is locked in inline flow", async () => {
+ const { user, getByText, queryByText, getByTestId } = render(
+ ,
+ );
+
+ // Navigate through the add account flow
+ expect(getByText(WITH_ACCOUNT_SELECTION)).toBeVisible();
+ await user.press(getByText(WITH_ACCOUNT_SELECTION));
+ advanceTimers();
+
+ expect(getByText(/bitcoin/i)).toBeVisible();
+ await user.press(getByText(/bitcoin/i));
+ advanceTimers();
+
+ expect(getByText(/add new or existing account/i)).toBeVisible();
+ await user.press(getByText(/add new or existing account/i));
+ advanceTimers();
+
+ expect(getByText(/connect device/i)).toBeVisible();
+ advanceTimers();
+
+ const deviceItem = getByText(/ledger stax/i);
+ expect(deviceItem).toBeVisible();
+ await user.press(deviceItem);
+ advanceTimers();
+
+ // Wait for scanning to start
+ await waitFor(() => {
+ expect(getByText(/checking the blockchain/i)).toBeVisible();
+ });
+
+ // Trigger device locked error
+ await act(() => {
+ triggerError?.(new Error("Device locked"));
+ });
+
+ // Wait for error modal to appear
+ await waitFor(() => {
+ expect(queryByText("Device locked")).toBeVisible();
+ });
+
+ // Click Retry button (only Retry has testID="proceed-button", not Cancel)
+ const retryButton = getByTestId("proceed-button");
+ await user.press(retryButton);
+
+ // Should return to device selection screen (not to wallet)
+ await waitFor(() => {
+ expect(getByText(/connect device/i)).toBeVisible();
+ expect(queryByText(/checking the blockchain/i)).not.toBeVisible();
+ });
+ });
+
+ it("should close flow and return to initial screen when clicking X button on error modal in inline flow", async () => {
+ const { user, getByText, queryByText, getByTestId } = render(
+ ,
+ );
+
+ // Navigate through the add account flow
+ expect(getByText(WITH_ACCOUNT_SELECTION)).toBeVisible();
+ await user.press(getByText(WITH_ACCOUNT_SELECTION));
+ advanceTimers();
+
+ expect(getByText(/bitcoin/i)).toBeVisible();
+ await user.press(getByText(/bitcoin/i));
+ advanceTimers();
+
+ expect(getByText(/add new or existing account/i)).toBeVisible();
+ await user.press(getByText(/add new or existing account/i));
+ advanceTimers();
+
+ expect(getByText(/connect device/i)).toBeVisible();
+ advanceTimers();
+
+ const deviceItem = getByText(/ledger stax/i);
+ expect(deviceItem).toBeVisible();
+ await user.press(deviceItem);
+ advanceTimers();
+
+ // Wait for scanning to start
+ await waitFor(() => {
+ expect(getByText(/checking the blockchain/i)).toBeVisible();
+ });
+
+ // Trigger device locked error
+ await act(() => {
+ triggerError?.(new Error("Device locked"));
+ });
+
+ // Wait for error modal to appear
+ await waitFor(() => {
+ expect(queryByText("Device locked")).toBeVisible();
+ });
+
+ // Click X button to close error modal - should close entire flow
+ const closeButton = getByTestId("modal-close-button");
+ await user.press(closeButton);
+
+ // Should close the entire flow and return to the initial screen
+ await waitFor(() => {
+ expect(queryByText(/checking the blockchain/i)).not.toBeVisible();
+ expect(queryByText(/connect device/i)).not.toBeVisible();
+ expect(getByText(WITH_ACCOUNT_SELECTION)).toBeVisible();
+ });
+ });
+
+ it("should close flow and return to device selection when clicking X button on error modal in non-inline flow", async () => {
+ const { user, getByText, queryByText, getByTestId } = render(
+ ,
+ );
+
+ // Navigate through the add account flow
+ expect(getByText(WITH_ACCOUNT_SELECTION)).toBeVisible();
+ await user.press(getByText(WITH_ACCOUNT_SELECTION));
+ advanceTimers();
+
+ expect(getByText(/bitcoin/i)).toBeVisible();
+ await user.press(getByText(/bitcoin/i));
+ advanceTimers();
+
+ expect(getByText(/add new or existing account/i)).toBeVisible();
+ await user.press(getByText(/add new or existing account/i));
+ advanceTimers();
+
+ expect(getByText(/connect device/i)).toBeVisible();
+ advanceTimers();
+
+ const deviceItem = getByText(/ledger stax/i);
+ expect(deviceItem).toBeVisible();
+ await user.press(deviceItem);
+ advanceTimers();
+
+ // Wait for scanning to start
+ await waitFor(() => {
+ expect(getByText(/checking the blockchain/i)).toBeVisible();
+ });
+
+ // Trigger device locked error
+ await act(() => {
+ triggerError?.(new Error("Device locked"));
+ });
+
+ // Wait for error modal to appear
+ await waitFor(() => {
+ expect(queryByText("Device locked")).toBeVisible();
+ });
+
+ // Click X button to close error modal - should close the flow
+ const closeButton = getByTestId("modal-close-button");
+ await user.press(closeButton);
+
+ // For non-inline flows, should pop the navigation and return to device selection
+ await waitFor(() => {
+ expect(queryByText(/checking the blockchain/i)).not.toBeVisible();
+ expect(queryByText("Device locked")).not.toBeVisible();
+ expect(getByText(/connect device/i)).toBeVisible();
+ });
+ });
+
+ it("should close inline flow and return to initial screen after account creation", async () => {
+ const { user, getByText, queryByText } = render(
+ ,
+ );
+
+ // Navigate through the add account flow
+ expect(getByText(WITH_ACCOUNT_SELECTION)).toBeVisible();
+ await user.press(getByText(WITH_ACCOUNT_SELECTION));
+ advanceTimers();
+
+ expect(getByText(/bitcoin/i)).toBeVisible();
+ await user.press(getByText(/bitcoin/i));
+ advanceTimers();
+
+ expect(getByText(/add new or existing account/i)).toBeVisible();
+ await user.press(getByText(/add new or existing account/i));
+ advanceTimers();
+
+ expect(getByText(/connect device/i)).toBeVisible();
+ advanceTimers();
+
+ const deviceItem = getByText(/ledger stax/i);
+ expect(deviceItem).toBeVisible();
+ await user.press(deviceItem);
+ advanceTimers();
+
+ // Wait for scanning to start
+ await waitFor(() => {
+ expect(getByText(/checking the blockchain/i)).toBeVisible();
+ });
+
+ // Complete scanning
+ await mockScanAccountsSubscription([BTC_ACCOUNT]);
+ expect(getByText(/we found 1 account/i)).toBeVisible();
+
+ // Confirm account addition
+ await user.press(getByText(/confirm/i));
+
+ // Should close the entire flow and return to the initial screen
+ await waitFor(() => {
+ expect(queryByText(/checking the blockchain/i)).not.toBeVisible();
+ expect(queryByText(/connect device/i)).not.toBeVisible();
+ expect(getByText(WITH_ACCOUNT_SELECTION)).toBeVisible();
+ });
+ });
});
diff --git a/apps/ledger-live-mobile/src/newArch/features/ModularDrawer/hooks/useDeviceNavigation.ts b/apps/ledger-live-mobile/src/newArch/features/ModularDrawer/hooks/useDeviceNavigation.ts
index af9cbeea616..014f0761f67 100644
--- a/apps/ledger-live-mobile/src/newArch/features/ModularDrawer/hooks/useDeviceNavigation.ts
+++ b/apps/ledger-live-mobile/src/newArch/features/ModularDrawer/hooks/useDeviceNavigation.ts
@@ -38,6 +38,11 @@ export function useDeviceNavigation({
(selectedAsset: CryptoCurrency, createTokenAccount?: boolean) => {
onClose?.();
resetSelection();
+
+ // Number of screens in the navigation stack to pop when closing:
+ // SelectDevice (1) + AddAccounts flow (1) = 2 screens to pop
+ const navigationDepth = isInline ? 2 : undefined;
+
navigation.navigate(NavigatorName.DeviceSelection, {
screen: ScreenName.SelectDevice,
params: {
@@ -46,6 +51,7 @@ export function useDeviceNavigation({
context: AddAccountContexts.AddAccounts,
inline: isInline,
onCloseNavigation: onClose,
+ navigationDepth,
onSuccess,
},
});