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, }, });