diff --git a/apps/mobile/src/__integration__/onboarding-import-asb.test.tsx b/apps/mobile/src/__integration__/onboarding-import-asb.test.tsx index acaa0106d..9e8e03fe7 100644 --- a/apps/mobile/src/__integration__/onboarding-import-asb.test.tsx +++ b/apps/mobile/src/__integration__/onboarding-import-asb.test.tsx @@ -533,4 +533,59 @@ describe('Flow: Onboarding → Import from Algorand Secure Backup', () => { }, SLOW_TEST_TIMEOUT_MS, ) + + it( + 'Given the user navigates back into the backup screen after the flow has wiped the store, the displayed file is cleared and Continue is disabled', + async () => { + // Reproduces the user-reported back-nav loop: paste a backup, + // advance through the flow, after which `SelectAccounts` cleanup + // resets the store. Without the screen reacting to the cleared + // envelope, the local "Pasted backup" card stayed visible and + // tapping Next jumped to a Key screen with no envelope — + // which then bounced straight back here, looking broken. + vi.mocked(Clipboard.getStringAsync).mockResolvedValueOnce( + buildSingleAccountAsbBackup(), + ) + + renderAsbImportFromOnboarding() + await enterAsbFlow() + + // Step 1: paste a backup to set the envelope + loadedFile card. + fireEvent.click( + screen.getByTestId('asb_import_backup_paste_button'), + ) + await waitForButtonEnabled('asb_import_backup_continue_button') + // The clear (X) button only renders alongside the loaded-file + // card, so its presence is a reliable proxy for "the card is + // visible". + expect( + screen.getByTestId('asb_import_backup_clear_button'), + ).toBeTruthy() + expect(useAsbImportFlowStore.getState().envelope).not.toBeNull() + + // Step 2: simulate the store wipe that the rest of the flow + // performs (SelectAccounts cleanup runs `reset()` on unmount; + // Result screen Done also calls it). What the user actually + // does — back-nav from Result through the stack — converges to + // the same observable end-state on this screen. + useAsbImportFlowStore.getState().reset() + + // Step 3: the screen has to mirror the store. The card should + // disappear and Continue should disable so the user cannot + // advance into a Key screen with nothing to decrypt against. + await waitFor(() => { + expect( + screen.queryByTestId('asb_import_backup_clear_button'), + ).toBeNull() + }) + expect( + ( + screen.getByTestId( + 'asb_import_backup_continue_button', + ) as HTMLButtonElement + ).disabled, + ).toBe(true) + }, + SLOW_TEST_TIMEOUT_MS, + ) }) diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 964760729..091ad6e12 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -1084,6 +1084,10 @@ "body": "Enter the 12-word recovery key that was generated when the Algorand Secure Backup file was created.", "continue": "Continue", "decrypting": "Unlocking your backup…", + "too_many_words_title": "Invalid recovery key", + "too_many_words_body": "The recovery key you pasted contains too many words.", + "insufficient_slots_title": "Invalid recovery key", + "insufficient_slots_body": "The recovery key you pasted does not fit in the remaining slots.", "errors": { "title": "Backup could not be unlocked", "empty_file": "The backup file is empty.", diff --git a/apps/mobile/src/modules/backup/screens/BackupVerificationScreen/BackupVerificationScreen.tsx b/apps/mobile/src/modules/backup/screens/BackupVerificationScreen/BackupVerificationScreen.tsx index 6cf57b537..fd518bb0f 100644 --- a/apps/mobile/src/modules/backup/screens/BackupVerificationScreen/BackupVerificationScreen.tsx +++ b/apps/mobile/src/modules/backup/screens/BackupVerificationScreen/BackupVerificationScreen.tsx @@ -12,11 +12,15 @@ import { PWButton, PWScrollView, PWText, PWView } from '@components/core' import { useLanguage } from '@hooks/useLanguage' +import { usePreventScreenCapture } from '@hooks/usePreventScreenCapture' import { BackupQuizItem } from '../../components/BackupQuizItem' import { useBackupVerificationScreen } from './useBackupVerificationScreen' import { useStyles } from './styles' +const SCREEN_CAPTURE_TAG = 'backup-verification' + export const BackupVerificationScreen = () => { + usePreventScreenCapture(SCREEN_CAPTURE_TAG) const styles = useStyles() const { t } = useLanguage() const { items, onSelect, onSubmit, isFilled } = diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/WordSuggestionDropdown/WordSuggestionDropdown.tsx b/apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/MnemonicSuggestionBar.tsx similarity index 61% rename from apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/WordSuggestionDropdown/WordSuggestionDropdown.tsx rename to apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/MnemonicSuggestionBar.tsx index b83d34e85..ea8deccad 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/WordSuggestionDropdown/WordSuggestionDropdown.tsx +++ b/apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/MnemonicSuggestionBar.tsx @@ -14,36 +14,37 @@ import { PWText, PWTouchableOpacity, PWView } from '@components/core' import { useStyles } from './styles' -export type WordSuggestionDropdownProps = { +export type MnemonicSuggestionBarProps = { suggestions: string[] onSelectSuggestion: (word: string) => void + /** testID prefix; each pill is `${testIDPrefix}_${word}`. */ + testIDPrefix?: string } -export const WordSuggestionDropdown = ({ +/** + * Sticky horizontal pill bar with wordlist completions for the focused + * mnemonic slot. Renders nothing when there are no suggestions, so callers + * can place it unconditionally between scroll and footer. + */ +export const MnemonicSuggestionBar = ({ suggestions, onSelectSuggestion, -}: WordSuggestionDropdownProps) => { + testIDPrefix = 'mnemonic_suggestion', +}: MnemonicSuggestionBarProps) => { const styles = useStyles() - if (suggestions.length === 0) { - return null - } + if (suggestions.length === 0) return null return ( - + {suggestions.map(word => ( onSelectSuggestion(word)} - testID={`suggestion-${word}`} + style={styles.pill} + testID={`${testIDPrefix}_${word}`} > - - {word} - + {word} ))} diff --git a/apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/__tests__/MnemonicSuggestionBar.spec.tsx b/apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/__tests__/MnemonicSuggestionBar.spec.tsx new file mode 100644 index 000000000..77885b999 --- /dev/null +++ b/apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/__tests__/MnemonicSuggestionBar.spec.tsx @@ -0,0 +1,58 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@test-utils/render' + +import { MnemonicSuggestionBar } from '../MnemonicSuggestionBar' + +describe('MnemonicSuggestionBar', () => { + it('renders one pill per suggestion', () => { + render( + , + ) + + expect(screen.getByText('abandon')).toBeTruthy() + expect(screen.getByText('ability')).toBeTruthy() + expect(screen.getByText('able')).toBeTruthy() + }) + + it('renders nothing when there are no suggestions', () => { + render( + , + ) + + expect(screen.queryByTestId(/^test_suggestion_/)).toBeNull() + }) + + it('invokes onSelectSuggestion with the tapped word', () => { + const onSelectSuggestion = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('test_suggestion_ability')) + + expect(onSelectSuggestion).toHaveBeenCalledWith('ability') + }) +}) diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/WordSuggestionDropdown/index.ts b/apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/index.ts similarity index 80% rename from apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/WordSuggestionDropdown/index.ts rename to apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/index.ts index 106c5ef57..179323586 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/WordSuggestionDropdown/index.ts +++ b/apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/index.ts @@ -10,5 +10,7 @@ limitations under the License */ -export { WordSuggestionDropdown } from './WordSuggestionDropdown' -export type { WordSuggestionDropdownProps } from './WordSuggestionDropdown' +export { + MnemonicSuggestionBar, + type MnemonicSuggestionBarProps, +} from './MnemonicSuggestionBar' diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/WordSuggestionDropdown/styles.ts b/apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/styles.ts similarity index 65% rename from apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/WordSuggestionDropdown/styles.ts rename to apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/styles.ts index cd32a82f1..dd08b04cb 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/WordSuggestionDropdown/styles.ts +++ b/apps/mobile/src/modules/onboarding/components/MnemonicSuggestionBar/styles.ts @@ -13,18 +13,21 @@ import { makeStyles } from '@rneui/themed' export const useStyles = makeStyles(theme => ({ - container: { + bar: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: theme.spacing.sm, + paddingHorizontal: theme.spacing.xl, + paddingTop: theme.spacing.md, + paddingBottom: theme.spacing.xs, + borderTopWidth: theme.borders.sm, + borderTopColor: theme.colors.layerGrayLighter, backgroundColor: theme.colors.background, - borderRadius: theme.spacing.sm, - borderWidth: theme.borders.sm, - borderColor: theme.colors.layerGray, - ...theme.shadows.md, }, - suggestionItem: { - paddingVertical: theme.spacing.sm, + pill: { paddingHorizontal: theme.spacing.md, - }, - suggestionText: { - color: theme.colors.textMain, + paddingVertical: theme.spacing.sm, + backgroundColor: theme.colors.layerGrayLighter, + borderRadius: theme.borderRadius.md, }, })) diff --git a/apps/mobile/src/modules/onboarding/hooks/__tests__/useMnemonicWordEntry.spec.ts b/apps/mobile/src/modules/onboarding/hooks/__tests__/useMnemonicWordEntry.spec.ts new file mode 100644 index 000000000..775e1306c --- /dev/null +++ b/apps/mobile/src/modules/onboarding/hooks/__tests__/useMnemonicWordEntry.spec.ts @@ -0,0 +1,332 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { renderHook, act } from '@testing-library/react' +import * as Clipboard from 'expo-clipboard' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +import { useMnemonicWordEntry } from '../useMnemonicWordEntry' + +vi.mock('expo-clipboard', () => ({ + getStringAsync: vi.fn(), +})) + +vi.mock('@perawallet/wallet-core-kms', () => ({ + MNEMONIC_WORDLIST: [ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + 'school', + 'zoo', + ], +})) + +const TWELVE_WORDS = [ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', +] + +const renderEntry = () => { + const onTooManyWords = vi.fn() + const onInsufficientSlots = vi.fn() + const view = renderHook(() => + useMnemonicWordEntry({ + wordCount: 12, + onTooManyWords, + onInsufficientSlots, + }), + ) + return { ...view, onTooManyWords, onInsufficientSlots } +} + +beforeEach(() => { + vi.clearAllMocks() + // Default to an empty clipboard so the paste fallback only kicks in for + // tests that explicitly mock a value; otherwise `mockResolvedValue` set + // by one test would leak into later ones and corrupt their state. + vi.mocked(Clipboard.getStringAsync).mockResolvedValue('') +}) + +describe('useMnemonicWordEntry — paste distribution', () => { + it('distributes a full N-word paste across all slots from any starting slot', async () => { + const { result } = renderEntry() + + await act(async () => { + await result.current.handleWordChange(TWELVE_WORDS.join(' '), 5) + }) + + expect(result.current.words).toEqual(TWELVE_WORDS) + }) + + it.each([ + ['comma-separated', TWELVE_WORDS.join(',')], + ['comma + space', TWELVE_WORDS.join(', ')], + ['mixed whitespace and commas', TWELVE_WORDS.join(' ,\n ')], + ])('accepts %s separators', async (_label, pasted) => { + const { result } = renderEntry() + + await act(async () => { + await result.current.handleWordChange(pasted, 0) + }) + + expect(result.current.words).toEqual(TWELVE_WORDS) + }) + + it('falls back to the clipboard when the keyboard collapses pasted whitespace', async () => { + vi.mocked(Clipboard.getStringAsync).mockResolvedValue( + TWELVE_WORDS.join('\n'), + ) + + const { result } = renderEntry() + + await act(async () => { + await result.current.handleWordChange(TWELVE_WORDS.join(''), 0) + }) + + expect(Clipboard.getStringAsync).toHaveBeenCalled() + expect(result.current.words).toEqual(TWELVE_WORDS) + }) + + it('does not consult the clipboard for short single-token inputs even if a mnemonic is on the clipboard', async () => { + // Regression: iOS autocomplete delivers a whole word in one event, + // which previously tripped the clipboard fallback and overwrote every + // slot when the user had a mnemonic copied for any reason. + vi.mocked(Clipboard.getStringAsync).mockResolvedValue( + TWELVE_WORDS.join(' '), + ) + + const { result } = renderEntry() + + await act(async () => { + // Whole word inserted in one shot — 7 chars, below the threshold. + await result.current.handleWordChange('abandon', 0) + }) + + expect(Clipboard.getStringAsync).not.toHaveBeenCalled() + expect(result.current.words[0]).toBe('abandon') + expect(result.current.words.slice(1).every(w => w === '')).toBe(true) + }) + + it('treats a single wordlist token with trailing punctuation as autocomplete, not a paste', async () => { + // Regression: some keyboards append a trailing comma/period when + // accepting a suggestion. Without normalizing through splitMnemonic, + // the wordlist check missed the trailing punctuation, the clipboard + // fallback engaged, and a copied mnemonic overwrote every slot. + vi.mocked(Clipboard.getStringAsync).mockResolvedValue( + TWELVE_WORDS.join(' '), + ) + + const { result } = renderEntry() + + await act(async () => { + await result.current.handleWordChange('abandon,', 0) + }) + + expect(Clipboard.getStringAsync).not.toHaveBeenCalled() + // The slot holds the normalised token (comma stripped by + // splitMnemonic) so the user doesn't have to clean it up before + // Continue enables; and the other 11 slots stay empty rather than + // being clobbered by the clipboard mnemonic. + expect(result.current.words[0]).toBe('abandon') + expect(result.current.words.slice(1).every(w => w === '')).toBe(true) + }) + + it('fills sequential slots when a partial paste fits in the remaining inputs', async () => { + const { result } = renderEntry() + + await act(async () => { + await result.current.handleWordChange('abandon ability able', 2) + }) + + expect(result.current.words).toEqual([ + '', + '', + 'abandon', + 'ability', + 'able', + '', + '', + '', + '', + '', + '', + '', + ]) + }) + + it('fires onTooManyWords when more words than the slot count are pasted', async () => { + const { result, onTooManyWords } = renderEntry() + + await act(async () => { + await result.current.handleWordChange( + [...TWELVE_WORDS, 'zoo'].join(' '), + 0, + ) + }) + + expect(onTooManyWords).toHaveBeenCalledOnce() + expect(result.current.words.every(w => w === '')).toBe(true) + }) + + it('fires onInsufficientSlots when a partial paste cannot fit', async () => { + const { result, onInsufficientSlots } = renderEntry() + + await act(async () => { + await result.current.handleWordChange( + 'abandon ability able about', + 10, + ) + }) + + expect(onInsufficientSlots).toHaveBeenCalledOnce() + expect(result.current.words.every(w => w === '')).toBe(true) + }) + + it('writes a single typed character without consulting the clipboard', async () => { + const { result } = renderEntry() + + await act(async () => { + await result.current.handleWordChange('a', 0) + }) + + expect(Clipboard.getStringAsync).not.toHaveBeenCalled() + expect(result.current.words[0]).toBe('a') + }) + + it('writes the typed value when the clipboard read throws', async () => { + vi.mocked(Clipboard.getStringAsync).mockRejectedValue( + new Error('denied'), + ) + + const { result } = renderEntry() + + await act(async () => { + await result.current.handleWordChange('abandonability', 0) + }) + + expect(result.current.words[0]).toBe('abandonability') + }) +}) + +describe('useMnemonicWordEntry — suggestions', () => { + it('returns wordlist matches that share the current prefix', async () => { + const { result } = renderEntry() + + await act(async () => { + await result.current.handleWordChange('ab', 0) + }) + + expect(result.current.suggestions).toEqual([ + 'abandon', + 'ability', + 'able', + 'about', + ]) + }) + + it('hides the only suggestion once the slot holds a complete word', async () => { + const { result } = renderEntry() + + await act(async () => { + await result.current.handleWordChange('school', 0) + }) + + expect(result.current.suggestions).toEqual([]) + }) + + it('returns an empty list while the current input is too short', async () => { + const { result } = renderEntry() + + await act(async () => { + await result.current.handleWordChange('a', 0) + }) + + expect(result.current.suggestions).toEqual([]) + }) +}) + +describe('useMnemonicWordEntry — handleSelectSuggestion', () => { + it('writes the suggestion into the focused slot and advances focus', async () => { + const { result } = renderEntry() + + act(() => { + result.current.setFocused(3) + }) + + act(() => { + result.current.handleSelectSuggestion('abandon') + }) + + expect(result.current.words[3]).toBe('abandon') + expect(result.current.focused).toBe(4) + }) + + it('does not advance focus past the last slot', async () => { + const { result } = renderEntry() + + act(() => { + result.current.setFocused(11) + }) + + act(() => { + result.current.handleSelectSuggestion('abandon') + }) + + expect(result.current.words[11]).toBe('abandon') + expect(result.current.focused).toBe(11) + }) +}) + +describe('useMnemonicWordEntry — handleSubmitEditing', () => { + it('advances focus to the next slot when called for any non-last index', () => { + const { result } = renderEntry() + + act(() => { + result.current.handleSubmitEditing(5) + }) + + expect(result.current.focused).toBe(6) + }) + + it('does not advance past the last slot', () => { + const { result } = renderEntry() + + act(() => { + result.current.setFocused(11) + }) + + act(() => { + result.current.handleSubmitEditing(11) + }) + + expect(result.current.focused).toBe(11) + }) +}) diff --git a/apps/mobile/src/modules/onboarding/hooks/index.ts b/apps/mobile/src/modules/onboarding/hooks/index.ts index a2b81fefd..1def52a69 100644 --- a/apps/mobile/src/modules/onboarding/hooks/index.ts +++ b/apps/mobile/src/modules/onboarding/hooks/index.ts @@ -18,3 +18,8 @@ export { export { useExitAccountFlow } from './useExitAccountFlow' export { useAsbImportFlowStore } from './asbImportFlowStore' export { usePeraWebImportFlowStore } from './peraWebImportFlowStore' +export { useMnemonicWordEntry } from './useMnemonicWordEntry' +export type { + UseMnemonicWordEntryParams, + UseMnemonicWordEntryResult, +} from './useMnemonicWordEntry' diff --git a/apps/mobile/src/modules/onboarding/hooks/useMnemonicWordEntry.ts b/apps/mobile/src/modules/onboarding/hooks/useMnemonicWordEntry.ts new file mode 100644 index 000000000..e0d84f5fc --- /dev/null +++ b/apps/mobile/src/modules/onboarding/hooks/useMnemonicWordEntry.ts @@ -0,0 +1,259 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import * as Clipboard from 'expo-clipboard' + +import { MNEMONIC_WORDLIST } from '@perawallet/wallet-core-kms' + +import { splitMnemonic } from '../utils' + +import type { Nullable } from '@perawallet/wallet-core-shared' +import type { PWInputRef } from '@components/core' + +const MAX_SUGGESTIONS = 4 +const WORDLIST_SET = new Set(MNEMONIC_WORDLIST) + +export type UseMnemonicWordEntryParams = { + /** Total number of words the user is expected to enter. */ + wordCount: number + /** Fired when a paste contains more words than `wordCount`. Callers wire + * their own toast / dialog copy from this. */ + onTooManyWords: () => void + /** Fired when a partial paste cannot fit in the slots remaining at the + * paste site (e.g. pasting 6 words starting at slot 9 of a 12-word + * mnemonic). */ + onInsufficientSlots: () => void +} + +export type UseMnemonicWordEntryResult = { + words: string[] + focused: number + suggestions: string[] + setFocused: (index: number) => void + /** Direct setter for programmatic input (QR scan, deep link). Splits and + * distributes the same way a paste does. */ + updateWord: (value: string, index: number) => void + /** Wired straight to ``. Detects pastes via the + * input-length delta and consults the system clipboard so multi-word + * pastes still distribute when the keyboard collapses whitespace. */ + handleWordChange: (value: string, index: number) => Promise + /** Sets the focused slot to the picked suggestion and advances focus. */ + handleSelectSuggestion: (word: string) => void + /** Per-slot `ref` callbacks. Attach to each input so the hook can + * imperatively move focus on suggestion select / keyboard Next. */ + refCallbacks: ((ref: Nullable) => void)[] + /** Wire to ``. Advances focus to the next + * slot unless the user is already on the last one. */ + handleSubmitEditing: (index: number) => void +} + +/** + * Reusable mechanic for "enter a mnemonic across N input slots" screens. + * + * Handles: + * - Per-slot word state with focus tracking. + * - Paste distribution: a full N-word paste fills every slot regardless of + * which slot received it; a partial paste fills forward from the paste + * site. + * - Clipboard fallback: some Android keyboards (Gboard, Samsung) corrupt + * multi-word pastes by collapsing whitespace into a single token. When + * the input-length delta suggests a paste, we re-read the clipboard and + * prefer it if it has more separable words than the value we received. + * - Wordlist-driven suggestions for the focused slot, with the exact match + * filtered out so the suggestion row hides once the slot holds a complete + * word. + * + * The hook is wordlist- and copy-agnostic above the BIP39/Algo25 wordlist: + * callers pass their own `onTooManyWords` / `onInsufficientSlots` handlers + * so screen-specific i18n stays out of this layer. + */ +export const useMnemonicWordEntry = ({ + wordCount, + onTooManyWords, + onInsufficientSlots, +}: UseMnemonicWordEntryParams): UseMnemonicWordEntryResult => { + const [words, setWords] = useState(() => + new Array(wordCount).fill(''), + ) + const wordsRef = useRef(words) + wordsRef.current = words + + const [focused, setFocused] = useState(0) + + const suggestions = useMemo(() => { + const current = (words[focused] ?? '').trim().toLowerCase() + if (current.length < 2) return [] + return MNEMONIC_WORDLIST.filter( + w => w !== current && w.startsWith(current), + ).slice(0, MAX_SUGGESTIONS) + }, [words, focused]) + + const updateWord = useCallback( + (value: string, index: number) => { + const split = splitMnemonic(value) + + if (split.length > 1) { + if (split.length === wordCount) { + setWords(split) + return + } + + if (split.length > wordCount) { + onTooManyWords() + return + } + + const remainingSlots = wordCount - index + if (split.length <= remainingSlots) { + setWords(prev => { + const next = [...prev] + split.forEach((w, i) => { + next[index + i] = w + }) + return next + }) + } else { + onInsufficientSlots() + } + return + } + + // Take the split-result token rather than `value.trim()` so the + // separator characters splitMnemonic recognises (commas, mixed + // whitespace) are stripped from the slot too. Without this, a + // keyboard that appends "abandon," would leave the comma in the + // slot and block the user from continuing. + setWords(prev => { + const next = [...prev] + next[index] = split[0] ?? '' + return next + }) + }, + [wordCount, onTooManyWords, onInsufficientSlots], + ) + + const handleWordChange = useCallback( + async (value: string, index: number) => { + const currentWord = wordsRef.current[index] ?? '' + const delta = value.length - currentWord.length + + // The clipboard fallback exists for keyboards (Gboard, Samsung) + // that mangle multi-word pastes — collapsing whitespace, + // dropping the first separator, etc. We want to consult the + // clipboard for those cases but NOT for ordinary typing or + // autocomplete. + // + // Heuristic: skip the clipboard read whenever the change looks + // like autocomplete — a single token that is itself a valid + // BIP39 word. iOS autocomplete delivers the whole completed + // word in one change event, which would otherwise trip the + // delta>1 check and, if the user had a mnemonic on the + // clipboard for any reason, overwrite every slot. + // + // Everything else with delta>1 (multi-token paste, single + // non-wordlist token like a collapsed `helpinhale`, etc.) goes + // through the clipboard check, which only takes effect when + // the clipboard has more separable words than the received + // value. + // Derive the candidate token from splitMnemonic, not value.trim(), + // so trailing punctuation a keyboard may append ("abandon,", + // "abandon.") still classifies as autocomplete. Stripping only + // whitespace would leave the comma attached, fail the wordlist + // check, and let a clipboard mnemonic overwrite every slot. + const tokens = splitMnemonic(value) + const looksLikeAutocomplete = + tokens.length === 1 && WORDLIST_SET.has(tokens[0].toLowerCase()) + + if (delta > 1 && !looksLikeAutocomplete) { + try { + const clipboardContent = await Clipboard.getStringAsync() + if ( + clipboardContent && + splitMnemonic(clipboardContent).length > + splitMnemonic(value).length + ) { + updateWord(clipboardContent, index) + return + } + } catch { + // Clipboard read failed; fall through to the typed value. + } + } + + updateWord(value, index) + }, + [updateWord], + ) + + const handleSelectSuggestion = useCallback( + (word: string) => { + setWords(prev => { + const next = [...prev] + next[focused] = word + return next + }) + if (focused < wordCount - 1) { + setFocused(focused + 1) + } + }, + [focused, wordCount], + ) + + const inputRefs = useRef[]>( + new Array(wordCount).fill(null), + ) + + const refCallbacks = useMemo( + () => + Array.from( + { length: wordCount }, + (_, i) => (ref: Nullable) => { + inputRefs.current[i] = ref + }, + ), + [wordCount], + ) + + // Skip the mount run so we don't fight the consumer's `autoFocus` on the + // first input. Only subsequent `focused` changes (suggestion select, + // keyboard Next) drive imperative focus. + const isInitialFocusRunRef = useRef(true) + useEffect(() => { + if (isInitialFocusRunRef.current) { + isInitialFocusRunRef.current = false + return + } + inputRefs.current[focused]?.focus() + }, [focused]) + + const handleSubmitEditing = useCallback( + (index: number) => { + if (index < wordCount - 1) { + setFocused(index + 1) + } + }, + [wordCount], + ) + + return { + words, + focused, + suggestions, + setFocused, + updateWord, + handleWordChange, + handleSelectSuggestion, + refCallbacks, + handleSubmitEditing, + } +} diff --git a/apps/mobile/src/modules/onboarding/screens/AsbImportBackupScreen/useAsbImportBackupScreen.ts b/apps/mobile/src/modules/onboarding/screens/AsbImportBackupScreen/useAsbImportBackupScreen.ts index 1d71b0f52..3317d6e75 100644 --- a/apps/mobile/src/modules/onboarding/screens/AsbImportBackupScreen/useAsbImportBackupScreen.ts +++ b/apps/mobile/src/modules/onboarding/screens/AsbImportBackupScreen/useAsbImportBackupScreen.ts @@ -10,7 +10,7 @@ limitations under the License */ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import * as Clipboard from 'expo-clipboard' import { File } from 'expo-file-system' import { @@ -44,10 +44,22 @@ export const useAsbImportBackupScreen = (): UseAsbImportBackupScreenResult => { const navigation = useAppNavigation() const { t } = useLanguage() const { errorToast } = useToast() + const envelope = useAsbImportFlowStore(state => state.envelope) const setEnvelope = useAsbImportFlowStore(state => state.setEnvelope) const [loadedFile, setLoadedFile] = useState(null) + // Later flow steps (SelectAccounts cleanup, Result Done) wipe the store + // to zero decrypted material. If the user navigates back into this screen + // afterwards, the local "loadedFile" card would otherwise still display + // "Pasted backup", but tapping Next leads to a Key screen with no + // envelope to decrypt — so it bounces straight back here. Mirror the + // store: if the envelope disappears, clear the displayed indicator so + // the user has to re-pick / re-paste. + useEffect(() => { + if (!envelope) setLoadedFile(null) + }, [envelope]) + const showValidationError = useCallback( (reason: AsbErrorReason) => { errorToast( diff --git a/apps/mobile/src/modules/onboarding/screens/AsbImportInfoScreen/AsbImportInfoScreen.tsx b/apps/mobile/src/modules/onboarding/screens/AsbImportInfoScreen/AsbImportInfoScreen.tsx index 17fddb96d..75b8dd70d 100644 --- a/apps/mobile/src/modules/onboarding/screens/AsbImportInfoScreen/AsbImportInfoScreen.tsx +++ b/apps/mobile/src/modules/onboarding/screens/AsbImportInfoScreen/AsbImportInfoScreen.tsx @@ -12,7 +12,9 @@ import React from 'react' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { PWButton, PWRoundIcon, PWText, PWView } from '@components/core' +import { useTheme } from '@rneui/themed' +import { PWButton, PWText, PWView } from '@components/core' +import ShieldCheckImage from '@assets/icons/shield-check.svg' import { useLanguage } from '@hooks/useLanguage' import { useStyles } from './styles' import { useAsbImportInfoScreen } from './useAsbImportInfoScreen' @@ -21,14 +23,16 @@ export const AsbImportInfoScreen = () => { const insets = useSafeAreaInsets() const styles = useStyles(insets) const { t } = useLanguage() + const { theme } = useTheme() const { handleContinue } = useAsbImportInfoScreen() return ( - { + usePreventScreenCapture(SCREEN_CAPTURE_TAG) const insets = useSafeAreaInsets() + const headerHeight = useHeaderHeight() const styles = useStyles(insets) const { t } = useLanguage() const { @@ -41,13 +47,19 @@ export const AsbImportKeyScreen = () => { handleWordChange, handleSelectSuggestion, handleContinue, + refCallbacks, + handleSubmitEditing, } = useAsbImportKeyScreen() const wordsPerColumn = Math.ceil(wordCount / 2) return ( - + { > {globalIndex + 1} @@ -92,6 +108,11 @@ export const AsbImportKeyScreen = () => { style={styles.inputWrap} > @@ -105,6 +126,21 @@ export const AsbImportKeyScreen = () => { globalIndex, ) } + onSubmitEditing={() => + handleSubmitEditing( + globalIndex, + ) + } + returnKeyType={ + globalIndex === + wordCount - 1 + ? 'done' + : 'next' + } + blurOnSubmit={ + globalIndex === + wordCount - 1 + } autoCapitalize='none' autoCorrect={false} autoFocus={ @@ -122,6 +158,9 @@ export const AsbImportKeyScreen = () => { ? styles.inputContainerFocused : styles.inputContainer } + inputStyle={ + styles.input + } /> @@ -131,23 +170,14 @@ export const AsbImportKeyScreen = () => { ) })} - - {suggestions.length > 0 && ( - - {suggestions.map(s => ( - handleSelectSuggestion(s)} - style={styles.suggestionPill} - testID={`asb_import_key_suggestion_${s}`} - > - {s} - - ))} - - )} + + ({ width: theme.spacing.xl, color: theme.colors.textGray, }, + labelFocused: { + width: theme.spacing.xl, + color: theme.colors.textMain, + }, inputWrap: { flex: 1, }, inputOuter: { paddingHorizontal: 0, + flexShrink: 1, }, inputContainer: { + backgroundColor: theme.colors.background, borderBottomWidth: theme.borders.sm, - borderBottomColor: theme.colors.layerGrayLighter, + borderBottomColor: theme.colors.layerGray, + flexShrink: 1, }, inputContainerFocused: { + backgroundColor: theme.colors.background, borderBottomWidth: theme.borders.sm, borderBottomColor: theme.colors.textMain, + flexShrink: 1, }, - suggestionsRow: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: theme.spacing.sm, - }, - suggestionPill: { - paddingHorizontal: theme.spacing.md, - paddingVertical: theme.spacing.sm, - backgroundColor: theme.colors.layerGrayLighter, - borderRadius: theme.borderRadius.md, + input: { + flexShrink: 1, + backgroundColor: 'transparent', }, footer: { padding: theme.spacing.xl, - paddingBottom: theme.spacing.xxl + insets.bottom, + paddingBottom: theme.spacing.md + insets.bottom, }, })) diff --git a/apps/mobile/src/modules/onboarding/screens/AsbImportKeyScreen/useAsbImportKeyScreen.ts b/apps/mobile/src/modules/onboarding/screens/AsbImportKeyScreen/useAsbImportKeyScreen.ts index 7bbf12dd7..990473fc9 100644 --- a/apps/mobile/src/modules/onboarding/screens/AsbImportKeyScreen/useAsbImportKeyScreen.ts +++ b/apps/mobile/src/modules/onboarding/screens/AsbImportKeyScreen/useAsbImportKeyScreen.ts @@ -10,7 +10,7 @@ limitations under the License */ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { ASB_RECOVERY_MNEMONIC_WORD_COUNT, AsbErrorReason, @@ -22,7 +22,13 @@ import { MNEMONIC_WORDLIST } from '@perawallet/wallet-core-kms' import { useAppNavigation } from '@hooks/useAppNavigation' import { useLanguage } from '@hooks/useLanguage' import { useToast } from '@hooks/useToast' -import { useAsbImportFlowStore } from '@modules/onboarding/hooks' +import { + useAsbImportFlowStore, + useMnemonicWordEntry, +} from '@modules/onboarding/hooks' + +import type { Nullable } from '@perawallet/wallet-core-shared' +import type { PWInputRef } from '@components/core' type UseAsbImportKeyScreenResult = { words: string[] @@ -32,13 +38,14 @@ type UseAsbImportKeyScreenResult = { suggestions: string[] wordCount: number setFocused: (index: number) => void - handleWordChange: (value: string, index: number) => void + handleWordChange: (value: string, index: number) => Promise handleSelectSuggestion: (suggestion: string) => void handleContinue: () => Promise + refCallbacks: ((ref: Nullable) => void)[] + handleSubmitEditing: (index: number) => void } const WORDLIST_SET = new Set(MNEMONIC_WORDLIST) -const MAX_SUGGESTIONS = 4 export const useAsbImportKeyScreen = (): UseAsbImportKeyScreenResult => { const navigation = useAppNavigation() @@ -47,12 +54,49 @@ export const useAsbImportKeyScreen = (): UseAsbImportKeyScreenResult => { const envelope = useAsbImportFlowStore(state => state.envelope) const setPayload = useAsbImportFlowStore(state => state.setPayload) - const [words, setWords] = useState( - new Array(ASB_RECOVERY_MNEMONIC_WORD_COUNT).fill(''), - ) - const [focused, setFocused] = useState(0) const [isProcessing, setIsProcessing] = useState(false) + // The flow store is wiped after a successful import (and on backgrounding, + // for the decrypted payload). If the user navigates back into this screen + // afterwards — Android system back from the Result screen, for example — + // there's no envelope to decrypt against, so the screen is non-functional. + // Redirect them back to the file-pick step instead of leaving Continue + // silently no-op'ing. + useEffect(() => { + if (!envelope) { + navigation.replace('AsbImportBackup') + } + }, [envelope, navigation]) + + const onTooManyWords = useCallback(() => { + errorToast( + t('onboarding.asb_import.key.too_many_words_title'), + t('onboarding.asb_import.key.too_many_words_body'), + ) + }, [errorToast, t]) + + const onInsufficientSlots = useCallback(() => { + errorToast( + t('onboarding.asb_import.key.insufficient_slots_title'), + t('onboarding.asb_import.key.insufficient_slots_body'), + ) + }, [errorToast, t]) + + const { + words, + focused, + suggestions, + setFocused, + handleWordChange, + handleSelectSuggestion, + refCallbacks, + handleSubmitEditing, + } = useMnemonicWordEntry({ + wordCount: ASB_RECOVERY_MNEMONIC_WORD_COUNT, + onTooManyWords, + onInsufficientSlots, + }) + const trimmedWords = useMemo( () => words.map(w => w.trim().toLowerCase()), [words], @@ -65,37 +109,6 @@ export const useAsbImportKeyScreen = (): UseAsbImportKeyScreenResult => { [trimmedWords, isProcessing], ) - const suggestions = useMemo(() => { - const current = trimmedWords[focused] - if (!current || current.length < 2) return [] - return MNEMONIC_WORDLIST.filter(w => w.startsWith(current)).slice( - 0, - MAX_SUGGESTIONS, - ) - }, [trimmedWords, focused]) - - const handleWordChange = useCallback((value: string, index: number) => { - setWords(prev => { - const next = [...prev] - next[index] = value - return next - }) - }, []) - - const handleSelectSuggestion = useCallback( - (suggestion: string) => { - setWords(prev => { - const next = [...prev] - next[focused] = suggestion - return next - }) - if (focused < ASB_RECOVERY_MNEMONIC_WORD_COUNT - 1) { - setFocused(focused + 1) - } - }, - [focused], - ) - const handleContinue = useCallback(async () => { if (!envelope || isProcessing) return @@ -108,7 +121,12 @@ export const useAsbImportKeyScreen = (): UseAsbImportKeyScreenResult => { await Promise.resolve() const payload = decryptBackupPayload(envelope, mnemonic) setPayload(payload) - navigation.push('AsbImportSelectAccounts') + // `replace` (not `push`) so the Key screen unmounts and the + // typed recovery mnemonic stored in the input hook is dropped + // for GC. Strings can't be zeroed in JS, but the reference goes + // away. Bonus: back-navigating from SelectAccounts / Result no + // longer lands on a stale Key screen with prefilled words. + navigation.replace('AsbImportSelectAccounts') } catch (e) { const reason = e instanceof AsbImportError @@ -122,6 +140,11 @@ export const useAsbImportKeyScreen = (): UseAsbImportKeyScreenResult => { t(`onboarding.asb_import.key.errors.${reason}` as const), ) } finally { + // No mnemonic wipe here on purpose. Success path drops the + // reference via the `navigation.replace` above (the screen + // unmounts and `words` is GC'd with it). Error path keeps the + // typed words so the user can correct a typo and retry without + // re-typing 12 words. setIsProcessing(false) } }, [ @@ -145,5 +168,7 @@ export const useAsbImportKeyScreen = (): UseAsbImportKeyScreenResult => { handleWordChange, handleSelectSuggestion, handleContinue, + refCallbacks, + handleSubmitEditing, } } diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/ImportAccountScreen.tsx b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/ImportAccountScreen.tsx index 89a84f92e..85a437ba8 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/ImportAccountScreen.tsx +++ b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/ImportAccountScreen.tsx @@ -14,6 +14,7 @@ import React from 'react' import { useTheme } from '@rneui/themed' import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useHeaderHeight } from '@react-navigation/elements' import { PWButton, @@ -25,16 +26,21 @@ import { PWView, } from '@components/core' -import { KeyboardAvoidingView } from 'react-native' +import { KeyboardAvoidingView, Platform } from 'react-native' import { useStyles } from './styles' import { useImportAccountScreen } from './useImportAccountScreen' import { useNavigationHeader } from '@hooks/useNavigationHeader' +import { usePreventScreenCapture } from '@hooks/usePreventScreenCapture' import { QRScannerView } from '@components/QRScannerView' -import { WordSuggestionDropdown } from './WordSuggestionDropdown' +import { MnemonicSuggestionBar } from '@modules/onboarding/components/MnemonicSuggestionBar' + +const SCREEN_CAPTURE_TAG = 'import-account-mnemonic' export const ImportAccountScreen = () => { + usePreventScreenCapture(SCREEN_CAPTURE_TAG) const { theme } = useTheme() const insets = useSafeAreaInsets() + const headerHeight = useHeaderHeight() const { words, focused, @@ -45,8 +51,6 @@ export const ImportAccountScreen = () => { handleImportAccount, mnemonicLength, t, - isKeyboardVisible, - keyboardHeight, handleOpenSupportOptions, isQRScannerVisible, handleCloseQRScanner, @@ -54,8 +58,9 @@ export const ImportAccountScreen = () => { suggestions, handleSelectSuggestion, refCallbacks, + handleSubmitEditing, } = useImportAccountScreen() - const styles = useStyles({ insets, isKeyboardVisible, keyboardHeight }) + const styles = useStyles(insets) const wordsPerColumn = Math.ceil(mnemonicLength / 2) @@ -70,7 +75,11 @@ export const ImportAccountScreen = () => { return ( - + { offsetIndex, ) } + onSubmitEditing={() => + handleSubmitEditing( + offsetIndex, + ) + } + returnKeyType={ + offsetIndex === + mnemonicLength - + 1 + ? 'done' + : 'next' + } + blurOnSubmit={ + offsetIndex === + mnemonicLength - + 1 + } autoFocus={ column === 0 && @@ -173,16 +199,6 @@ export const ImportAccountScreen = () => { /> - {isFocused && ( - - )} ) })} @@ -192,6 +208,12 @@ export const ImportAccountScreen = () => { + + { }) expect(mockImportAccount).toHaveBeenCalled() - expect(mockPush).toHaveBeenCalledWith('SearchAccounts', { + expect(mockReplace).toHaveBeenCalledWith('SearchAccounts', { mode: 'import', walletKeyId: 'WALLET1', derivationType: 9, diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/styles.ts b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/styles.ts index 4d5d39061..574e0fe35 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/styles.ts +++ b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/styles.ts @@ -13,82 +13,71 @@ import { makeStyles } from '@rneui/themed' import { EdgeInsets } from 'react-native-safe-area-context' -type StyleProps = { - insets: EdgeInsets - isKeyboardVisible: boolean - keyboardHeight: number -} - -export const useStyles = makeStyles( - (theme, { insets, isKeyboardVisible, keyboardHeight }: StyleProps) => { - return { - mainContainer: { - flex: 1, - backgroundColor: theme.colors.background, - }, - wordContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: theme.spacing.lg, - }, - column: { - width: '47%', - }, - scrollContainer: { - flex: 1, - }, - scrollView: { - paddingHorizontal: theme.spacing.xl, - }, - footer: { - backgroundColor: theme.colors.background, - paddingHorizontal: theme.spacing.xl, - paddingTop: theme.spacing.md, - paddingBottom: - insets.bottom + - theme.spacing.md + - (isKeyboardVisible ? keyboardHeight : 0), - }, - inputContainerRow: { - marginTop: theme.spacing.sm, - flexDirection: 'row', - gap: theme.spacing.sm, - alignItems: 'center', - }, - focusedInputContainerRow: { - marginTop: theme.spacing.sm, - flexDirection: 'row', - gap: theme.spacing.sm, - alignItems: 'center', - }, - label: { - color: theme.colors.textGray, - }, - focusedLabel: { - color: theme.colors.textMain, - }, - inputWrapper: { - flex: 1, - }, - inputOuterContainer: { - flexShrink: 1, - }, - inputContainer: { - backgroundColor: theme.colors.background, - borderBottomWidth: theme.borders.sm, - borderBottomColor: theme.colors.layerGray, - flexShrink: 1, - }, - focusedInputContainer: { - backgroundColor: theme.colors.background, - borderBottomWidth: theme.borders.sm, - borderBottomColor: theme.colors.textMain, - flexShrink: 1, - }, - input: { - flexShrink: 1, - backgroundColor: 'transparent', - }, - } - }, -) +export const useStyles = makeStyles((theme, insets: EdgeInsets) => { + return { + mainContainer: { + flex: 1, + backgroundColor: theme.colors.background, + }, + wordContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: theme.spacing.lg, + }, + column: { + width: '47%', + }, + scrollContainer: { + flex: 1, + }, + scrollView: { + paddingHorizontal: theme.spacing.xl, + }, + footer: { + backgroundColor: theme.colors.background, + paddingHorizontal: theme.spacing.xl, + paddingTop: theme.spacing.md, + paddingBottom: theme.spacing.md + insets.bottom, + }, + inputContainerRow: { + marginTop: theme.spacing.sm, + flexDirection: 'row', + gap: theme.spacing.sm, + alignItems: 'center', + }, + focusedInputContainerRow: { + marginTop: theme.spacing.sm, + flexDirection: 'row', + gap: theme.spacing.sm, + alignItems: 'center', + }, + label: { + color: theme.colors.textGray, + }, + focusedLabel: { + color: theme.colors.textMain, + }, + inputWrapper: { + flex: 1, + }, + inputOuterContainer: { + flexShrink: 1, + }, + inputContainer: { + backgroundColor: theme.colors.background, + borderBottomWidth: theme.borders.sm, + borderBottomColor: theme.colors.layerGray, + flexShrink: 1, + }, + focusedInputContainer: { + backgroundColor: theme.colors.background, + borderBottomWidth: theme.borders.sm, + borderBottomColor: theme.colors.textMain, + flexShrink: 1, + }, + input: { + flexShrink: 1, + backgroundColor: 'transparent', + }, + } +}) diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/types.ts b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/types.ts index 54858dd62..0e72be473 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/types.ts +++ b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/types.ts @@ -24,8 +24,6 @@ export type UseImportAccountScreenResult = { handleImportAccount: () => void mnemonicLength: number t: (key: string) => string - isKeyboardVisible: boolean - keyboardHeight: number handleOpenSupportOptions: () => void isQRScannerVisible: boolean handleCloseQRScanner: () => void @@ -33,4 +31,5 @@ export type UseImportAccountScreenResult = { suggestions: string[] handleSelectSuggestion: (word: string) => void refCallbacks: ((ref: Nullable) => void)[] + handleSubmitEditing: (index: number) => void } diff --git a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/useImportAccountScreen.tsx b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/useImportAccountScreen.tsx index 7956bda81..e2e9e7866 100644 --- a/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/useImportAccountScreen.tsx +++ b/apps/mobile/src/modules/onboarding/screens/ImportAccountScreen/useImportAccountScreen.tsx @@ -10,7 +10,7 @@ limitations under the License */ -import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react' +import React, { useState, useCallback, useMemo } from 'react' import { Linking } from 'react-native' import * as Clipboard from 'expo-clipboard' @@ -23,31 +23,23 @@ import { type WalletAccount, } from '@perawallet/wallet-core-accounts' import { useMarkMnemonicBackupComplete } from '@perawallet/wallet-core-backup' -import { MNEMONIC_WORDLIST as WORDLIST } from '@perawallet/wallet-core-kms' import { config } from '@perawallet/wallet-core-config' -import type { PWInputRef } from '@components/core' import type { UseImportAccountScreenResult } from './types' import { useToast } from '@hooks/useToast' import { useLanguage } from '@hooks/useLanguage' import { useAppNavigation } from '@hooks/useAppNavigation' -import { - deferToNextCycle, - logger, - type Nullable, -} from '@perawallet/wallet-core-shared' +import { deferToNextCycle, logger } from '@perawallet/wallet-core-shared' import { useModalState } from '@hooks/useModalState' -import { useKeyboardHeight } from '@hooks/useKeyboardHeight' import { useDeepLink } from '@hooks/useDeepLink' import { DeeplinkType } from '@hooks/deeplink/types' import { useBottomSheet } from '@modules/bottom-sheet' +import { useMnemonicWordEntry } from '@modules/onboarding/hooks' import { ImportAccountSupportOptionsContent, type ImportAccountSupportOptionsContentResult, } from './ImportAccountSupportOptionsContent' -const MAX_SUGGESTIONS = 4 - export function useImportAccountScreen(): UseImportAccountScreenResult { const { params: { accountType }, @@ -60,16 +52,40 @@ export function useImportAccountScreen(): UseImportAccountScreenResult { const { parseDeeplink } = useDeepLink() const { request: requestBottomSheet } = useBottomSheet() - const { isKeyboardVisible, keyboardHeight } = useKeyboardHeight() - const mnemonicLength = MNEMONIC_WORD_COUNT[accountType] - const [words, setWords] = useState( - new Array(mnemonicLength).fill(''), - ) - const wordsRef = useRef(words) - wordsRef.current = words - const [focused, setFocused] = useState(0) + const onTooManyWords = useCallback(() => { + showToast({ + title: t('onboarding.import_account.invalid_mnemonic_title'), + body: t('onboarding.import_account.invalid_mnemonic_body'), + type: 'error', + }) + }, [showToast, t]) + + const onInsufficientSlots = useCallback(() => { + showToast({ + title: t('onboarding.import_account.insufficient_slots_title'), + body: t('onboarding.import_account.insufficient_slots_body'), + type: 'error', + }) + }, [showToast, t]) + + const { + words, + focused, + suggestions, + setFocused, + updateWord, + handleWordChange, + handleSelectSuggestion, + refCallbacks, + handleSubmitEditing, + } = useMnemonicWordEntry({ + wordCount: mnemonicLength, + onTooManyWords, + onInsufficientSlots, + }) + const [processing, setProcessing] = useState(false) const { isOpen: isQRScannerVisible, @@ -77,171 +93,15 @@ export function useImportAccountScreen(): UseImportAccountScreenResult { close: handleCloseQRScanner, } = useModalState() - const inputRefs = useRef[]>( - new Array(mnemonicLength).fill(null), - ) - - const refCallbacks = useMemo( - () => - Array.from( - { length: mnemonicLength }, - (_, i) => (ref: Nullable) => { - inputRefs.current[i] = ref - }, - ), - [mnemonicLength], - ) - - const focusInput = useCallback((index: number) => { - inputRefs.current[index]?.focus() - }, []) - - useEffect(() => { - focusInput(focused) - }, [focused, focusInput]) - + // Strict wordlist validation is deferred to `useImportAccount`, which + // surfaces typed errors (DuplicateAccountError, validation failures) the + // catch block translates into toasts. The button only gates on + // non-empty slots so the user can try the import and see the real + // failure, rather than the button silently never becoming tappable. + // (Contrast with ASB key entry, where pre-validating against the + // wordlist avoids an opaque decryption failure later.) const canImport = useMemo(() => words.every(w => w.length > 0), [words]) - const suggestions = useMemo(() => { - const currentWord = words[focused]?.toLowerCase() ?? '' - - if (currentWord.length === 0) { - return [] - } - - const matches: string[] = [] - - for (const word of WORDLIST) { - if (word.startsWith(currentWord)) { - matches.push(word) - - if (matches.length >= MAX_SUGGESTIONS) { - break - } - } - } - - // If the only match is an exact match, no suggestions needed - if (matches.length === 1 && matches[0] === currentWord) { - return [] - } - - return matches - }, [words, focused]) - - const handleSelectSuggestion = useCallback( - (word: string) => { - setWords(prev => { - const next = [...prev] - - next[focused] = word - return next - }) - - const nextIndex = focused + 1 - - if (nextIndex < mnemonicLength) { - setFocused(nextIndex) - } - }, - [focused, mnemonicLength], - ) - - const updateWord = useCallback( - (word: string, index: number) => { - const trimmedValue = word.trim() - const splitWords = trimmedValue.split(/\s+/).filter(Boolean) - - if (splitWords.length > 1) { - // Case: Pasted content is a full mnemonic of the expected length - if (splitWords.length === mnemonicLength) { - setWords(splitWords) - return - } - - // Case: Pasted content is larger than the total expected mnemonic length - if (splitWords.length > mnemonicLength) { - showToast({ - title: t( - 'onboarding.import_account.invalid_mnemonic_title', - ), - body: t( - 'onboarding.import_account.invalid_mnemonic_body', - ), - type: 'error', - }) - return - } - - // Case: Pasted content is smaller than the total expected length - const remainingSlots = mnemonicLength - index - - if (splitWords.length <= remainingSlots) { - setWords(prev => { - const next = [...prev] - - splitWords.forEach((w, i) => { - next[index + i] = w - }) - return next - }) - } else { - showToast({ - title: t( - 'onboarding.import_account.insufficient_slots_title', - ), - body: t( - 'onboarding.import_account.insufficient_slots_body', - ), - type: 'error', - }) - } - return - } - - setWords(prev => { - const next = [...prev] - - next[index] = word.trim() - return next - }) - }, - [mnemonicLength, showToast, t], - ) - - const handleWordChange = useCallback( - async (text: string, index: number) => { - const currentWord = wordsRef.current[index] ?? '' - - if (text.length - currentWord.length > 1) { - try { - const clipboardContent = await Clipboard.getStringAsync() - - if (clipboardContent) { - const clipboardWords = clipboardContent - .trim() - .split(/\s+/) - .filter(Boolean) - const receivedWords = text - .trim() - .split(/\s+/) - .filter(Boolean) - - if (clipboardWords.length > receivedWords.length) { - updateWord(clipboardContent, index) - return - } - } - } catch { - // Clipboard read failed; fall through - } - } - - updateWord(text, index) - }, - [updateWord], - ) - const handleImportAccount = useCallback(() => { setProcessing(true) deferToNextCycle(async () => { @@ -253,11 +113,16 @@ export function useImportAccountScreen(): UseImportAccountScreenResult { type: accountType, }) + // `replace` (not `push`) so this screen unmounts and the typed + // mnemonic held in the input hook is dropped for GC. Strings + // can't be zeroed in JS, but the reference goes away — and + // back-navigating from later steps no longer lands on a + // stale Import screen with prefilled words. if (result.type === 'hdWallet' && 'walletKeyId' in result) { // HD import: jump into the discovery flow. Backup is marked // only after the user commits a selection (see // ImportSelectAddressesScreen). - navigation.push('SearchAccounts', { + navigation.replace('SearchAccounts', { mode: 'import', walletKeyId: result.walletKeyId, derivationType: result.derivationType, @@ -266,7 +131,7 @@ export function useImportAccountScreen(): UseImportAccountScreenResult { // algo25 import: the account already exists. Mark backup and // route through the existing post-create discovery. markBackupComplete(result as WalletAccount) - navigation.push('SearchAccounts', { + navigation.replace('SearchAccounts', { account: result as WalletAccount, }) } @@ -372,8 +237,6 @@ export function useImportAccountScreen(): UseImportAccountScreenResult { handleImportAccount, mnemonicLength, t, - isKeyboardVisible, - keyboardHeight, handleOpenSupportOptions, isQRScannerVisible, handleCloseQRScanner, @@ -381,5 +244,6 @@ export function useImportAccountScreen(): UseImportAccountScreenResult { suggestions, handleSelectSuggestion, refCallbacks, + handleSubmitEditing, } } diff --git a/apps/mobile/src/modules/onboarding/utils/__tests__/splitMnemonic.spec.ts b/apps/mobile/src/modules/onboarding/utils/__tests__/splitMnemonic.spec.ts new file mode 100644 index 000000000..f23fba65c --- /dev/null +++ b/apps/mobile/src/modules/onboarding/utils/__tests__/splitMnemonic.spec.ts @@ -0,0 +1,61 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, it, expect } from 'vitest' +import { splitMnemonic } from '../splitMnemonic' + +describe('splitMnemonic', () => { + it('splits a space-separated mnemonic', () => { + expect(splitMnemonic('alpha beta gamma')).toEqual([ + 'alpha', + 'beta', + 'gamma', + ]) + }) + + it('splits a comma-separated mnemonic', () => { + expect(splitMnemonic('alpha,beta,gamma')).toEqual([ + 'alpha', + 'beta', + 'gamma', + ]) + }) + + it('splits a comma+space-separated mnemonic', () => { + expect(splitMnemonic('alpha, beta, gamma')).toEqual([ + 'alpha', + 'beta', + 'gamma', + ]) + }) + + it('tolerates any mix of newlines, tabs, spaces, and commas', () => { + expect(splitMnemonic('alpha,\n beta\t,gamma\n')).toEqual([ + 'alpha', + 'beta', + 'gamma', + ]) + }) + + it('trims leading and trailing whitespace and commas', () => { + expect(splitMnemonic(' ,alpha beta, ')).toEqual(['alpha', 'beta']) + }) + + it('returns an empty array for blank input', () => { + expect(splitMnemonic('')).toEqual([]) + expect(splitMnemonic(' ,,, \n\t')).toEqual([]) + }) + + it('returns a single-element array for a single word', () => { + expect(splitMnemonic('alpha')).toEqual(['alpha']) + }) +}) diff --git a/apps/mobile/src/modules/onboarding/utils/index.ts b/apps/mobile/src/modules/onboarding/utils/index.ts new file mode 100644 index 000000000..70d81f393 --- /dev/null +++ b/apps/mobile/src/modules/onboarding/utils/index.ts @@ -0,0 +1,13 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +export { splitMnemonic } from './splitMnemonic' diff --git a/apps/mobile/src/modules/onboarding/utils/splitMnemonic.ts b/apps/mobile/src/modules/onboarding/utils/splitMnemonic.ts new file mode 100644 index 000000000..52b45506f --- /dev/null +++ b/apps/mobile/src/modules/onboarding/utils/splitMnemonic.ts @@ -0,0 +1,22 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +/** + * Split a pasted mnemonic blob into individual words. Accepts any mix of + * whitespace and commas as separators so users can paste `a b c`, `a,b,c`, + * or `a, b, c` regardless of how their source app formatted the list. + */ +export const splitMnemonic = (value: string): string[] => + value + .trim() + .split(/[\s,]+/) + .filter(Boolean) diff --git a/apps/mobile/vitest.integration-setup.ts b/apps/mobile/vitest.integration-setup.ts index 30e5a4481..92cf6b64b 100644 --- a/apps/mobile/vitest.integration-setup.ts +++ b/apps/mobile/vitest.integration-setup.ts @@ -97,6 +97,16 @@ vi.mock('@react-navigation/stack', async () => { } }) +// `useHeaderHeight` throws when the screen isn't inside a real +// header-bearing navigator. The test navigator above renders screens +// without a header, so any production screen using `useHeaderHeight` +// (e.g. for `KeyboardAvoidingView.keyboardVerticalOffset`) would crash. +// Stub it to 0 — flow tests don't care about layout offsets. +vi.mock('@react-navigation/elements', async importOriginal => ({ + ...(await importOriginal()), + useHeaderHeight: () => 0, +})) + // SVGs used by onboarding screens. svgr emits real React SVG components, // but rendering them under jsdom triggers `InvalidCharacterError` on // attributes whose value is a long data URL (jsdom tries to use it as an @@ -140,6 +150,14 @@ vi.mock('@assets/icons/check.svg', () => { React.createElement('div', { ...props, 'data-testid': 'SvgIcon' }), } }) +// Shield glyph rendered on the ASB import info screen. +vi.mock('@assets/icons/shield-check.svg', () => { + const React = require('react') + return { + default: (props: Record) => + React.createElement('div', { ...props, 'data-testid': 'SvgIcon' }), + } +}) // `expo-modules-core` references the React-Native `__DEV__` global at // module-load time (in `setUpJsLogger.fx.ts`). Under jsdom this is diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts index d3bca96cf..636661c2d 100644 --- a/apps/mobile/vitest.setup.ts +++ b/apps/mobile/vitest.setup.ts @@ -727,6 +727,26 @@ vi.mock('expo-clipboard', () => ({ getStringAsync: vi.fn(), })) +// `expo-modules-core` references `__DEV__` at module-load time in +// `setUpJsLogger.fx.ts`; under jsdom this is undefined and any expo-* package +// that transitively imports it crashes during import. Define the global up +// front so consumers that aren't separately mocked (e.g. expo-screen-capture) +// can be loaded by the routes barrel under unit tests. +// +// Side-effect: every unit test now sees `__DEV__ === false`. App code that +// gates dev-only logging/asserts on `__DEV__` will run as if in a production +// build. If a spec ever wants to exercise a dev-mode branch it should set +// the global itself (and restore it afterwards) rather than rely on the +// default. +;(globalThis as { __DEV__?: boolean }).__DEV__ = false + +vi.mock('expo-screen-capture', () => ({ + preventScreenCaptureAsync: vi.fn().mockResolvedValue(undefined), + allowScreenCaptureAsync: vi.fn().mockResolvedValue(undefined), + addScreenshotListener: vi.fn(() => ({ remove: vi.fn() })), + removeScreenshotListener: vi.fn(), +})) + // `expo-file-system` transitively imports `expo-modules-core`, which probes // `__DEV__` at module init and crashes under jsdom. Stub the `File` class // to the surface the ASB import screen actually uses (the static picker +