Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/mobile/src/__integration__/onboarding-import-asb.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
})
4 changes: 4 additions & 0 deletions apps/mobile/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<PWView style={styles.container}>
<PWView style={styles.bar}>
{suggestions.map(word => (
<PWTouchableOpacity
key={word}
style={styles.suggestionItem}
onPress={() => onSelectSuggestion(word)}
testID={`suggestion-${word}`}
style={styles.pill}
testID={`${testIDPrefix}_${word}`}
>
<PWText
variant='body'
style={styles.suggestionText}
>
{word}
</PWText>
<PWText variant='h4'>{word}</PWText>
</PWTouchableOpacity>
))}
</PWView>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<MnemonicSuggestionBar
suggestions={['abandon', 'ability', 'able']}
onSelectSuggestion={vi.fn()}
/>,
)

expect(screen.getByText('abandon')).toBeTruthy()
expect(screen.getByText('ability')).toBeTruthy()
expect(screen.getByText('able')).toBeTruthy()
})

it('renders nothing when there are no suggestions', () => {
render(
<MnemonicSuggestionBar
suggestions={[]}
onSelectSuggestion={vi.fn()}
testIDPrefix='test_suggestion'
/>,
)

expect(screen.queryByTestId(/^test_suggestion_/)).toBeNull()
})

it('invokes onSelectSuggestion with the tapped word', () => {
const onSelectSuggestion = vi.fn()
render(
<MnemonicSuggestionBar
suggestions={['abandon', 'ability']}
onSelectSuggestion={onSelectSuggestion}
testIDPrefix='test_suggestion'
/>,
)

fireEvent.click(screen.getByTestId('test_suggestion_ability'))

expect(onSelectSuggestion).toHaveBeenCalledWith('ability')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@
limitations under the License
*/

export { WordSuggestionDropdown } from './WordSuggestionDropdown'
export type { WordSuggestionDropdownProps } from './WordSuggestionDropdown'
export {
MnemonicSuggestionBar,
type MnemonicSuggestionBarProps,
} from './MnemonicSuggestionBar'
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}))
Loading
Loading