1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-04-23 02:29:10 +00:00

feat(core/ui): ability to cancel recovery on word count selector

This commit is contained in:
Ioan Bizău 2025-02-17 14:16:31 +01:00 committed by Ioan Bizău
parent 6d7b039e6a
commit 64b9084b6c
31 changed files with 1691 additions and 1415 deletions

View File

@ -0,0 +1 @@
Added ability to cancel recovery on word count selection screen.

View File

@ -1463,9 +1463,9 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// def select_word_count(
/// *,
/// recovery_type: RecoveryType,
/// ) -> LayoutObj[int | str]: # TR returns str
/// ) -> LayoutObj[int | str | UIResult]: # TR returns str
/// """Select a mnemonic word count from the options: 12, 18, 20, 24, or 33.
/// For unlocking a repeated backup, select from 20 or 33."""
/// For unlocking a repeated backup, select between 20 and 33."""
Qstr::MP_QSTR_select_word_count => obj_fn_kw!(0, new_select_word_count).as_obj(),
/// def set_brightness(*, current: int | None = None) -> LayoutObj[UiResult]:

View File

@ -1,7 +1,10 @@
use crate::ui::{
component::{Component, Event, EventCtx},
geometry::{Grid, GridCellSpan, Rect},
shape::Renderer,
use crate::{
strutil::TString,
ui::{
component::{Component, Event, EventCtx},
geometry::{Grid, GridCellSpan, Rect},
shape::Renderer,
},
};
use super::super::{
@ -11,37 +14,90 @@ use super::super::{
use heapless::Vec;
#[cfg_attr(feature = "debug", derive(ufmt::derive::uDebug))]
#[derive(Copy, Clone)]
pub enum SelectWordCountMsg {
Selected(u32),
Cancelled,
}
type Cell = (usize, usize);
struct Btn {
text: TString<'static>,
msg: SelectWordCountMsg,
placement: GridCellSpan,
}
impl Btn {
pub const fn new(content: &'static str, value: u32, cell: Cell) -> Self {
Self {
text: TString::Str(content),
msg: SelectWordCountMsg::Selected(value),
placement: GridCellSpan {
from: cell,
to: (cell.0, cell.1 + 1),
},
}
}
}
pub struct SelectWordCountLayout {
choice_buttons: &'static [Btn],
cancel_button_placement: GridCellSpan,
}
impl SelectWordCountLayout {
/*
* 12 | 18 | 20
* ------------
* x | 24 | 33
*/
pub const LAYOUT_ALL: SelectWordCountLayout = SelectWordCountLayout {
choice_buttons: &[
Btn::new("12", 12, (0, 0)),
Btn::new("18", 18, (0, 2)),
Btn::new("20", 20, (0, 4)),
Btn::new("24", 24, (1, 2)),
Btn::new("33", 33, (1, 4)),
],
cancel_button_placement: GridCellSpan {
from: (1, 0),
to: (1, 1),
},
};
/*
* x | 20 | 33
*/
pub const LAYOUT_MULTISHARE: SelectWordCountLayout = SelectWordCountLayout {
choice_buttons: &[Btn::new("20", 20, (0, 2)), Btn::new("33", 33, (0, 4))],
cancel_button_placement: GridCellSpan {
from: (0, 0),
to: (0, 1),
},
};
}
pub struct SelectWordCount {
keypad: ValueKeypad,
layout: SelectWordCountLayout,
choice_buttons: Vec<Button, 5>,
cancel_button: Button,
}
impl SelectWordCount {
const NUMBERS_ALL: [u32; 5] = [12, 18, 20, 24, 33];
const LABELS_ALL: [&'static str; 5] = ["12", "18", "20", "24", "33"];
const CELLS_ALL: [(usize, usize); 5] = [(0, 0), (0, 2), (0, 4), (1, 0), (1, 2)];
pub fn new(layout: SelectWordCountLayout) -> Self {
let choice_buttons = layout
.choice_buttons
.iter()
.map(|btn| Button::with_text(btn.text).styled(theme::button_pin()))
.collect();
const NUMBERS_MULTISHARE: [u32; 2] = [20, 33];
const LABELS_MULTISHARE: [&'static str; 2] = ["20", "33"];
const CELLS_MULTISHARE: [(usize, usize); 2] = [(0, 0), (0, 2)];
let cancel_button = Button::with_icon(theme::ICON_CANCEL).styled(theme::button_cancel());
pub fn new_all() -> Self {
Self {
keypad: ValueKeypad::new(&Self::NUMBERS_ALL, &Self::LABELS_ALL, &Self::CELLS_ALL),
}
}
pub fn new_multishare() -> Self {
Self {
keypad: ValueKeypad::new(
&Self::NUMBERS_MULTISHARE,
&Self::LABELS_MULTISHARE,
&Self::CELLS_MULTISHARE,
),
layout,
choice_buttons,
cancel_button,
}
}
}
@ -49,69 +105,38 @@ impl SelectWordCount {
impl Component for SelectWordCount {
type Msg = SelectWordCountMsg;
fn place(&mut self, bounds: Rect) -> Rect {
self.keypad.place(bounds)
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.keypad.event(ctx, event)
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.keypad.render(target)
}
}
type ValueKeyPacked = (Button, u32, (usize, usize)); // (Button, number, cell)
pub struct ValueKeypad {
buttons: Vec<ValueKeyPacked, 5>,
}
impl ValueKeypad {
fn new(numbers: &[u32], labels: &[&'static str], cells: &[(usize, usize)]) -> Self {
let mut buttons = Vec::new();
for ((&number, &label), &cell) in numbers.iter().zip(labels).zip(cells).take(5) {
unwrap!(buttons.push((
Button::with_text(label.into()).styled(theme::button_pin()),
number,
cell
)));
}
Self { buttons }
}
}
impl Component for ValueKeypad {
type Msg = SelectWordCountMsg;
fn place(&mut self, bounds: Rect) -> Rect {
let (_, bounds) = bounds.split_bottom(2 * theme::BUTTON_HEIGHT + theme::BUTTON_SPACING);
let grid = Grid::new(bounds, 2, 6).with_spacing(theme::BUTTON_SPACING);
for (btn, _, (x, y)) in self.buttons.iter_mut() {
btn.place(grid.cells(GridCellSpan {
from: (*x, *y),
to: (*x, *y + 1),
}));
for (i, button) in self.choice_buttons.iter_mut().enumerate() {
button.place(grid.cells(self.layout.choice_buttons[i].placement));
}
self.cancel_button
.place(grid.cells(self.layout.cancel_button_placement));
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
for (i, (btn, _, _)) in self.buttons.iter_mut().enumerate() {
if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) {
return Some(SelectWordCountMsg::Selected(self.buttons[i].1));
for (i, button) in self.choice_buttons.iter_mut().enumerate() {
if matches!(button.event(ctx, event), Some(ButtonMsg::Clicked)) {
return Some(self.layout.choice_buttons[i].msg);
}
}
if matches!(
self.cancel_button.event(ctx, event),
Some(ButtonMsg::Clicked)
) {
return Some(SelectWordCountMsg::Cancelled);
}
None
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
for btn in self.buttons.iter() {
btn.0.render(target)
for button in self.choice_buttons.iter() {
button.render(target);
}
self.cancel_button.render(target);
}
}
@ -119,13 +144,5 @@ impl Component for ValueKeypad {
impl crate::trace::Trace for SelectWordCount {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("SelectWordCount");
t.child("keypad", &self.keypad);
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for ValueKeypad {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("ValueKeypad");
}
}

View File

@ -49,7 +49,7 @@ pub use keyboard::{
passphrase::{PassphraseKeyboard, PassphraseKeyboardMsg},
pin::{PinKeyboard, PinKeyboardMsg},
slip39::Slip39Input,
word_count::{SelectWordCount, SelectWordCountMsg},
word_count::{SelectWordCount, SelectWordCountLayout, SelectWordCountMsg},
};
pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet};
#[cfg(feature = "translations")]

View File

@ -64,6 +64,7 @@ impl TryFrom<SelectWordCountMsg> for Obj {
fn try_from(value: SelectWordCountMsg) -> Result<Self, Self::Error> {
match value {
SelectWordCountMsg::Selected(i) => i.try_into(),
SelectWordCountMsg::Cancelled => Ok(CANCELLED.as_obj()),
}
}
}

View File

@ -37,8 +37,8 @@ use super::{
check_homescreen_format, AddressDetails, Bip39Input, Button, ButtonMsg, ButtonPage,
ButtonStyleSheet, CancelConfirmMsg, CoinJoinProgress, Dialog, FidoConfirm, Frame,
Homescreen, IconDialog, Lockscreen, MnemonicKeyboard, NumberInputDialog,
PassphraseKeyboard, PinKeyboard, Progress, SelectWordCount, SetBrightnessDialog,
ShareWords, SimplePage, Slip39Input,
PassphraseKeyboard, PinKeyboard, Progress, SelectWordCount, SelectWordCountLayout,
SetBrightnessDialog, ShareWords, SimplePage, Slip39Input,
},
fonts, theme, UIBolt,
};
@ -700,16 +700,17 @@ impl FirmwareUI for UIBolt {
TR::recovery__num_of_words,
));
let content = if matches!(recovery_type, RecoveryType::UnlockRepeatedBackup) {
SelectWordCount::new_multishare()
} else {
SelectWordCount::new_all()
};
let selector = SelectWordCount::new(
if matches!(recovery_type, RecoveryType::UnlockRepeatedBackup) {
SelectWordCountLayout::LAYOUT_MULTISHARE
} else {
SelectWordCountLayout::LAYOUT_ALL
},
);
let layout = RootComponent::new(Frame::left_aligned(
theme::label_title(),
title,
Dialog::new(paragraphs, content),
Dialog::new(paragraphs, selector),
));
Ok(layout)
}

View File

@ -14,7 +14,7 @@ use crate::{
};
use super::super::{
component::{ButtonLayout, Choice, ChoiceFactory, ChoicePage},
component::{ButtonLayout, Choice, ChoiceFactory, ChoiceMsg, ChoicePage},
fonts,
theme::bootloader::{BLD_BG, BLD_FG, ICON_EXIT, ICON_REDO, ICON_TRASH},
};
@ -140,11 +140,7 @@ impl Menu {
let choices = MenuChoiceFactory::new(firmware_present);
Self {
pad: Pad::with_background(BLD_BG).with_clear(),
choice_page: Child::new(
ChoicePage::new(choices)
.with_carousel(true)
.with_only_one_item(true),
),
choice_page: Child::new(ChoicePage::new(choices).with_only_one_item(true)),
}
}
}
@ -159,7 +155,10 @@ impl Component for Menu {
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.choice_page.event(ctx, event).map(|evt| evt.0)
match self.choice_page.event(ctx, event) {
Some(ChoiceMsg::Choice { item, .. }) => Some(item),
_ => None,
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {

View File

@ -11,6 +11,11 @@ use super::super::{
const DEFAULT_ITEMS_DISTANCE: i16 = 10;
pub enum ChoiceMsg<T> {
Choice { item: T, long_press: bool },
Cancel,
}
pub trait Choice {
fn render_center<'s>(&self, target: &mut impl Renderer<'s>, _area: Rect, _inverse: bool);
@ -50,6 +55,12 @@ pub trait ChoiceFactory {
fn get(&self, index: usize) -> (Self::Item, Self::Action);
}
#[derive(PartialEq, Copy, Clone)]
pub enum ChoiceControls {
Carousel,
Cancellable,
}
/// General component displaying a set of items on the screen
/// and allowing the user to select one of them.
///
@ -60,9 +71,6 @@ pub trait ChoiceFactory {
/// Each `Choice` is responsible for setting the screen -
/// choosing the button text, their duration, text displayed
/// on screen etc.
///
/// `is_carousel` can be used to make the choice page "infinite" -
/// after reaching one end, users will appear at the other end.
pub struct ChoicePage<F, A>
where
F: ChoiceFactory<Action = A>,
@ -73,8 +81,9 @@ where
page_counter: usize,
/// How many pixels are between the items.
items_distance: i16,
/// Whether the choice page is "infinite" (carousel).
is_carousel: bool,
/// Whether the choice page is cancelable
/// or a carousel.
controls: ChoiceControls,
/// Whether we should show items on left/right even when they cannot
/// be painted entirely (they would be cut off).
show_incomplete: bool,
@ -116,7 +125,7 @@ where
),
page_counter: 0,
items_distance: DEFAULT_ITEMS_DISTANCE,
is_carousel: false,
controls: ChoiceControls::Cancellable,
show_incomplete: false,
show_only_one_item: false,
inverse_selected_item: false,
@ -138,9 +147,8 @@ where
self
}
/// Enabling the carousel mode.
pub fn with_carousel(mut self, carousel: bool) -> Self {
self.is_carousel = carousel;
pub fn with_controls(mut self, controls: ChoiceControls) -> Self {
self.controls = controls;
self
}
@ -172,13 +180,13 @@ where
ctx: &mut EventCtx,
new_choices: F,
new_page_counter: Option<usize>,
is_carousel: bool,
controls: ChoiceControls,
) {
self.choices = new_choices;
if let Some(new_counter) = new_page_counter {
self.page_counter = new_counter;
}
self.is_carousel = is_carousel;
self.controls = controls;
self.update(ctx);
}
@ -239,12 +247,12 @@ where
let (left_area, _center_area, right_area) = center_row_area.split_center(center_width);
// Possibly drawing on the left side.
if self.has_previous_choice() || self.is_carousel {
if self.has_previous_choice() || self.controls == ChoiceControls::Carousel {
self.show_left_choices(target, left_area);
}
// Possibly drawing on the right side.
if self.has_next_choice() || self.is_carousel {
if self.has_next_choice() || self.controls == ChoiceControls::Carousel {
self.show_right_choices(target, right_area);
}
}
@ -307,7 +315,7 @@ where
// Breaking out of the loop if we exhausted left items
// and the carousel mode is not enabled.
if page_index < 0 {
if self.is_carousel {
if self.controls == ChoiceControls::Carousel {
// Moving to the last page.
page_index = self.last_page_index() as i16;
} else {
@ -348,7 +356,7 @@ where
// Breaking out of the loop if we exhausted right items
// and the carousel mode is not enabled.
if page_index > self.last_page_index() {
if self.is_carousel {
if self.controls == ChoiceControls::Carousel {
// Moving to the first page.
page_index = 0;
} else {
@ -428,8 +436,10 @@ where
/// Check possibility of going left/right.
fn can_move(&self, button: ButtonPos) -> bool {
match button {
ButtonPos::Left => self.has_previous_choice() || self.is_carousel,
ButtonPos::Right => self.has_next_choice() || self.is_carousel,
ButtonPos::Left => {
self.has_previous_choice() || self.controls == ChoiceControls::Carousel
}
ButtonPos::Right => self.has_next_choice() || self.controls == ChoiceControls::Carousel,
_ => false,
}
}
@ -439,7 +449,7 @@ where
if self.has_previous_choice() {
self.decrease_page_counter();
self.update(ctx);
} else if self.is_carousel {
} else if self.controls == ChoiceControls::Carousel {
self.page_counter_to_max();
self.update(ctx);
}
@ -450,7 +460,7 @@ where
if self.has_next_choice() {
self.increase_page_counter();
self.update(ctx);
} else if self.is_carousel {
} else if self.controls == ChoiceControls::Carousel {
self.page_counter_to_zero();
self.update(ctx);
}
@ -486,7 +496,7 @@ impl<F, A> Component for ChoicePage<F, A>
where
F: ChoiceFactory<Action = A>,
{
type Msg = (A, bool);
type Msg = ChoiceMsg<A>;
fn place(&mut self, bounds: Rect) -> Rect {
let (content_area, button_area) = bounds.split_bottom(theme::BUTTON_HEIGHT);
@ -558,9 +568,13 @@ where
if let Some(ButtonControllerMsg::Triggered(pos, long_press)) = button_event {
match pos {
ButtonPos::Left => {
// Clicked BACK. Decrease the page counter.
// In case of carousel going to the right end.
self.move_left(ctx);
if self.controls == ChoiceControls::Cancellable && self.page_counter == 0 {
return Some(ChoiceMsg::<A>::Cancel);
} else {
// Clicked BACK. Decrease the page counter.
// In case of carousel going to the right end.
self.move_left(ctx);
}
}
ButtonPos::Right => {
// Clicked NEXT. Increase the page counter.
@ -570,7 +584,10 @@ where
ButtonPos::Middle => {
// Clicked SELECT. Send current choice index with information about long-press
self.clear_and_repaint(ctx);
return Some((self.get_current_action(), long_press));
return Some(ChoiceMsg::Choice {
item: self.get_current_action(),
long_press,
});
}
}
};
@ -583,7 +600,10 @@ where
buttons.reset_state(ctx);
});
self.clear_and_repaint(ctx);
return Some((self.get_current_action(), true));
return Some(ChoiceMsg::Choice {
item: self.get_current_action(),
long_press: true,
});
}
};
// The middle button was pressed, highlighting the current choice by color
@ -614,11 +634,11 @@ where
t.component("ChoicePage");
t.int("active_page", self.page_counter as i64);
t.int("page_count", self.choices.count() as i64);
t.bool("is_carousel", self.is_carousel);
t.bool("is_carousel", self.controls == ChoiceControls::Carousel);
if self.has_previous_choice() {
t.child("prev_choice", &self.choices.get(self.page_counter - 1).0);
} else if self.is_carousel {
} else if self.controls == ChoiceControls::Carousel {
// In case of carousel going to the left end.
t.child("prev_choice", &self.choices.get(self.last_page_index()).0);
}
@ -627,7 +647,7 @@ where
if self.has_next_choice() {
t.child("next_choice", &self.choices.get(self.page_counter + 1).0);
} else if self.is_carousel {
} else if self.controls == ChoiceControls::Carousel {
// In case of carousel going to the very left.
t.child("next_choice", &self.choices.get(0).0);
}

View File

@ -8,7 +8,7 @@ use crate::{
},
};
use super::super::{ButtonLayout, ChoiceFactory, ChoiceItem, ChoicePage};
use super::super::{ButtonLayout, ChoiceFactory, ChoiceItem, ChoiceMsg, ChoicePage};
struct ChoiceFactoryNumberInput {
min: u32,
@ -69,14 +69,14 @@ impl NumberInput {
}
impl Component for NumberInput {
type Msg = u32;
type Msg = ChoiceMsg<u32>;
fn place(&mut self, bounds: Rect) -> Rect {
self.choice_page.place(bounds)
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.choice_page.event(ctx, event).map(|evt| evt.0)
self.choice_page.event(ctx, event)
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {

View File

@ -12,8 +12,8 @@ use crate::{
};
use super::super::{
theme, ButtonDetails, ButtonLayout, CancelConfirmMsg, ChangingTextLine, ChoiceFactory,
ChoiceItem, ChoicePage,
theme, ButtonDetails, ButtonLayout, CancelConfirmMsg, ChangingTextLine, ChoiceControls,
ChoiceFactory, ChoiceItem, ChoiceMsg, ChoicePage,
};
/// Defines the choices currently available on the screen
@ -279,8 +279,8 @@ impl PassphraseEntry {
pub fn new() -> Self {
Self {
choice_page: ChoicePage::new(ChoiceFactoryPassphrase::new(ChoiceCategory::Menu, true))
.with_carousel(true)
.with_initial_page_counter(random_menu_position()),
.with_initial_page_counter(random_menu_position())
.with_controls(ChoiceControls::Carousel),
passphrase_dots: Child::new(ChangingTextLine::center_mono("", MAX_PASSPHRASE_LENGTH)),
show_plain_passphrase: false,
show_last_digit: false,
@ -334,8 +334,12 @@ impl PassphraseEntry {
/// Displaying the MENU
fn show_menu_page(&mut self, ctx: &mut EventCtx) {
let menu_choices = ChoiceFactoryPassphrase::new(ChoiceCategory::Menu, self.is_empty());
self.choice_page
.reset(ctx, menu_choices, Some(random_menu_position()), true);
self.choice_page.reset(
ctx,
menu_choices,
Some(random_menu_position()),
ChoiceControls::Carousel,
);
}
/// Displaying the character category
@ -345,7 +349,7 @@ impl PassphraseEntry {
ctx,
category_choices,
Some(random_category_position(&self.current_category)),
true,
ChoiceControls::Carousel,
);
}
@ -396,54 +400,70 @@ impl Component for PassphraseEntry {
}
}
if let Some((action, long_press)) = self.choice_page.event(ctx, event) {
match action {
PassphraseAction::CancelOrDelete => {
if self.is_empty() {
return Some(CancelConfirmMsg::Cancelled);
match self.choice_page.event(ctx, event) {
Some(ChoiceMsg::Choice {
item: PassphraseAction::CancelOrDelete,
long_press,
}) => {
if self.is_empty() {
return Some(CancelConfirmMsg::Cancelled);
} else {
// Deleting all when long-pressed
if long_press {
self.delete_all_digits(ctx);
} else {
// Deleting all when long-pressed
if long_press {
self.delete_all_digits(ctx);
} else {
self.delete_last_digit(ctx);
}
self.update_passphrase_dots(ctx);
if self.is_empty() {
// Allowing for DELETE/CANCEL change
self.show_menu_page(ctx);
}
ctx.request_paint();
self.delete_last_digit(ctx);
}
}
PassphraseAction::Enter => {
return Some(CancelConfirmMsg::Confirmed);
}
PassphraseAction::Show => {
self.show_plain_passphrase = true;
self.update_passphrase_dots(ctx);
if self.is_empty() {
// Allowing for DELETE/CANCEL change
self.show_menu_page(ctx);
}
ctx.request_paint();
}
PassphraseAction::Category(category) => {
self.current_category = category;
self.show_category_page(ctx);
ctx.request_paint();
}
PassphraseAction::Menu => {
self.current_category = ChoiceCategory::Menu;
self.show_menu_page(ctx);
ctx.request_paint();
}
PassphraseAction::Character(ch) if !self.is_full() => {
self.append_char(ctx, ch);
self.show_last_digit = true;
self.update_passphrase_dots(ctx);
self.randomize_category_position(ctx);
ctx.request_paint();
}
_ => {}
}
}
Some(ChoiceMsg::Choice {
item: PassphraseAction::Enter,
..
}) => {
return Some(CancelConfirmMsg::Confirmed);
}
Some(ChoiceMsg::Choice {
item: PassphraseAction::Show,
..
}) => {
self.show_plain_passphrase = true;
self.update_passphrase_dots(ctx);
ctx.request_paint();
}
Some(ChoiceMsg::Choice {
item: PassphraseAction::Category(category),
..
}) => {
self.current_category = category;
self.show_category_page(ctx);
ctx.request_paint();
}
Some(ChoiceMsg::Choice {
item: PassphraseAction::Menu,
..
}) => {
self.current_category = ChoiceCategory::Menu;
self.show_menu_page(ctx);
ctx.request_paint();
}
Some(ChoiceMsg::Choice {
item: PassphraseAction::Character(ch),
..
}) if !self.is_full() => {
self.append_char(ctx, ch);
self.show_last_digit = true;
self.update_passphrase_dots(ctx);
self.randomize_category_position(ctx);
ctx.request_paint();
}
_ => {}
};
None
}

View File

@ -15,7 +15,7 @@ use crate::{
use super::super::{
super::fonts, theme, ButtonDetails, ButtonLayout, CancelConfirmMsg, ChangingTextLine,
ChoiceFactory, ChoiceItem, ChoicePage,
ChoiceControls, ChoiceFactory, ChoiceItem, ChoiceMsg, ChoicePage,
};
#[derive(Clone, Copy)]
@ -171,7 +171,7 @@ impl<'a> PinEntry<'a> {
// Starting at a random digit.
choice_page: ChoicePage::new(ChoiceFactoryPIN)
.with_initial_page_counter(get_random_digit_position())
.with_carousel(true),
.with_controls(ChoiceControls::Carousel),
header_line: Child::new(
header_line_content
.map(|s| ChangingTextLine::center_bold(s, MAX_PIN_LENGTH))
@ -295,38 +295,49 @@ impl Component for PinEntry<'_> {
}
}
if let Some((action, long_press)) = self.choice_page.event(ctx, event) {
match action {
PinAction::Delete => {
// Deleting all when long-pressed
if long_press {
self.textbox.clear(ctx);
} else {
self.textbox.delete_last(ctx);
}
self.update(ctx);
match self.choice_page.event(ctx, event) {
Some(ChoiceMsg::Choice {
item: PinAction::Delete,
long_press,
}) => {
// Deleting all when long-pressed
if long_press {
self.textbox.clear(ctx);
} else {
self.textbox.delete_last(ctx);
}
PinAction::Show => {
self.show_real_pin = true;
self.update(ctx);
}
PinAction::Enter if !self.is_empty() => {
// ENTER is not valid when the PIN is empty
return Some(CancelConfirmMsg::Confirmed);
}
PinAction::Digit(ch) if !self.is_full() => {
self.textbox.append(ctx, ch);
// Choosing random digit to be shown next
self.choice_page
.set_page_counter(ctx, get_random_digit_position(), true);
self.show_last_digit = true;
self.timeout_timer
.start(ctx, Duration::from_secs(LAST_DIGIT_TIMEOUT_S));
self.update(ctx);
}
_ => {}
self.update(ctx);
}
}
Some(ChoiceMsg::Choice {
item: PinAction::Show,
..
}) => {
self.show_real_pin = true;
self.update(ctx);
}
Some(ChoiceMsg::Choice {
item: PinAction::Enter,
..
}) if !self.is_empty() => {
// ENTER is not valid when the PIN is empty
return Some(CancelConfirmMsg::Confirmed);
}
Some(ChoiceMsg::Choice {
item: PinAction::Digit(ch),
..
}) if !self.is_full() => {
self.textbox.append(ctx, ch);
// Choosing random digit to be shown next
self.choice_page
.set_page_counter(ctx, get_random_digit_position(), true);
self.show_last_digit = true;
self.timeout_timer
.start(ctx, Duration::from_secs(LAST_DIGIT_TIMEOUT_S));
self.update(ctx);
}
_ => {}
};
None
}

View File

@ -8,7 +8,9 @@ use crate::{
},
};
use super::super::{ButtonLayout, ChoiceFactory, ChoiceItem, ChoicePage};
use super::super::{
ButtonDetails, ButtonLayout, ChoiceControls, ChoiceFactory, ChoiceItem, ChoiceMsg, ChoicePage,
};
use heapless::Vec;
// So that there is only one implementation, and not multiple generic ones
@ -17,12 +19,12 @@ const MAX_LENGTH: usize = 5;
struct ChoiceFactorySimple {
choices: Vec<TString<'static>, MAX_LENGTH>,
carousel: bool,
controls: ChoiceControls,
}
impl ChoiceFactorySimple {
fn new(choices: Vec<TString<'static>, MAX_LENGTH>, carousel: bool) -> Self {
Self { choices, carousel }
fn new(choices: Vec<TString<'static>, MAX_LENGTH>, controls: ChoiceControls) -> Self {
Self { choices, controls }
}
fn get_string(&self, choice_index: usize) -> TString<'static> {
@ -48,10 +50,14 @@ impl ChoiceFactory for ChoiceFactorySimple {
});
// Disabling prev/next buttons for the first/last choice when not in carousel.
// (could be done to the same button if there is only one)
if !self.carousel {
// (could be done to the same item if there is only one)
if self.controls != ChoiceControls::Carousel {
if choice_index == 0 {
choice_item.set_left_btn(None);
if self.controls == ChoiceControls::Cancellable {
choice_item.set_left_btn(Some(ButtonDetails::cancel_icon()));
} else {
choice_item.set_left_btn(None);
}
}
if choice_index == self.count() - 1 {
choice_item.set_right_btn(None);
@ -70,10 +76,11 @@ pub struct SimpleChoice {
}
impl SimpleChoice {
pub fn new(str_choices: Vec<TString<'static>, MAX_LENGTH>, carousel: bool) -> Self {
let choices = ChoiceFactorySimple::new(str_choices, carousel);
pub fn new(str_choices: Vec<TString<'static>, MAX_LENGTH>, controls: ChoiceControls) -> Self {
let choices = ChoiceFactorySimple::new(str_choices, controls);
let choice_page = ChoicePage::new(choices).with_controls(controls);
Self {
choice_page: ChoicePage::new(choices).with_carousel(carousel),
choice_page,
return_index: false,
}
}
@ -103,14 +110,14 @@ impl SimpleChoice {
}
impl Component for SimpleChoice {
type Msg = usize;
type Msg = ChoiceMsg<usize>;
fn place(&mut self, bounds: Rect) -> Rect {
self.choice_page.place(bounds)
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.choice_page.event(ctx, event).map(|evt| evt.0)
self.choice_page.event(ctx, event)
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {

View File

@ -9,7 +9,10 @@ use crate::{
},
};
use super::super::{theme, ButtonLayout, ChangingTextLine, ChoiceFactory, ChoiceItem, ChoicePage};
use super::super::{
theme, ButtonLayout, ChangingTextLine, ChoiceControls, ChoiceFactory, ChoiceItem, ChoiceMsg,
ChoicePage,
};
use heapless::Vec;
enum WordlistAction {
@ -173,8 +176,8 @@ impl WordlistEntry {
// Starting at random letter position
choice_page: ChoicePage::new(choices)
.with_incomplete(true)
.with_carousel(true)
.with_initial_page_counter(get_random_position(choices_count)),
.with_initial_page_counter(get_random_position(choices_count))
.with_controls(ChoiceControls::Carousel),
chosen_letters: Child::new(ChangingTextLine::center_mono(PROMPT, LINE_CAPACITY)),
textbox: TextBox::empty(MAX_WORD_LENGTH),
offer_words: false,
@ -253,8 +256,12 @@ impl WordlistEntry {
let new_page_counter = self.get_new_page_counter(&new_choices);
// Not using carousel in case of words, as that looks weird in case
// there is only one word to choose from.
self.choice_page
.reset(ctx, new_choices, Some(new_page_counter), !self.offer_words);
self.choice_page.reset(
ctx,
new_choices,
Some(new_page_counter),
ChoiceControls::Carousel,
);
ctx.request_paint();
}
@ -280,31 +287,41 @@ impl Component for WordlistEntry {
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
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 {
self.textbox.clear(ctx);
} else {
self.textbox.delete_last(ctx);
}
self.update(ctx);
}
WordlistAction::Letter(letter) => {
self.textbox.append(ctx, letter);
self.update(ctx);
}
WordlistAction::Word(word) => {
return Some(word);
}
match self.choice_page.event(ctx, event) {
Some(ChoiceMsg::Choice {
item: WordlistAction::Previous,
..
}) if self.can_go_back => {
return Some("");
}
}
Some(ChoiceMsg::Choice {
item: WordlistAction::Delete,
long_press,
}) => {
// Deleting all when long-pressed
if long_press {
self.textbox.clear(ctx);
} else {
self.textbox.delete_last(ctx);
}
self.update(ctx);
}
Some(ChoiceMsg::Choice {
item: WordlistAction::Letter(letter),
..
}) => {
self.textbox.append(ctx, letter);
self.update(ctx);
}
Some(ChoiceMsg::Choice {
item: WordlistAction::Word(word),
..
}) => {
return Some(word);
}
_ => {}
};
None
}

View File

@ -18,7 +18,7 @@ pub use common_messages::CancelConfirmMsg;
pub use error::ErrorScreen;
pub use hold_to_confirm::{HoldToConfirm, HoldToConfirmMsg};
pub use input_methods::{
choice::{Choice, ChoiceFactory, ChoicePage},
choice::{Choice, ChoiceControls, ChoiceFactory, ChoiceMsg, ChoicePage},
choice_item::ChoiceItem,
};
pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet, ProgressLoader};

View File

@ -117,17 +117,25 @@ impl ComponentMsgObj for CoinJoinProgress {
impl ComponentMsgObj for NumberInput {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
(CONFIRMED.as_obj(), msg.try_into()?).try_into()
match msg {
Self::Msg::Cancel => (CANCELLED.as_obj(), 0.try_into()?).try_into(),
Self::Msg::Choice { item, .. } => (CONFIRMED.as_obj(), item.try_into()?).try_into(),
}
}
}
impl ComponentMsgObj for SimpleChoice {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
if self.return_index {
msg.try_into()
} else {
let text = self.result_by_index(msg);
text.try_into()
match msg {
Self::Msg::Cancel => Ok(CANCELLED.as_obj()),
Self::Msg::Choice { item, .. } => {
if self.return_index {
item.try_into()
} else {
let text = self.result_by_index(item);
text.try_into()
}
}
}
}
}

View File

@ -34,10 +34,10 @@ use crate::{
use super::{
component::{
AddressDetails, ButtonActions, ButtonDetails, ButtonLayout, ButtonPage, CoinJoinProgress,
ConfirmHomescreen, Flow, FlowPages, Frame, Homescreen, Lockscreen, NumberInput, Page,
PassphraseEntry, PinEntry, Progress, ScrollableFrame, ShareWords, ShowMore, SimpleChoice,
WordlistEntry, WordlistType,
AddressDetails, ButtonActions, ButtonDetails, ButtonLayout, ButtonPage, ChoiceControls,
CoinJoinProgress, ConfirmHomescreen, Flow, FlowPages, Frame, Homescreen, Lockscreen,
NumberInput, Page, PassphraseEntry, PinEntry, Progress, ScrollableFrame, ShareWords,
ShowMore, SimpleChoice, WordlistEntry, WordlistType,
},
constant, fonts, theme, UICaesar,
};
@ -887,7 +887,7 @@ impl FirmwareUI for UICaesar {
let layout = RootComponent::new(
Frame::new(
description,
SimpleChoice::new(words, false)
SimpleChoice::new(words, ChoiceControls::Carousel)
.with_show_incomplete()
.with_return_index(),
)
@ -904,12 +904,14 @@ impl FirmwareUI for UICaesar {
} else {
&["12", "18", "20", "24", "33"]
};
nums.iter().map(|&num| num.into()).collect()
};
let layout = RootComponent::new(
Frame::new(title, SimpleChoice::new(choices, false)).with_title_centered(),
Frame::new(
title,
SimpleChoice::new(choices, ChoiceControls::Cancellable),
)
.with_title_centered(),
);
Ok(layout)
}

View File

@ -1,41 +1,116 @@
use crate::ui::{
component::{Component, Event, EventCtx},
geometry::{Alignment, Grid, GridCellSpan, Rect},
shape::Renderer,
use crate::{
strutil::TString,
ui::{
component::{Component, Event, EventCtx},
geometry::{Alignment, Grid, GridCellSpan, Rect},
shape::Renderer,
},
};
use heapless::Vec;
use super::super::super::{
component::{
button::{Button, ButtonContent, ButtonMsg},
BinarySelection, BinarySelectionMsg,
},
component::button::{Button, ButtonMsg},
cshape, theme,
};
#[derive(Copy, Clone)]
pub enum SelectWordCountMsg {
Selected(u32),
Cancelled,
}
// We allow large_enum_variant here because the code is simpler and the larger
// variant (ValueKeypad) predates the smaller one.
#[allow(clippy::large_enum_variant)]
pub enum SelectWordCount {
All(ValueKeypad),
Multishare(BinarySelection),
type Cell = (usize, usize);
struct Btn {
text: TString<'static>,
msg: SelectWordCountMsg,
placement: GridCellSpan,
}
impl Btn {
pub const fn new(content: &'static str, value: u32, cell: Cell) -> Self {
Self {
text: TString::Str(content),
msg: SelectWordCountMsg::Selected(value),
placement: GridCellSpan {
from: cell,
to: (cell.0 + 1, cell.1 + 1),
},
}
}
}
pub struct SelectWordCountLayout {
choice_buttons: &'static [Btn],
cancel_button_placement: GridCellSpan,
}
impl SelectWordCountLayout {
/*
* 12 | 18
* -------
* 20 | 24
* -------
* x | 33
*/
pub const LAYOUT_ALL: SelectWordCountLayout = SelectWordCountLayout {
choice_buttons: &[
Btn::new("12", 12, (0, 0)),
Btn::new("18", 18, (0, 2)),
Btn::new("20", 20, (2, 0)),
Btn::new("24", 24, (2, 2)),
Btn::new("33", 33, (4, 2)),
],
cancel_button_placement: GridCellSpan {
from: (4, 0),
to: (5, 1),
},
};
/*
* 20 | 33
* -------
* x
*/
pub const LAYOUT_MULTISHARE: SelectWordCountLayout = SelectWordCountLayout {
choice_buttons: &[Btn::new("20", 20, (0, 0)), Btn::new("33", 33, (0, 2))],
cancel_button_placement: GridCellSpan {
from: (2, 0),
to: (3, 3),
},
};
}
pub struct SelectWordCount {
keypad_area: Rect,
layout: SelectWordCountLayout,
choice_buttons: Vec<Button, 5>,
cancel_button: Button,
}
impl SelectWordCount {
pub fn new_all() -> Self {
Self::All(ValueKeypad::new())
}
pub fn new(layout: SelectWordCountLayout) -> Self {
let choice_buttons = layout
.choice_buttons
.iter()
.map(|btn| {
Button::with_text(btn.text)
.styled(theme::button_keyboard())
.with_text_align(Alignment::Center)
})
.collect();
pub fn new_multishare() -> Self {
Self::Multishare(BinarySelection::new(
ButtonContent::Text("20".into()),
ButtonContent::Text("33".into()),
theme::button_keyboard(),
theme::button_keyboard(),
))
let cancel_button = Button::with_icon(theme::ICON_CLOSE)
.styled(theme::button_cancel())
.with_text_align(Alignment::Center);
Self {
keypad_area: Rect::zero(),
layout,
choice_buttons,
cancel_button,
}
}
}
@ -43,91 +118,42 @@ impl Component for SelectWordCount {
type Msg = SelectWordCountMsg;
fn place(&mut self, bounds: Rect) -> Rect {
match self {
SelectWordCount::All(full_selector) => full_selector.place(bounds),
SelectWordCount::Multishare(bin_selector) => bin_selector.place(bounds),
}
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
match self {
SelectWordCount::All(full_selector) => full_selector.event(ctx, event),
SelectWordCount::Multishare(bin_selector) => {
if let Some(m) = bin_selector.event(ctx, event) {
return match m {
BinarySelectionMsg::Left => Some(SelectWordCountMsg::Selected(20)),
BinarySelectionMsg::Right => Some(SelectWordCountMsg::Selected(33)),
};
}
None
}
}
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
match self {
SelectWordCount::All(full_selector) => full_selector.render(target),
SelectWordCount::Multishare(bin_selector) => bin_selector.render(target),
}
}
}
pub struct ValueKeypad {
button: [Button; Self::NUMBERS.len()],
keypad_area: Rect,
}
impl ValueKeypad {
const NUMBERS: [u32; 5] = [12, 18, 20, 24, 33];
const LABELS: [&'static str; 5] = ["12", "18", "20", "24", "33"];
const CELLS: [(usize, usize); 5] = [(0, 0), (0, 2), (1, 0), (1, 2), (2, 1)];
fn new() -> Self {
ValueKeypad {
button: Self::LABELS.map(|t| {
Button::with_text(t.into())
.styled(theme::button_keyboard())
.with_text_align(Alignment::Center)
}),
keypad_area: Rect::zero(),
}
}
}
impl Component for ValueKeypad {
type Msg = SelectWordCountMsg;
fn place(&mut self, bounds: Rect) -> Rect {
let n_rows: usize = 3;
let n_rows: usize = self.layout.choice_buttons.len() + 1;
let n_cols: usize = 4;
let (_, bounds) = bounds.split_bottom(
n_rows as i16 * theme::BUTTON_HEIGHT + (n_rows as i16 - 1) * theme::BUTTON_SPACING,
);
let grid = Grid::new(bounds, n_rows, n_cols).with_spacing(theme::BUTTON_SPACING);
for (btn, (x, y)) in self.button.iter_mut().zip(Self::CELLS) {
btn.place(grid.cells(GridCellSpan {
from: (x, y),
to: (x, y + 1),
}));
for (i, button) in self.choice_buttons.iter_mut().enumerate() {
button.place(grid.cells(self.layout.choice_buttons[i].placement));
}
self.cancel_button
.place(grid.cells(self.layout.cancel_button_placement));
self.keypad_area = grid.area;
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
for (i, btn) in self.button.iter_mut().enumerate() {
if let Some(ButtonMsg::Clicked) = btn.event(ctx, event) {
return Some(SelectWordCountMsg::Selected(Self::NUMBERS[i]));
for (i, button) in self.choice_buttons.iter_mut().enumerate() {
if matches!(button.event(ctx, event), Some(ButtonMsg::Clicked)) {
return Some(self.layout.choice_buttons[i].msg);
}
}
if matches!(
self.cancel_button.event(ctx, event),
Some(ButtonMsg::Clicked)
) {
return Some(SelectWordCountMsg::Cancelled);
}
None
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
for btn in self.button.iter() {
btn.render(target)
for button in self.choice_buttons.iter() {
button.render(target);
}
self.cancel_button.render(target);
cshape::KeyboardOverlay::new(self.keypad_area).render(target);
}
@ -137,16 +163,5 @@ impl Component for ValueKeypad {
impl crate::trace::Trace for SelectWordCount {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("SelectWordCount");
match self {
SelectWordCount::All(full_selector) => t.child("all", full_selector),
SelectWordCount::Multishare(bin_selector) => t.child("multi-share", bin_selector),
}
}
}
#[cfg(feature = "ui_debug")]
impl crate::trace::Trace for ValueKeypad {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("ValueKeypad");
}
}

View File

@ -63,7 +63,7 @@ pub use keyboard::{
passphrase::{PassphraseKeyboard, PassphraseKeyboardMsg},
pin::{PinKeyboard, PinKeyboardMsg},
slip39::Slip39Input,
word_count::{SelectWordCount, SelectWordCountMsg},
word_count::{SelectWordCount, SelectWordCountLayout, SelectWordCountMsg},
};
pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet};
#[cfg(feature = "translations")]

View File

@ -28,6 +28,7 @@ impl TryFrom<SelectWordCountMsg> for Obj {
fn try_from(value: SelectWordCountMsg) -> Result<Self, Self::Error> {
match value {
SelectWordCountMsg::Selected(i) => i.try_into(),
SelectWordCountMsg::Cancelled => Ok(CANCELLED.as_obj()),
}
}
}
@ -84,6 +85,7 @@ where
impl ComponentMsgObj for SelectWordCount {
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
match msg {
SelectWordCountMsg::Cancelled => Ok(CANCELLED.as_obj()),
SelectWordCountMsg::Selected(n) => n.try_into(),
}
}

View File

@ -35,8 +35,8 @@ use crate::{
use super::{
component::{
check_homescreen_format, Bip39Input, CoinJoinProgress, Frame, Homescreen, Lockscreen,
MnemonicKeyboard, PinKeyboard, Progress, SelectWordCount, Slip39Input, StatusScreen,
SwipeContent, SwipeUpScreen, VerticalMenu,
MnemonicKeyboard, PinKeyboard, Progress, SelectWordCount, SelectWordCountLayout,
Slip39Input, StatusScreen, SwipeContent, SwipeUpScreen, VerticalMenu,
},
flow::{
self, new_confirm_action_simple, ConfirmActionExtra, ConfirmActionMenuStrings,
@ -752,14 +752,16 @@ impl FirmwareUI for UIDelizia {
}
fn select_word_count(recovery_type: RecoveryType) -> Result<impl LayoutMaybeTrace, Error> {
let content = if matches!(recovery_type, RecoveryType::UnlockRepeatedBackup) {
SelectWordCount::new_multishare()
} else {
SelectWordCount::new_all()
};
let selector = SelectWordCount::new(
if matches!(recovery_type, RecoveryType::UnlockRepeatedBackup) {
SelectWordCountLayout::LAYOUT_MULTISHARE
} else {
SelectWordCountLayout::LAYOUT_ALL
},
);
let layout = RootComponent::new(Frame::left_aligned(
TR::recovery__num_of_words.into(),
content,
selector,
));
Ok(layout)
}

View File

@ -445,9 +445,9 @@ def select_word(
def select_word_count(
*,
recovery_type: RecoveryType,
) -> LayoutObj[int | str]: # TR returns str
) -> LayoutObj[int | str | UIResult]: # TR returns str
"""Select a mnemonic word count from the options: 12, 18, 20, 24, or 33.
For unlocking a repeated backup, select from 20 or 33."""
For unlocking a repeated backup, select between 20 and 33."""
# rust/src/ui/api/firmware_micropython.rs

View File

@ -7,6 +7,7 @@ from trezor import TR, wire
from trezor.messages import Success
from apps.common import backup_types
from apps.management.recovery_device.recover import RecoveryAborted
from . import layout, recover
@ -103,7 +104,10 @@ async def _continue_recovery_process() -> Success:
TR.buttons__continue, TR.recovery__num_of_words
)
# ask for the number of words
word_count = await layout.request_word_count(recovery_type)
try:
word_count = await layout.request_word_count(recovery_type)
except wire.ActionCancelled:
raise RecoveryAborted
# ...and only then show the starting screen with word count.
await _request_share_first_screen(word_count, recovery_type)
assert word_count is not None

View File

@ -63,6 +63,35 @@ def confirm_recovery(debug: "DebugLink", title: str = "recovery__title") -> None
debug.press_right()
def cancel_select_number_of_words(
debug: "DebugLink",
unlock_repeated_backup=False,
) -> None:
if debug.layout_type is LayoutType.Bolt:
assert debug.read_layout().text_content() == TR.recovery__num_of_words
# click the button from ValuePad
if unlock_repeated_backup:
coords = buttons.grid34(0, 2)
else:
coords = buttons.grid34(0, 3)
debug.click(coords)
elif debug.layout_type is LayoutType.Caesar:
debug.press_right()
layout = debug.read_layout()
assert layout.title() == TR.word_count__title
# navigate to the number and confirm it
debug.press_left()
elif debug.layout_type is LayoutType.Delizia:
# click the button from ValuePad
if unlock_repeated_backup:
coords = buttons.grid34(0, 3)
else:
coords = buttons.grid34(0, 3)
debug.click(coords)
else:
raise ValueError("Unknown model")
def select_number_of_words(
debug: "DebugLink",
num_of_words: int = 20,
@ -74,14 +103,14 @@ def select_number_of_words(
def select_bolt() -> "LayoutContent":
# click the button from ValuePad
if unlock_repeated_backup:
coords_map = {20: buttons.grid34(0, 2), 33: buttons.grid34(1, 2)}
coords_map = {20: buttons.grid34(1, 2), 33: buttons.grid34(2, 2)}
else:
coords_map = {
12: buttons.grid34(0, 2),
18: buttons.grid34(1, 2),
20: buttons.grid34(2, 2),
24: buttons.grid34(0, 3),
33: buttons.grid34(1, 3),
24: buttons.grid34(1, 3),
33: buttons.grid34(2, 3),
}
coords = coords_map.get(num_of_words)
if coords is None:
@ -101,14 +130,14 @@ def select_number_of_words(
def select_delizia() -> "LayoutContent":
# click the button from ValuePad
if unlock_repeated_backup:
coords_map = {20: buttons.NO_UI_DELIZIA, 33: buttons.YES_UI_DELIZIA}
coords_map = {20: buttons.grid34(0, 1), 33: buttons.grid34(2, 1)}
else:
coords_map = {
12: buttons.grid34(0, 1),
18: buttons.grid34(2, 1),
20: buttons.grid34(0, 2),
24: buttons.grid34(2, 2),
33: buttons.grid34(1, 3),
33: buttons.grid34(2, 3),
}
coords = coords_map.get(num_of_words)
if coords is None:

View File

@ -51,6 +51,25 @@ def prepare_recovery_and_evaluate(
assert features.recovery_status == messages.RecoveryStatus.Nothing
@contextmanager
def prepare_recovery_and_evaluate_cancel(
device_handler: "BackgroundDeviceHandler",
) -> Generator["DebugLink", None, None]:
features = device_handler.features()
debug = device_handler.debuglink()
assert features.initialized is False
device_handler.run(device.recover, pin_protection=False) # type: ignore
yield debug
with pytest.raises(exceptions.Cancelled):
device_handler.result()
features = device_handler.features()
assert features.initialized is False
assert features.recovery_status == messages.RecoveryStatus.Nothing
@pytest.mark.setup_client(uninitialized=True)
def test_recovery_slip39_basic(device_handler: "BackgroundDeviceHandler"):
with prepare_recovery_and_evaluate(device_handler) as debug:
@ -60,6 +79,13 @@ def test_recovery_slip39_basic(device_handler: "BackgroundDeviceHandler"):
recovery.finalize(debug)
@pytest.mark.setup_client(uninitialized=True)
def test_recovery_cancel_number_of_words(device_handler: "BackgroundDeviceHandler"):
with prepare_recovery_and_evaluate_cancel(device_handler) as debug:
recovery.confirm_recovery(debug)
recovery.cancel_select_number_of_words(debug)
@pytest.mark.setup_client(uninitialized=True)
def test_recovery_bip39(device_handler: "BackgroundDeviceHandler"):
with prepare_recovery_and_evaluate(device_handler) as debug:

View File

@ -18,7 +18,7 @@ from typing import TYPE_CHECKING
import pytest
from trezorlib import device, messages
from trezorlib import device, exceptions, messages
from .. import buttons
from ..common import MOCK_GET_ENTROPY
@ -202,3 +202,25 @@ def test_repeated_backup(
assert features.backup_availability == messages.BackupAvailability.NotAvailable
assert features.no_backup is False
assert features.recovery_status == messages.RecoveryStatus.Nothing
# try to unlock backup yet again...
device_handler.run(
device.recover,
type=messages.RecoveryType.UnlockRepeatedBackup,
)
recovery.confirm_recovery(debug, "recovery__title_unlock_repeated_backup")
# but cancel on the word count selection screen!
recovery.cancel_select_number_of_words(debug, unlock_repeated_backup=True)
with pytest.raises(exceptions.Cancelled):
device_handler.result()
# ...we are out of recovery mode!
features = device_handler.features()
assert features.backup_type is messages.BackupType.Slip39_Basic_Extendable
assert features.initialized is True
assert features.backup_availability == messages.BackupAvailability.NotAvailable
assert features.no_backup is False
assert features.recovery_status == messages.RecoveryStatus.Nothing

View File

@ -29,6 +29,7 @@ from ...input_flows import (
InputFlowSlip39BasicRecovery,
InputFlowSlip39BasicRecoveryAbort,
InputFlowSlip39BasicRecoveryAbortBetweenShares,
InputFlowSlip39BasicRecoveryAbortOnNumberOfWords,
InputFlowSlip39BasicRecoveryInvalidFirstShare,
InputFlowSlip39BasicRecoveryInvalidSecondShare,
InputFlowSlip39BasicRecoveryNoAbort,
@ -118,6 +119,20 @@ def test_abort(client: Client):
assert client.features.recovery_status is messages.RecoveryStatus.Nothing
@pytest.mark.models(skip=["legacy", "safe3"])
@pytest.mark.setup_client(uninitialized=True)
def test_abort_on_number_of_words(client: Client):
# on Caesar, test_abort actually aborts on the # of words selection
with client:
IF = InputFlowSlip39BasicRecoveryAbortOnNumberOfWords(client)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
device.recover(client, pin_protection=False, label="label")
client.init_device()
assert client.features.initialized is False
assert client.features.recovery_status is messages.RecoveryStatus.Nothing
@pytest.mark.setup_client(uninitialized=True)
def test_abort_between_shares(client: Client):
with client:

View File

@ -2075,6 +2075,16 @@ class InputFlowSlip39BasicRecovery(InputFlowBase):
yield from self.REC.success_wallet_recovered()
class InputFlowSlip39BasicRecoveryAbortOnNumberOfWords(InputFlowBase):
def __init__(self, client: Client):
super().__init__(client)
def input_flow_common(self) -> BRGeneratorType:
yield from self.REC.confirm_recovery()
if self.client.layout_type in (LayoutType.Bolt, LayoutType.Delizia):
yield from self.REC.input_number_of_words(None)
class InputFlowSlip39BasicRecoveryAbort(InputFlowBase):
def __init__(self, client: Client):
super().__init__(client)

View File

@ -192,7 +192,7 @@ class RecoveryFlow:
assert TR.recovery__wanna_cancel_recovery in self._text_content()
self.debug.press_yes()
def input_number_of_words(self, num_words: int) -> BRGeneratorType:
def input_number_of_words(self, num_words: int | None) -> BRGeneratorType:
br = yield
assert br.code == B.MnemonicWordCount
assert br.name == "recovery_word_count"
@ -200,7 +200,11 @@ class RecoveryFlow:
assert TR.word_count__title in self.debug.read_layout().title()
else:
assert TR.recovery__num_of_words in self._text_content()
self.debug.input(str(num_words))
if num_words is None:
self.debug.press_no()
else:
self.debug.input(str(num_words))
def warning_invalid_recovery_seed(self) -> BRGeneratorType:
br = yield

File diff suppressed because it is too large Load Diff

View File

@ -23,17 +23,29 @@ def confirm_recovery(debug: "DebugLink") -> None:
debug.read_layout(wait=True)
def select_number_of_words(debug: "DebugLink", num_of_words: int = 20) -> None:
debug.click(buttons.OK)
debug.read_layout(wait=True)
# click the number
word_option_offset = 6
word_options = (12, 18, 20, 24, 33)
index = word_option_offset + word_options.index(
num_of_words
) # raises if num of words is invalid
coords = buttons.grid34(index % 3, index // 3)
def select_number_of_words(
debug: "DebugLink", tag_version: tuple | None, num_of_words: int = 20
) -> None:
if "SelectWordCount" not in debug.read_layout().all_components():
debug.click(buttons.OK)
debug.read_layout(wait=True)
if tag_version is None or tag_version > (2, 8, 8):
# layout changed after adding the cancel button
coords_map = {
12: buttons.grid34(0, 2),
18: buttons.grid34(1, 2),
20: buttons.grid34(2, 2),
24: buttons.grid34(1, 3),
33: buttons.grid34(2, 3),
}
coords = coords_map.get(num_of_words)
else:
word_option_offset = 6
word_options = (12, 18, 20, 24, 33)
index = word_option_offset + word_options.index(
num_of_words
) # raises if num of words is invalid
coords = buttons.grid34(index % 3, index // 3)
debug.click(coords)
debug.read_layout(wait=True)

View File

@ -30,6 +30,7 @@ from trezorlib.messages import (
)
from trezorlib.tools import H_
from ..click_tests import recovery
from ..common import MNEMONIC_SLIP39_BASIC_20_3of6, MNEMONIC_SLIP39_BASIC_20_3of6_SECRET
from ..device_handler import BackgroundDeviceHandler
from ..emulators import ALL_TAGS, EmulatorWrapper
@ -308,7 +309,7 @@ def test_upgrade_shamir_recovery(gen: str, tag: Optional[str]):
device_handler.run(device.recover, pin_protection=False)
recovery_old.confirm_recovery(debug)
recovery_old.select_number_of_words(debug)
recovery_old.select_number_of_words(debug, version_from_tag(tag))
layout = recovery_old.enter_share(debug, MNEMONIC_SLIP39_BASIC_20_3of6[0])
if not debug.legacy_ui and not debug.legacy_debug:
assert (
@ -327,14 +328,14 @@ def test_upgrade_shamir_recovery(gen: str, tag: Optional[str]):
emu.client.watch_layout(True)
# second share
layout = recovery_old.enter_share(debug, MNEMONIC_SLIP39_BASIC_20_3of6[2])
layout = recovery.enter_share(debug, MNEMONIC_SLIP39_BASIC_20_3of6[2])
assert (
"2 of 3 shares entered" in layout.text_content()
or "1 more share" in layout.text_content()
)
# last one
layout = recovery_old.enter_share(debug, MNEMONIC_SLIP39_BASIC_20_3of6[1])
layout = recovery.enter_share(debug, MNEMONIC_SLIP39_BASIC_20_3of6[1])
assert (
"Wallet recovery completed" in layout.text_content()
or "finished recovering" in layout.text_content()