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 +