diff --git a/core/.changelog.d/3458.added b/core/.changelog.d/3458.added new file mode 100644 index 000000000..7ae079a70 --- /dev/null +++ b/core/.changelog.d/3458.added @@ -0,0 +1 @@ +Allow for going back to previous word in recovery process diff --git a/core/assets/model_r/arrow_left_big.png b/core/assets/model_r/arrow_left_big.png new file mode 100644 index 000000000..5bcebfc79 Binary files /dev/null and b/core/assets/model_r/arrow_left_big.png differ diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index 31cc785ae..7ed8f8416 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -143,6 +143,7 @@ static void _librust_qstrs(void) { MP_QSTR_buttons__try_again; MP_QSTR_buttons__turn_off; MP_QSTR_buttons__turn_on; + MP_QSTR_can_go_back; MP_QSTR_cancel_arrow; MP_QSTR_cancel_cross; MP_QSTR_cardano__addr_base; @@ -431,6 +432,7 @@ static void _librust_qstrs(void) { MP_QSTR_inputs__cancel; MP_QSTR_inputs__delete; MP_QSTR_inputs__enter; + MP_QSTR_inputs__previous; MP_QSTR_inputs__return; MP_QSTR_inputs__show; MP_QSTR_inputs__space; @@ -595,6 +597,7 @@ static void _librust_qstrs(void) { MP_QSTR_plurals__transaction_of_x_operations; MP_QSTR_plurals__x_groups_needed; MP_QSTR_plurals__x_shares_needed; + MP_QSTR_prefill_word; MP_QSTR_progress__authenticity_check; MP_QSTR_progress__done; MP_QSTR_progress__loading_transaction; diff --git a/core/embed/rust/src/translations/generated/translated_string.rs b/core/embed/rust/src/translations/generated/translated_string.rs index ce7584a11..9702a9b4c 100644 --- a/core/embed/rust/src/translations/generated/translated_string.rs +++ b/core/embed/rust/src/translations/generated/translated_string.rs @@ -843,6 +843,7 @@ pub enum TranslatedString { words__writable = 830, words__yes = 831, reboot_to_bootloader__just_a_moment = 832, + inputs__previous = 833, } impl TranslatedString { @@ -1681,6 +1682,7 @@ impl TranslatedString { Self::words__writable => "Writable", Self::words__yes => "Yes", Self::reboot_to_bootloader__just_a_moment => "Just a moment...", + Self::inputs__previous => "PREVIOUS", } } @@ -2520,6 +2522,7 @@ impl TranslatedString { Qstr::MP_QSTR_words__writable => Some(Self::words__writable), Qstr::MP_QSTR_words__yes => Some(Self::words__yes), Qstr::MP_QSTR_reboot_to_bootloader__just_a_moment => Some(Self::reboot_to_bootloader__just_a_moment), + Qstr::MP_QSTR_inputs__previous => Some(Self::inputs__previous), _ => None, } } diff --git a/core/embed/rust/src/ui/component/maybe.rs b/core/embed/rust/src/ui/component/maybe.rs index cc8a1cb0e..739f1ab82 100644 --- a/core/embed/rust/src/ui/component/maybe.rs +++ b/core/embed/rust/src/ui/component/maybe.rs @@ -11,7 +11,8 @@ pub struct Maybe { } impl Maybe { - pub fn new(pad: Pad, inner: T, visible: bool) -> Self { + pub fn new(bg_color: Color, inner: T, visible: bool) -> Self { + let pad = Pad::with_background(bg_color); Self { inner, visible, @@ -19,12 +20,12 @@ impl Maybe { } } - pub fn visible(clear: Color, inner: T) -> Self { - Self::new(Pad::with_background(clear), inner, true) + pub fn visible(bg_color: Color, inner: T) -> Self { + Self::new(bg_color, inner, true) } - pub fn hidden(clear: Color, inner: T) -> Self { - Self::new(Pad::with_background(clear), inner, false) + pub fn hidden(bg_color: Color, inner: T) -> Self { + Self::new(bg_color, inner, false) } } diff --git a/core/embed/rust/src/ui/model_tr/component/input_methods/wordlist.rs b/core/embed/rust/src/ui/model_tr/component/input_methods/wordlist.rs index 7f81f6098..f04f88893 100644 --- a/core/embed/rust/src/ui/model_tr/component/input_methods/wordlist.rs +++ b/core/embed/rust/src/ui/model_tr/component/input_methods/wordlist.rs @@ -15,6 +15,7 @@ enum WordlistAction { Letter(char), Word(&'static str), Delete, + Previous, } const MAX_WORD_LENGTH: usize = 10; @@ -47,6 +48,9 @@ struct ChoiceFactoryWordlist { offer_words: bool, /// We want to randomize the order in which we show the words word_random_order: Vec, + /// Whether the input is empty - and we should show PREVIOUS instead of + /// DELETE + empty_input: bool, } impl ChoiceFactoryWordlist { @@ -72,6 +76,7 @@ impl ChoiceFactoryWordlist { wordlist, offer_words, word_random_order, + empty_input: prefix.is_empty(), } } } @@ -93,17 +98,31 @@ impl ChoiceFactory for ChoiceFactoryWordlist { // Putting DELETE as the first option in both cases // (is a requirement for WORDS, doing it for LETTERS as well to unite it) if choice_index == DELETE_INDEX { - return ( - TR::inputs__delete.map_translated(|t| { - ChoiceItem::new( - t, - ButtonLayout::arrow_armed_arrow(TR::buttons__confirm.into()), - ) - .with_icon(theme::ICON_DELETE) - .with_middle_action_without_release() - }), - WordlistAction::Delete, - ); + if self.empty_input { + return ( + TR::inputs__previous.map_translated(|t| { + ChoiceItem::new( + t, + ButtonLayout::arrow_armed_arrow(TR::buttons__select.into()), + ) + .with_icon(theme::ICON_DELETE) + .with_middle_action_without_release() + }), + WordlistAction::Previous, + ); + } else { + return ( + TR::inputs__delete.map_translated(|t| { + ChoiceItem::new( + t, + ButtonLayout::arrow_armed_arrow(TR::buttons__select.into()), + ) + .with_icon(theme::ICON_DELETE) + .with_middle_action_without_release() + }), + WordlistAction::Delete, + ); + } } if self.offer_words { // Taking a random (but always the same) word on this position @@ -140,10 +159,12 @@ pub struct WordlistEntry { textbox: TextBox, offer_words: bool, wordlist_type: WordlistType, + /// Whether going back is allowed (is not on the very first word). + can_go_back: bool, } impl WordlistEntry { - pub fn new(wordlist_type: WordlistType) -> Self { + pub fn new(wordlist_type: WordlistType, can_go_back: bool) -> Self { let choices = ChoiceFactoryWordlist::new(wordlist_type, ""); let choices_count = ::count(&choices); Self { @@ -156,6 +177,27 @@ impl WordlistEntry { textbox: TextBox::empty(), offer_words: false, wordlist_type, + can_go_back, + } + } + + pub fn prefilled_word(word: &str, wordlist_type: WordlistType, can_go_back: bool) -> Self { + // Word may be empty string, fallback to normal input + if word.is_empty() { + return Self::new(wordlist_type, can_go_back); + } + + let choices = ChoiceFactoryWordlist::new(wordlist_type, word); + Self { + // Showing the chosen word at index 1 + choice_page: ChoicePage::new(choices) + .with_incomplete(true) + .with_initial_page_counter(1), + chosen_letters: Child::new(ChangingTextLine::center_mono(String::from(word))), + textbox: TextBox::new(String::from(word)), + offer_words: false, + wordlist_type, + can_go_back, } } @@ -238,6 +280,11 @@ impl Component for WordlistEntry { fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { if let Some((action, long_press)) = self.choice_page.event(ctx, event) { match action { + WordlistAction::Previous => { + if self.can_go_back { + return Some(""); + } + } WordlistAction::Delete => { // Deleting all when long-pressed if long_press { diff --git a/core/embed/rust/src/ui/model_tr/layout.rs b/core/embed/rust/src/ui/model_tr/layout.rs index 4dc3b36fd..6381256ff 100644 --- a/core/embed/rust/src/ui/model_tr/layout.rs +++ b/core/embed/rust/src/ui/model_tr/layout.rs @@ -1347,9 +1347,19 @@ extern "C" fn new_request_passphrase(n_args: usize, args: *const Obj, kwargs: *m extern "C" fn new_request_bip39(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { let block = |_args: &[Obj], kwargs: &Map| { let prompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?; + let prefill_word: StrBuffer = kwargs.get(Qstr::MP_QSTR_prefill_word)?.try_into()?; + let can_go_back: bool = kwargs.get(Qstr::MP_QSTR_can_go_back)?.try_into()?; let obj = LayoutObj::new( - Frame::new(prompt, WordlistEntry::new(WordlistType::Bip39)).with_title_centered(), + Frame::new( + prompt, + WordlistEntry::prefilled_word( + prefill_word.as_ref(), + WordlistType::Bip39, + can_go_back, + ), + ) + .with_title_centered(), )?; Ok(obj.into()) }; @@ -1359,9 +1369,19 @@ extern "C" fn new_request_bip39(n_args: usize, args: *const Obj, kwargs: *mut Ma extern "C" fn new_request_slip39(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { let block = |_args: &[Obj], kwargs: &Map| { let prompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?; + let prefill_word: StrBuffer = kwargs.get(Qstr::MP_QSTR_prefill_word)?.try_into()?; + let can_go_back: bool = kwargs.get(Qstr::MP_QSTR_can_go_back)?.try_into()?; let obj = LayoutObj::new( - Frame::new(prompt, WordlistEntry::new(WordlistType::Slip39)).with_title_centered(), + Frame::new( + prompt, + WordlistEntry::prefilled_word( + prefill_word.as_ref(), + WordlistType::Slip39, + can_go_back, + ), + ) + .with_title_centered(), )?; Ok(obj.into()) }; @@ -1972,6 +1992,8 @@ pub static mp_module_trezorui2: Module = obj_module! { /// def request_bip39( /// *, /// prompt: str, + /// prefill_word: str, + /// can_go_back: bool, /// ) -> str: /// """Get recovery word for BIP39.""" Qstr::MP_QSTR_request_bip39 => obj_fn_kw!(0, new_request_bip39).as_obj(), @@ -1979,6 +2001,8 @@ pub static mp_module_trezorui2: Module = obj_module! { /// def request_slip39( /// *, /// prompt: str, + /// prefill_word: str, + /// can_go_back: bool, /// ) -> str: /// """SLIP39 word input keyboard.""" Qstr::MP_QSTR_request_slip39 => obj_fn_kw!(0, new_request_slip39).as_obj(), diff --git a/core/embed/rust/src/ui/model_tr/res/arrow_left_big.toif b/core/embed/rust/src/ui/model_tr/res/arrow_left_big.toif new file mode 100644 index 000000000..4bffdca91 Binary files /dev/null and b/core/embed/rust/src/ui/model_tr/res/arrow_left_big.toif differ diff --git a/core/embed/rust/src/ui/model_tr/theme/mod.rs b/core/embed/rust/src/ui/model_tr/theme/mod.rs index c196eeaf5..6728727af 100644 --- a/core/embed/rust/src/ui/model_tr/theme/mod.rs +++ b/core/embed/rust/src/ui/model_tr/theme/mod.rs @@ -63,6 +63,7 @@ pub fn textstyle_number(num: i32) -> &'static TextStyle { include_icon!(ICON_ARM_LEFT, "model_tr/res/arm_left.toif"); // 10*6 include_icon!(ICON_ARM_RIGHT, "model_tr/res/arm_right.toif"); // 10*6 include_icon!(ICON_ARROW_LEFT, "model_tr/res/arrow_left.toif"); // 4*7 +include_icon!(ICON_ARROW_LEFT_BIG, "model_tr/res/arrow_left_big.toif"); // 8*7 include_icon!(ICON_ARROW_RIGHT, "model_tr/res/arrow_right.toif"); // 4*7 include_icon!(ICON_ARROW_RIGHT_FAT, "model_tr/res/arrow_right_fat.toif"); // 4*8 include_icon!( diff --git a/core/embed/rust/src/ui/model_tt/component/keyboard/bip39.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/bip39.rs index 84898239b..5aa9cb16b 100644 --- a/core/embed/rust/src/ui/model_tt/component/keyboard/bip39.rs +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/bip39.rs @@ -16,6 +16,7 @@ use crate::{ }, }, }; +use heapless::String; const MAX_LENGTH: usize = 8; @@ -171,6 +172,23 @@ impl Bip39Input { } } + pub fn prefilled_word(word: &str) -> Self { + // Word may be empty string, fallback to normal input + if word.is_empty() { + return Self::new(); + } + + // Styling the input to reflect already filled word + Self { + button: Button::with_icon(theme::ICON_LIST_CHECK).styled(theme::button_pin_confirm()), + textbox: TextBox::new(String::from(word)), + multi_tap: MultiTapKeyboard::new(), + options_num: bip39::options_num(word), + suggested_word: bip39::complete_word(word), + button_suggestion: Button::empty().styled(theme::button_suggestion_confirm()), + } + } + /// Compute a bitmask of all letters contained in given key text. Lowest bit /// is 'a', second lowest 'b', etc. fn key_mask(key: usize) -> u32 { diff --git a/core/embed/rust/src/ui/model_tt/component/keyboard/mnemonic.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/mnemonic.rs index 49332c86d..0ca86598d 100644 --- a/core/embed/rust/src/ui/model_tt/component/keyboard/mnemonic.rs +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/mnemonic.rs @@ -2,7 +2,7 @@ use crate::ui::{ component::{maybe::paint_overlapping, Child, Component, Event, EventCtx, Label, Maybe}, geometry::{Alignment2D, Grid, Offset, Rect}, model_tt::{ - component::{Button, ButtonMsg}, + component::{Button, ButtonMsg, Swipe, SwipeDirection}, theme, }, }; @@ -11,6 +11,7 @@ pub const MNEMONIC_KEY_COUNT: usize = 9; pub enum MnemonicKeyboardMsg { Confirmed, + Previous, } pub struct MnemonicKeyboard { @@ -22,6 +23,10 @@ pub struct MnemonicKeyboard { input: Child>, /// Key buttons. keys: [Child>; MNEMONIC_KEY_COUNT], + /// Swipe controller - allowing for going to the previous word. + swipe: Swipe, + /// Whether going back is allowed (is not on the very first word). + can_go_back: bool, } impl MnemonicKeyboard @@ -29,13 +34,17 @@ where T: MnemonicInput, U: AsRef, { - pub fn new(input: T, prompt: U) -> Self { + pub fn new(input: T, prompt: U, can_go_back: bool) -> Self { + // Input might be already pre-filled + let prompt_visible = input.is_empty(); + Self { - prompt: Child::new(Maybe::visible( + prompt: Child::new(Maybe::new( theme::BG, Label::centered(prompt, theme::label_keyboard_prompt()), + prompt_visible, )), - back: Child::new(Maybe::hidden( + back: Child::new(Maybe::new( theme::BG, Button::with_icon_blend( theme::IMAGE_BG_BACK_BTN_TALL, @@ -44,11 +53,14 @@ where ) .styled(theme::button_reset()) .with_long_press(theme::ERASE_HOLD_DURATION), + !prompt_visible, )), - input: Child::new(Maybe::hidden(theme::BG, input)), + input: Child::new(Maybe::new(theme::BG, input, !prompt_visible)), keys: T::keys() .map(|t| Button::with_text(t).styled(theme::button_pin())) .map(Child::new), + swipe: Swipe::new().right(), + can_go_back, } } @@ -106,6 +118,7 @@ where let prompt_size = self.prompt.inner().inner().max_size(); let prompt_area = Rect::snap(prompt_center, prompt_size, Alignment2D::CENTER); + self.swipe.place(bounds); self.prompt.place(prompt_area); self.back.place(back_area); self.input.place(input_area); @@ -116,6 +129,13 @@ where } fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + // Swipe will cause going back to the previous word when allowed. + if self.can_go_back { + if let Some(SwipeDirection::Right) = self.swipe.event(ctx, event) { + return Some(MnemonicKeyboardMsg::Previous); + } + } + match self.input.event(ctx, event) { Some(MnemonicInputMsg::Confirmed) => { // Confirmed, bubble up. diff --git a/core/embed/rust/src/ui/model_tt/component/keyboard/pin.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/pin.rs index 82a76081d..d45e4c473 100644 --- a/core/embed/rust/src/ui/model_tt/component/keyboard/pin.rs +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/pin.rs @@ -79,8 +79,7 @@ where let erase_btn = Maybe::hidden(theme::BG, erase_btn).into_child(); let cancel_btn = Button::with_icon(theme::ICON_CANCEL).styled(theme::button_cancel()); - let cancel_btn = - Maybe::new(Pad::with_background(theme::BG), cancel_btn, allow_cancel).into_child(); + let cancel_btn = Maybe::new(theme::BG, cancel_btn, allow_cancel).into_child(); Self { allow_cancel, diff --git a/core/embed/rust/src/ui/model_tt/component/keyboard/slip39.rs b/core/embed/rust/src/ui/model_tt/component/keyboard/slip39.rs index 1f9cb31a9..3f927392b 100644 --- a/core/embed/rust/src/ui/model_tt/component/keyboard/slip39.rs +++ b/core/embed/rust/src/ui/model_tt/component/keyboard/slip39.rs @@ -203,6 +203,61 @@ impl Slip39Input { } } + pub fn prefilled_word(word: &str) -> Self { + // Word may be empty string, fallback to normal input + if word.is_empty() { + return Self::new(); + } + + let (buff, input_mask, final_word) = Self::setup_from_prefilled_word(word); + + Self { + // Button has the same style the whole time + button: Button::empty().styled(theme::button_pin_confirm()), + textbox: TextBox::new(buff), + multi_tap: MultiTapKeyboard::new(), + final_word, + input_mask, + } + } + + fn setup_from_prefilled_word( + word: &str, + ) -> (String, Slip39Mask, Option<&'static str>) { + let mut buff: String = String::new(); + + // Gradually appending encoded key digits to the buffer and checking if + // have not already formed a final word. + for ch in word.chars() { + let mut index = 0; + for (i, key) in Self::keys().iter().enumerate() { + if key.contains(ch) { + index = i; + break; + } + } + buff.push(Self::key_digit(index)) + .assert_if_debugging_ui("Text buffer is too small"); + + let sequence: Option = buff.parse().ok(); + let input_mask = sequence + .and_then(slip39::word_completion_mask) + .map(Slip39Mask) + .unwrap_or_else(Slip39Mask::full); + let final_word = if input_mask.is_final() { + sequence.and_then(slip39::button_sequence_to_word) + } else { + None + }; + + // As soon as we have a final word, we can stop. + if final_word.is_some() { + return (buff, input_mask, final_word); + } + } + (buff, Slip39Mask::full(), None) + } + /// Convert a key index into the key digit. This is what we push into the /// input buffer. /// diff --git a/core/embed/rust/src/ui/model_tt/layout.rs b/core/embed/rust/src/ui/model_tt/layout.rs index c995640fe..c8264bdd8 100644 --- a/core/embed/rust/src/ui/model_tt/layout.rs +++ b/core/embed/rust/src/ui/model_tt/layout.rs @@ -177,6 +177,7 @@ where panic!("invalid mnemonic") } } + MnemonicKeyboardMsg::Previous => "".try_into(), } } } @@ -1270,7 +1271,13 @@ extern "C" fn new_request_passphrase(n_args: usize, args: *const Obj, kwargs: *m extern "C" fn new_request_bip39(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { let block = move |_args: &[Obj], kwargs: &Map| { let prompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?; - let obj = LayoutObj::new(MnemonicKeyboard::new(Bip39Input::new(), prompt))?; + let prefill_word: StrBuffer = kwargs.get(Qstr::MP_QSTR_prefill_word)?.try_into()?; + let can_go_back: bool = kwargs.get(Qstr::MP_QSTR_can_go_back)?.try_into()?; + let obj = LayoutObj::new(MnemonicKeyboard::new( + Bip39Input::prefilled_word(prefill_word.as_ref()), + prompt, + can_go_back, + ))?; Ok(obj.into()) }; unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } @@ -1279,7 +1286,13 @@ extern "C" fn new_request_bip39(n_args: usize, args: *const Obj, kwargs: *mut Ma extern "C" fn new_request_slip39(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { let block = move |_args: &[Obj], kwargs: &Map| { let prompt: StrBuffer = kwargs.get(Qstr::MP_QSTR_prompt)?.try_into()?; - let obj = LayoutObj::new(MnemonicKeyboard::new(Slip39Input::new(), prompt))?; + let prefill_word: StrBuffer = kwargs.get(Qstr::MP_QSTR_prefill_word)?.try_into()?; + let can_go_back: bool = kwargs.get(Qstr::MP_QSTR_can_go_back)?.try_into()?; + let obj = LayoutObj::new(MnemonicKeyboard::new( + Slip39Input::prefilled_word(prefill_word.as_ref()), + prompt, + can_go_back, + ))?; Ok(obj.into()) }; unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } @@ -1936,6 +1949,8 @@ pub static mp_module_trezorui2: Module = obj_module! { /// def request_bip39( /// *, /// prompt: str, + /// prefill_word: str, + /// can_go_back: bool, /// ) -> str: /// """BIP39 word input keyboard.""" Qstr::MP_QSTR_request_bip39 => obj_fn_kw!(0, new_request_bip39).as_obj(), @@ -1943,6 +1958,8 @@ pub static mp_module_trezorui2: Module = obj_module! { /// def request_slip39( /// *, /// prompt: str, + /// prefill_word: str, + /// can_go_back: bool, /// ) -> str: /// """SLIP39 word input keyboard.""" Qstr::MP_QSTR_request_slip39 => obj_fn_kw!(0, new_request_slip39).as_obj(), diff --git a/core/mocks/generated/trezorui2.pyi b/core/mocks/generated/trezorui2.pyi index 33d29d4c2..96a2e6608 100644 --- a/core/mocks/generated/trezorui2.pyi +++ b/core/mocks/generated/trezorui2.pyi @@ -308,6 +308,8 @@ def request_passphrase( def request_bip39( *, prompt: str, + prefill_word: str, + can_go_back: bool, ) -> str: """Get recovery word for BIP39.""" @@ -316,6 +318,8 @@ def request_bip39( def request_slip39( *, prompt: str, + prefill_word: str, + can_go_back: bool, ) -> str: """SLIP39 word input keyboard.""" @@ -756,6 +760,8 @@ def request_passphrase( def request_bip39( *, prompt: str, + prefill_word: str, + can_go_back: bool, ) -> str: """BIP39 word input keyboard.""" @@ -764,6 +770,8 @@ def request_bip39( def request_slip39( *, prompt: str, + prefill_word: str, + can_go_back: bool, ) -> str: """SLIP39 word input keyboard.""" diff --git a/core/mocks/trezortranslate_keys.pyi b/core/mocks/trezortranslate_keys.pyi index 149785345..7e59f6d9b 100644 --- a/core/mocks/trezortranslate_keys.pyi +++ b/core/mocks/trezortranslate_keys.pyi @@ -337,6 +337,7 @@ class TR: inputs__cancel: str = "CANCEL" inputs__delete: str = "DELETE" inputs__enter: str = "ENTER" + inputs__previous: str = "PREVIOUS" inputs__return: str = "RETURN" inputs__show: str = "SHOW" inputs__space: str = "SPACE" diff --git a/core/src/apps/management/recovery_device/layout.py b/core/src/apps/management/recovery_device/layout.py index d49fbcf59..f442eeae9 100644 --- a/core/src/apps/management/recovery_device/layout.py +++ b/core/src/apps/management/recovery_device/layout.py @@ -50,15 +50,35 @@ async def request_mnemonic( await button_request("mnemonic", code=ButtonRequestType.MnemonicInput) - words: list[str] = [] - for i in range(word_count): + # Allowing to go back to previous words, therefore cannot use just loop over range(word_count) + words: list[str] = [""] * word_count + i = 0 + while True: + # All the words have been entered + if i >= word_count: + break + + # Prefilling the previously inputted word in case of going back word = await request_word( - i, word_count, is_slip39=backup_types.is_slip39_word_count(word_count) + i, + word_count, + is_slip39=backup_types.is_slip39_word_count(word_count), + prefill_word=words[i], ) - words.append(word) + + # User has decided to go back + if not word: + if i > 0: + i -= 1 + continue + + words[i] = word + + i += 1 try: - word_validity.check(backup_type, words) + non_empty_words = [word for word in words if word] + word_validity.check(backup_type, non_empty_words) except word_validity.AlreadyAdded: # show_share_already_added await show_recovery_warning( diff --git a/core/src/trezor/ui/layouts/tr/recovery.py b/core/src/trezor/ui/layouts/tr/recovery.py index f3c6236b8..4d21f59d1 100644 --- a/core/src/trezor/ui/layouts/tr/recovery.py +++ b/core/src/trezor/ui/layouts/tr/recovery.py @@ -18,15 +18,27 @@ async def request_word_count(dry_run: bool) -> int: return int(count) -async def request_word(word_index: int, word_count: int, is_slip39: bool) -> str: +async def request_word( + word_index: int, word_count: int, is_slip39: bool, prefill_word: str = "" +) -> str: from trezor.wire.context import wait prompt = TR.recovery__word_x_of_y_template.format(word_index + 1, word_count) + can_go_back = word_index > 0 + if is_slip39: - word_choice = RustLayout(trezorui2.request_slip39(prompt=prompt)) + word_choice = RustLayout( + trezorui2.request_slip39( + prompt=prompt, prefill_word=prefill_word, can_go_back=can_go_back + ) + ) else: - word_choice = RustLayout(trezorui2.request_bip39(prompt=prompt)) + word_choice = RustLayout( + trezorui2.request_bip39( + prompt=prompt, prefill_word=prefill_word, can_go_back=can_go_back + ) + ) word: str = await wait(word_choice) return word diff --git a/core/src/trezor/ui/layouts/tt/recovery.py b/core/src/trezor/ui/layouts/tt/recovery.py index 51687b582..10c8f3aa5 100644 --- a/core/src/trezor/ui/layouts/tt/recovery.py +++ b/core/src/trezor/ui/layouts/tt/recovery.py @@ -32,12 +32,23 @@ async def request_word_count(dry_run: bool) -> int: return int(count) -async def request_word(word_index: int, word_count: int, is_slip39: bool) -> str: +async def request_word( + word_index: int, word_count: int, is_slip39: bool, prefill_word: str = "" +) -> str: prompt = TR.recovery__type_word_x_of_y_template.format(word_index + 1, word_count) + can_go_back = word_index > 0 if is_slip39: - keyboard = RustLayout(trezorui2.request_slip39(prompt=prompt)) + keyboard = RustLayout( + trezorui2.request_slip39( + prompt=prompt, prefill_word=prefill_word, can_go_back=can_go_back + ) + ) else: - keyboard = RustLayout(trezorui2.request_bip39(prompt=prompt)) + keyboard = RustLayout( + trezorui2.request_bip39( + prompt=prompt, prefill_word=prefill_word, can_go_back=can_go_back + ) + ) word: str = await ctx_wait(keyboard) return word diff --git a/core/tools/translations/translate_missing.py b/core/tools/translations/translate_missing.py index 9469c0f14..61ec0eab0 100644 --- a/core/tools/translations/translate_missing.py +++ b/core/tools/translations/translate_missing.py @@ -19,7 +19,7 @@ def translate_dict( translated_text = translator.translate( value, src=from_lang, dest=to_lang ).text - new_dict[key] = translated_text + " (TODO)" + new_dict[key] = translated_text except Exception as e: print(f"Error translating {value}: {e}") new_dict[key] = MISSING_VALUE diff --git a/core/translations/cs.json b/core/translations/cs.json index 7348035ed..5786787f0 100644 --- a/core/translations/cs.json +++ b/core/translations/cs.json @@ -355,6 +355,7 @@ "inputs__cancel": "ZRUŠIT", "inputs__delete": "SMAZAT", "inputs__enter": "ZADAT", + "inputs__previous": "PŘEDCHOZÍ", "inputs__return": "VRÁTIT", "inputs__show": "UKÁZAT", "inputs__space": "MEZERNÍK", @@ -512,6 +513,7 @@ "progress__signing_transaction": "Podpis transakce...", "progress__syncing": "Synchronizace...", "progress__x_seconds_left_template": "Zbývá {} sekund", + "reboot_to_bootloader__just_a_moment": "Jenom chvilku...", "reboot_to_bootloader__restart": "Chcete restartovat Trezor v režimu bootloader?", "reboot_to_bootloader__title": "PŘEJÍT DO REŽIMU BOOTLOADER", "reboot_to_bootloader__version_by_template": "Verze firmwaru {}\nod {}", diff --git a/core/translations/de.json b/core/translations/de.json index d867bed5d..2fa25c2fd 100644 --- a/core/translations/de.json +++ b/core/translations/de.json @@ -355,6 +355,7 @@ "inputs__cancel": "ABBRECHEN", "inputs__delete": "LÖSCHEN", "inputs__enter": "EINGEBEN", + "inputs__previous": "VORHERIGE", "inputs__return": "ZURÜCK", "inputs__show": "ANZEIGEN", "inputs__space": "LEER", @@ -512,6 +513,7 @@ "progress__signing_transaction": "Transaktion wird signiert...", "progress__syncing": "Wird synchronisiert...", "progress__x_seconds_left_template": "{} Sekunden verbleibend", + "reboot_to_bootloader__just_a_moment": "Einen Augenblick...", "reboot_to_bootloader__restart": "Möchtest du Trezor im Bootloader-Modus neu starten?", "reboot_to_bootloader__title": "ZUM BOOTLOADER", "reboot_to_bootloader__version_by_template": "Firmware Version {}\nvon {}", diff --git a/core/translations/en.json b/core/translations/en.json index b23ea69b7..991e4ed19 100644 --- a/core/translations/en.json +++ b/core/translations/en.json @@ -343,6 +343,7 @@ "inputs__delete": "DELETE", "inputs__enter": "ENTER", "inputs__return": "RETURN", + "inputs__previous": "PREVIOUS", "inputs__show": "SHOW", "inputs__space": "SPACE", "joint__title": "JOINT TRANSACTION", diff --git a/core/translations/es.json b/core/translations/es.json index b283e1ef0..b75caa468 100644 --- a/core/translations/es.json +++ b/core/translations/es.json @@ -355,6 +355,7 @@ "inputs__cancel": "CANCELAR", "inputs__delete": "ELIMINAR", "inputs__enter": "INTRODUCIR", + "inputs__previous": "ANTERIOR", "inputs__return": "VOLVER", "inputs__show": "MOSTRAR", "inputs__space": "ESPACIO", @@ -512,6 +513,7 @@ "progress__signing_transaction": "Firmando transacción...", "progress__syncing": "Sincronizando...", "progress__x_seconds_left_template": "Quedan {} segundos", + "reboot_to_bootloader__just_a_moment": "Sólo un momento...", "reboot_to_bootloader__restart": "¿Deseas reiniciar el Trezor en modo bootloader?", "reboot_to_bootloader__title": "IR A BOOTLOADER", "reboot_to_bootloader__version_by_template": "Versión de firmware {}\npor {}", diff --git a/core/translations/fr.json b/core/translations/fr.json index 555b9dd8e..1eb86c758 100644 --- a/core/translations/fr.json +++ b/core/translations/fr.json @@ -355,6 +355,7 @@ "inputs__cancel": "ANNULER", "inputs__delete": "SUPPRIMER", "inputs__enter": "SAISIR", + "inputs__previous": "PREVIOUS", "inputs__return": "RETOUR", "inputs__show": "AFFICHER", "inputs__space": "ESPACE", @@ -512,6 +513,7 @@ "progress__signing_transaction": "Signature de la transaction en cours", "progress__syncing": "Synchronisation en cours", "progress__x_seconds_left_template": "{} secondes restantes", + "reboot_to_bootloader__just_a_moment": "Juste un moment...", "reboot_to_bootloader__restart": "Voulez-vous redémarrer Trezor en mode bootloader?", "reboot_to_bootloader__title": "ACCÉDER BOOTLOADER", "reboot_to_bootloader__version_by_template": "Version du firmware {}\npar {}", diff --git a/core/translations/order.json b/core/translations/order.json index 79ba4519b..4b2cc8a23 100644 --- a/core/translations/order.json +++ b/core/translations/order.json @@ -831,5 +831,6 @@ "829": "words__warning", "830": "words__writable", "831": "words__yes", - "832": "reboot_to_bootloader__just_a_moment" + "832": "reboot_to_bootloader__just_a_moment", + "833": "inputs__previous" } diff --git a/tests/buttons.py b/tests/buttons.py index b81dc8135..40a5ee695 100644 --- a/tests/buttons.py +++ b/tests/buttons.py @@ -24,6 +24,8 @@ OK = (RIGHT, BOTTOM) CANCEL = (LEFT, BOTTOM) INFO = (MID, BOTTOM) +RECOVERY_DELETE = (LEFT, TOP) + CORNER_BUTTON = (215, 25) CONFIRM_WORD = (MID, TOP) diff --git a/tests/click_tests/common.py b/tests/click_tests/common.py index e6753a669..380c2a0e7 100644 --- a/tests/click_tests/common.py +++ b/tests/click_tests/common.py @@ -4,6 +4,7 @@ from enum import Enum from typing import TYPE_CHECKING from .. import buttons +from .. import translations as TR if TYPE_CHECKING: from trezorlib.debuglink import DebugLink, LayoutContent @@ -147,3 +148,7 @@ def _move_one_closer( return debug.press_left(wait=True) else: return debug.press_right(wait=True) + + +def get_possible_btn_texts(path: str) -> str: + return "|".join(TR.translate(path)) diff --git a/tests/click_tests/recovery.py b/tests/click_tests/recovery.py index 891f4f824..61a7f0f21 100644 --- a/tests/click_tests/recovery.py +++ b/tests/click_tests/recovery.py @@ -2,12 +2,17 @@ from typing import TYPE_CHECKING from .. import buttons from .. import translations as TR -from .common import go_next +from .common import get_possible_btn_texts, go_next if TYPE_CHECKING: from trezorlib.debuglink import DebugLink, LayoutContent +DELETE_BTN_TEXTS = get_possible_btn_texts("inputs__delete") + get_possible_btn_texts( + "inputs__previous" +) + + def enter_word( debug: "DebugLink", word: str, is_slip39: bool = False ) -> "LayoutContent": @@ -117,6 +122,60 @@ def enter_shares(debug: "DebugLink", shares: list[str]) -> None: def enter_seed(debug: "DebugLink", seed_words: list[str]) -> None: + prepare_enter_seed(debug) + + for word in seed_words: + enter_word(debug, word, is_slip39=False) + + TR.assert_in(debug.read_layout().text_content(), "recovery__wallet_recovered") + + +def enter_seed_previous_correct( + debug: "DebugLink", seed_words: list[str], bad_indexes: dict[int, str] +) -> None: + prepare_enter_seed(debug) + + i = 0 + go_back = False + bad_word = "" + while True: + assert i >= 0 + + if i >= len(seed_words): + break + + if go_back: + go_back = False + if debug.model == "T": + debug.swipe_right(wait=True) + for _ in range(len(bad_word)): + debug.click(buttons.RECOVERY_DELETE, wait=True) + elif debug.model == "Safe 3": + layout = debug.read_layout() + + while layout.get_middle_choice() not in DELETE_BTN_TEXTS: + layout = debug.press_right(wait=True) + layout = debug.press_middle(wait=True) + + for _ in range(len(bad_word)): + while layout.get_middle_choice() not in DELETE_BTN_TEXTS: + layout = debug.press_left(wait=True) + layout = debug.press_middle(wait=True) + continue + + if i in bad_indexes: + word = bad_indexes.pop(i) + bad_word = word + go_back = True + else: + word = seed_words[i] + i += 1 + layout = enter_word(debug, word, is_slip39=False) + + TR.assert_in(debug.read_layout().text_content(), "recovery__wallet_recovered") + + +def prepare_enter_seed(debug: "DebugLink") -> None: TR.assert_in(debug.read_layout().text_content(), "recovery__enter_backup") if debug.model == "T": debug.click(buttons.OK, wait=True) @@ -124,14 +183,8 @@ def enter_seed(debug: "DebugLink", seed_words: list[str]) -> None: debug.press_right(wait=True) TR.assert_equals(debug.read_layout().title(), "recovery__title_recover") debug.press_right() - - debug.press_right(wait=True) - - assert "MnemonicKeyboard" in debug.read_layout().all_components() - for word in seed_words: - enter_word(debug, word, is_slip39=False) - - TR.assert_in(debug.read_layout().text_content(), "recovery__wallet_recovered") + layout = debug.press_right(wait=True) + assert "MnemonicKeyboard" in layout.all_components() def finalize(debug: "DebugLink") -> None: diff --git a/tests/click_tests/test_pin.py b/tests/click_tests/test_pin.py index 67e10d2ad..c005a574d 100644 --- a/tests/click_tests/test_pin.py +++ b/tests/click_tests/test_pin.py @@ -24,7 +24,12 @@ from trezorlib import device, exceptions from .. import buttons from .. import translations as TR -from .common import go_back, go_next, navigate_to_action_and_press +from .common import ( + get_possible_btn_texts, + go_back, + go_next, + navigate_to_action_and_press, +) if TYPE_CHECKING: from trezorlib.debuglink import DebugLink @@ -42,14 +47,9 @@ PIN24 = "875163065288639289952973" PIN50 = "31415926535897932384626433832795028841971693993751" PIN60 = PIN50 + "9" * 10 - -def _get_possible_btns(path: str) -> str: - return "|".join(TR.translate(path)) - - -DELETE = _get_possible_btns("inputs__delete") -SHOW = _get_possible_btns("inputs__show") -ENTER = _get_possible_btns("inputs__enter") +DELETE = get_possible_btn_texts("inputs__delete") +SHOW = get_possible_btn_texts("inputs__show") +ENTER = get_possible_btn_texts("inputs__enter") TR_PIN_ACTIONS = [ diff --git a/tests/click_tests/test_recovery.py b/tests/click_tests/test_recovery.py index d8ce6aba8..99e8a540f 100644 --- a/tests/click_tests/test_recovery.py +++ b/tests/click_tests/test_recovery.py @@ -68,3 +68,15 @@ def test_recovery_bip39(device_handler: "BackgroundDeviceHandler"): recovery.select_number_of_words(debug, num_of_words=12) recovery.enter_seed(debug, MNEMONIC12.split()) recovery.finalize(debug) + + +@pytest.mark.setup_client(uninitialized=True) +def test_recovery_bip39_previous_word(device_handler: "BackgroundDeviceHandler"): + with prepare_recovery_and_evaluate(device_handler) as debug: + recovery.confirm_recovery(debug) + + recovery.select_number_of_words(debug, num_of_words=12) + seed_words: list[str] = MNEMONIC12.split() + bad_indexes = {1: seed_words[-1], 7: seed_words[0]} + recovery.enter_seed_previous_correct(debug, seed_words, bad_indexes) + recovery.finalize(debug) diff --git a/tests/ui_tests/fixtures.json b/tests/ui_tests/fixtures.json index bd1beea65..43d943178 100644 --- a/tests/ui_tests/fixtures.json +++ b/tests/ui_tests/fixtures.json @@ -848,8 +848,9 @@ "TR_en_test_pin.py::test_pin_short": "a87619c455ec81763cbb133279f9e7fbb3b9a5a02c58e1da63640d51a4e107c3", "TR_en_test_pin.py::test_wipe_code_same_as_pin": "329962b3b949dc1429d361e4ae6c5224b4a75c981ae833fd3bf1618df44fbd2a", "TR_en_test_pin.py::test_wipe_code_setup": "4afe5ceb8f953892eea8b6b7c6997a1b5afa40b81372265a84a567b61773d89b", -"TR_en_test_recovery.py::test_recovery_bip39": "43a56fb1dea38503cfddf55c4a6e8d5a5188be7354bd5c2b31fde197f2af3443", -"TR_en_test_recovery.py::test_recovery_slip39_basic": "40df8fd6302577ff4f1d1d6d214d1ffd22791912ee4225ae86460a3fa3f3ac0f", +"TR_en_test_recovery.py::test_recovery_bip39": "f70c19e62380af74f43fbfa5fb75c03db47417348477298c51c88e37f1240444", +"TR_en_test_recovery.py::test_recovery_bip39_previous_word": "c3b2bd8858c6ab627905cb49f122281b08e2ce6965f49baabdcd419c68affe09", +"TR_en_test_recovery.py::test_recovery_slip39_basic": "a5afe54583805edf3b9f5f07778237b6a2f1b5c6d20db998cde78f5a3714c0de", "TR_en_test_reset_bip39.py::test_reset_bip39": "5ae502b3041376c4dac8de8b315242e20c29e76cbd93acc81790b61a65bb2401", "TR_en_test_reset_slip39_advanced.py::test_reset_slip39_advanced[16of16]": "53763c7177a26069cc164a84d93c6b6b53bfe3f58a770913b4b62b537fd9301d", "TR_en_test_reset_slip39_advanced.py::test_reset_slip39_advanced[2of2]": "ae94835bc49ebce6fdaa05671734701f6d2911cdee54e99e083edddbb070ffa7", @@ -7637,6 +7638,7 @@ "TT_en_test_pin.py::test_wipe_code_same_as_pin": "f13b6559d4d73c2853218fee85dc951c41c5b4e3867ee0e989443940233f2f96", "TT_en_test_pin.py::test_wipe_code_setup": "3d6a04cc7c8d3a061758a9559277a548bf4492ef59afd1d040693372d197383c", "TT_en_test_recovery.py::test_recovery_bip39": "65a138f634806c6483c55c6ce5365b8a7a4073a3c0c340b1826042262faa8545", +"TT_en_test_recovery.py::test_recovery_bip39_previous_word": "a009899ccd3305cb6737c8fa645cc9eedf4e46d6669a621a07d8cd9447d80f2f", "TT_en_test_recovery.py::test_recovery_slip39_basic": "9b0f5a7b8d2ab0fed1e5389076bc035e24dce377d275824220f1aa61e9bb4810", "TT_en_test_reset_bip39.py::test_reset_bip39": "19fd9f6233d72224696c547528a0079934a86cb41539b6f7149aab57b0aaec42", "TT_en_test_reset_slip39_advanced.py::test_reset_slip39_advanced[16of16]": "1d604f1766ce861e616745dcb5c89122165eb26ecc7f40039e50b8fe8c61a861",