diff --git a/core/.changelog.d/4142.fixed b/core/.changelog.d/4142.fixed new file mode 100644 index 000000000..47a0ffd1f --- /dev/null +++ b/core/.changelog.d/4142.fixed @@ -0,0 +1 @@ +[T3T1] Added missing info about remaining shares in super-shamir recovery. diff --git a/core/embed/rust/src/ui/model_mercury/flow/continue_recovery.rs b/core/embed/rust/src/ui/model_mercury/flow/continue_recovery.rs index 2e4f4b2be..6e6b8c5bf 100644 --- a/core/embed/rust/src/ui/model_mercury/flow/continue_recovery.rs +++ b/core/embed/rust/src/ui/model_mercury/flow/continue_recovery.rs @@ -1,6 +1,6 @@ use crate::{ error, - micropython::{map::Map, obj::Obj, qstr::Qstr, util}, + micropython::{iter::IterBuf, map::Map, obj::Obj, qstr::Qstr, util}, strutil::TString, translations::TR, ui::{ @@ -8,12 +8,14 @@ use crate::{ component::{ button_request::ButtonRequestExt, swipe_detect::SwipeSettings, - text::paragraphs::{Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs, VecExt}, + text::paragraphs::{ + Paragraph, ParagraphSource, ParagraphVecLong, ParagraphVecShort, VecExt, + }, ComponentExt, SwipeDirection, }, flow::{ base::{DecisionBuilder as _, StateChange}, - FlowMsg, FlowState, SwipeFlow, + FlowMsg, FlowState, SwipeFlow, SwipePage, }, layout::{obj::LayoutObj, util::RecoveryType}, }, @@ -40,6 +42,15 @@ pub enum ContinueRecoveryBetweenShares { CancelConfirm, } +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum ContinueRecoveryBetweenSharesAdvanced { + Main, + Menu, + CancelIntro, + CancelConfirm, + RemainingShares, +} + impl FlowState for ContinueRecoveryBeforeShares { #[inline] fn index(&'static self) -> usize { @@ -96,56 +107,99 @@ impl FlowState for ContinueRecoveryBetweenShares { } } -#[allow(clippy::not_unsafe_ptr_arg_deref)] -pub extern "C" fn new_continue_recovery(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { - unsafe { - util::try_with_args_and_kwargs(n_args, args, kwargs, ContinueRecoveryBeforeShares::new_obj) +impl FlowState for ContinueRecoveryBetweenSharesAdvanced { + #[inline] + fn index(&'static self) -> usize { + *self as usize + } + + fn handle_swipe(&'static self, direction: SwipeDirection) -> StateChange { + match (self, direction) { + (Self::Main, SwipeDirection::Left) => Self::Menu.swipe(direction), + (Self::Menu, SwipeDirection::Right) => Self::Main.swipe(direction), + (Self::Main, SwipeDirection::Up) => self.return_msg(FlowMsg::Confirmed), + (Self::CancelIntro, SwipeDirection::Up) => Self::CancelConfirm.swipe(direction), + (Self::CancelIntro, SwipeDirection::Right) => Self::Menu.swipe(direction), + (Self::CancelConfirm, SwipeDirection::Down) => Self::CancelIntro.swipe(direction), + (Self::RemainingShares, SwipeDirection::Right) => Self::Menu.swipe(direction), + _ => self.do_nothing(), + } + } + + fn handle_event(&'static self, msg: FlowMsg) -> StateChange { + match (self, msg) { + (Self::Main, FlowMsg::Info) => Self::Menu.transit(), + (Self::Menu, FlowMsg::Choice(0)) => Self::RemainingShares.transit(), + (Self::Menu, FlowMsg::Choice(1)) => Self::CancelIntro.swipe_left(), + (Self::Menu, FlowMsg::Cancelled) => Self::Main.swipe_right(), + (Self::CancelIntro, FlowMsg::Cancelled) => Self::Menu.transit(), + (Self::CancelConfirm, FlowMsg::Cancelled) => Self::CancelIntro.swipe_right(), + (Self::CancelConfirm, FlowMsg::Confirmed) => self.return_msg(FlowMsg::Cancelled), + (Self::RemainingShares, FlowMsg::Cancelled) => Self::Menu.transit(), + _ => self.do_nothing(), + } } } -impl ContinueRecoveryBeforeShares { - fn new_obj(_args: &[Obj], kwargs: &Map) -> Result { - let first_screen: bool = kwargs.get(Qstr::MP_QSTR_first_screen)?.try_into()?; - let recovery_type: RecoveryType = kwargs.get(Qstr::MP_QSTR_recovery_type)?.try_into()?; - let text: TString = kwargs.get(Qstr::MP_QSTR_text)?.try_into()?; // #shares entered - let subtext: Option = kwargs.get(Qstr::MP_QSTR_subtext)?.try_into_option()?; // #shares remaining +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn new_continue_recovery(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, new_obj) } +} - let (title, cancel_btn, cancel_title, cancel_intro) = match recovery_type { - RecoveryType::Normal => ( - TR::recovery__title, - TR::recovery__title_cancel_recovery, - TR::recovery__title_cancel_recovery, - TR::recovery__wanna_cancel_recovery, - ), - _ => ( - TR::recovery__title_dry_run, - TR::recovery__cancel_dry_run, - TR::recovery__title_cancel_dry_run, - TR::recovery__wanna_cancel_dry_run, - ), - }; +fn new_obj(_args: &[Obj], kwargs: &Map) -> Result { + let first_screen: bool = kwargs.get(Qstr::MP_QSTR_first_screen)?.try_into()?; + let recovery_type: RecoveryType = kwargs.get(Qstr::MP_QSTR_recovery_type)?.try_into()?; + let text: TString = kwargs.get(Qstr::MP_QSTR_text)?.try_into()?; // #shares entered + let subtext: Option = kwargs.get(Qstr::MP_QSTR_subtext)?.try_into_option()?; // #shares remaining + let pages: Option = kwargs.get(Qstr::MP_QSTR_pages)?.try_into_option()?; // info about remaining shares - let mut pars = ParagraphVecShort::new(); - let footer_instruction; - let footer_description; - if first_screen { - pars.add(Paragraph::new( - &theme::TEXT_MAIN_GREY_EXTRA_LIGHT, - TR::recovery__enter_each_word, - )); - footer_instruction = TR::instructions__swipe_up.into(); - footer_description = None; - } else { - pars.add(Paragraph::new(&theme::TEXT_MAIN_GREY_EXTRA_LIGHT, text)); - if let Some(sub) = subtext { - pars.add(Paragraph::new(&theme::TEXT_SUB_GREY, sub)); - } - footer_instruction = TR::instructions__swipe_up.into(); - footer_description = Some(TR::instructions__enter_next_share.into()); + let mut pars_show_shares = ParagraphVecLong::new(); + if let Some(pages) = pages { + let pages_iterable: Obj = pages; + for page in IterBuf::new().try_iterate(pages_iterable)? { + let [title, description]: [TString; 2] = util::iter_into_array(page)?; + pars_show_shares + .add(Paragraph::new(&theme::TEXT_SUB_GREY, title)) + .add(Paragraph::new(&theme::TEXT_MONO_GREY_LIGHT, description).break_after()); } + } - let paragraphs_main = Paragraphs::new(pars); - let content_main = Frame::left_aligned(title.into(), SwipeContent::new(paragraphs_main)) + let (title, cancel_btn, cancel_title, cancel_intro) = match recovery_type { + RecoveryType::Normal => ( + TR::recovery__title, + TR::recovery__title_cancel_recovery, + TR::recovery__title_cancel_recovery, + TR::recovery__wanna_cancel_recovery, + ), + _ => ( + TR::recovery__title_dry_run, + TR::recovery__cancel_dry_run, + TR::recovery__title_cancel_dry_run, + TR::recovery__wanna_cancel_dry_run, + ), + }; + + let mut pars_main = ParagraphVecShort::new(); + let footer_instruction; + let footer_description; + if first_screen { + pars_main.add(Paragraph::new( + &theme::TEXT_MAIN_GREY_EXTRA_LIGHT, + TR::recovery__enter_each_word, + )); + footer_instruction = TR::instructions__swipe_up.into(); + footer_description = None; + } else { + pars_main.add(Paragraph::new(&theme::TEXT_MAIN_GREY_EXTRA_LIGHT, text)); + if let Some(sub) = subtext { + pars_main.add(Paragraph::new(&theme::TEXT_SUB_GREY, sub)); + } + footer_instruction = TR::instructions__swipe_up.into(); + footer_description = Some(TR::instructions__enter_next_share.into()); + } + + let content_main = + Frame::left_aligned(title.into(), SwipeContent::new(pars_main.into_paragraphs())) .with_subtitle(TR::words__instructions.into()) .with_menu_button() .with_footer(footer_instruction, footer_description) @@ -155,8 +209,47 @@ impl ContinueRecoveryBeforeShares { .repeated_button_request(ButtonRequest::new( ButtonRequestCode::RecoveryHomepage, "recovery".into(), + )) + .with_pages(|_| 1); + + let paragraphs_cancel = ParagraphVecShort::from_iter([ + Paragraph::new(&theme::TEXT_MAIN_GREY_LIGHT, cancel_intro).with_bottom_padding(17), + Paragraph::new(&theme::TEXT_WARNING, TR::recovery__progress_will_be_lost), + ]) + .into_paragraphs(); + let content_cancel_intro = + Frame::left_aligned(cancel_title.into(), SwipeContent::new(paragraphs_cancel)) + .with_cancel_button() + .with_footer( + TR::instructions__swipe_up.into(), + Some(TR::words__continue_anyway.into()), + ) + .with_swipe(SwipeDirection::Up, SwipeSettings::default()) + .with_swipe(SwipeDirection::Right, SwipeSettings::immediate()) + .map(|msg| match msg { + FrameMsg::Button(FlowMsg::Cancelled) => Some(FlowMsg::Cancelled), + _ => None, + }) + .repeated_button_request(ButtonRequest::new( + ButtonRequestCode::ProtectCall, + "abort_recovery".into(), )); + let content_cancel_confirm = Frame::left_aligned( + cancel_title.into(), + SwipeContent::new(PromptScreen::new_tap_to_cancel()), + ) + .with_cancel_button() + .with_footer(TR::instructions__tap_to_confirm.into(), None) + .with_swipe(SwipeDirection::Down, SwipeSettings::default()) + .with_swipe(SwipeDirection::Right, SwipeSettings::immediate()) + .map(|msg| match msg { + FrameMsg::Content(PromptMsg::Confirmed) => Some(FlowMsg::Confirmed), + FrameMsg::Button(FlowMsg::Cancelled) => Some(FlowMsg::Cancelled), + _ => None, + }); + + let res = if first_screen { let content_menu = Frame::left_aligned( TString::empty(), VerticalMenu::empty().danger(theme::ICON_CANCEL, cancel_btn.into()), @@ -168,60 +261,81 @@ impl ContinueRecoveryBeforeShares { FrameMsg::Button(_) => Some(FlowMsg::Cancelled), }); - let paragraphs_cancel = ParagraphVecShort::from_iter([ - Paragraph::new(&theme::TEXT_MAIN_GREY_LIGHT, cancel_intro).with_bottom_padding(17), - Paragraph::new(&theme::TEXT_WARNING, TR::recovery__progress_will_be_lost), - ]) - .into_paragraphs(); - let content_cancel_intro = - Frame::left_aligned(cancel_title.into(), SwipeContent::new(paragraphs_cancel)) - .with_cancel_button() - .with_footer( - TR::instructions__swipe_up.into(), - Some(TR::words__continue_anyway.into()), - ) - .with_swipe(SwipeDirection::Up, SwipeSettings::default()) - .with_swipe(SwipeDirection::Right, SwipeSettings::immediate()) - .map(|msg| match msg { - FrameMsg::Button(FlowMsg::Cancelled) => Some(FlowMsg::Cancelled), - _ => None, - }) - .repeated_button_request(ButtonRequest::new( - ButtonRequestCode::ProtectCall, - "abort_recovery".into(), - )); - - let content_cancel_confirm = Frame::left_aligned( - cancel_title.into(), - SwipeContent::new(PromptScreen::new_tap_to_cancel()), + SwipeFlow::new(&ContinueRecoveryBeforeShares::Main)? + .with_page(&ContinueRecoveryBeforeShares::Main, content_main)? + .with_page(&ContinueRecoveryBeforeShares::Menu, content_menu)? + } else if pars_show_shares.is_empty() { + let content_menu = Frame::left_aligned( + TString::empty(), + VerticalMenu::empty().danger(theme::ICON_CANCEL, cancel_btn.into()), ) .with_cancel_button() - .with_footer(TR::instructions__tap_to_confirm.into(), None) - .with_swipe(SwipeDirection::Down, SwipeSettings::default()) .with_swipe(SwipeDirection::Right, SwipeSettings::immediate()) .map(|msg| match msg { - FrameMsg::Content(PromptMsg::Confirmed) => Some(FlowMsg::Confirmed), - FrameMsg::Button(FlowMsg::Cancelled) => Some(FlowMsg::Cancelled), - _ => None, + FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => Some(FlowMsg::Choice(i)), + FrameMsg::Button(_) => Some(FlowMsg::Cancelled), }); - let res = if first_screen { - SwipeFlow::new(&ContinueRecoveryBeforeShares::Main)? - .with_page(&ContinueRecoveryBeforeShares::Main, content_main)? - .with_page(&ContinueRecoveryBeforeShares::Menu, content_menu)? - } else { - SwipeFlow::new(&ContinueRecoveryBetweenShares::Main)? - .with_page(&ContinueRecoveryBetweenShares::Main, content_main)? - .with_page(&ContinueRecoveryBetweenShares::Menu, content_menu)? - .with_page( - &ContinueRecoveryBetweenShares::CancelIntro, - content_cancel_intro, - )? - .with_page( - &ContinueRecoveryBetweenShares::CancelConfirm, - content_cancel_confirm, - )? - }; - Ok(LayoutObj::new(res)?.into()) - } + SwipeFlow::new(&ContinueRecoveryBetweenShares::Main)? + .with_page(&ContinueRecoveryBetweenShares::Main, content_main)? + .with_page(&ContinueRecoveryBetweenShares::Menu, content_menu)? + .with_page( + &ContinueRecoveryBetweenShares::CancelIntro, + content_cancel_intro, + )? + .with_page( + &ContinueRecoveryBetweenShares::CancelConfirm, + content_cancel_confirm, + )? + } else { + let content_menu = Frame::left_aligned( + TString::empty(), + VerticalMenu::empty() + .item( + theme::ICON_CHEVRON_RIGHT, + TR::recovery__title_remaining_shares.into(), + ) + .danger(theme::ICON_CANCEL, cancel_btn.into()), + ) + .with_cancel_button() + .with_swipe(SwipeDirection::Right, SwipeSettings::immediate()) + .map(|msg| match msg { + FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => Some(FlowMsg::Choice(i)), + FrameMsg::Button(_) => Some(FlowMsg::Cancelled), + }); + + let n_remaining_shares = pars_show_shares.len(); + let content_remaining_shares = Frame::left_aligned( + TR::recovery__title_remaining_shares.into(), + SwipeContent::new(SwipePage::vertical(pars_show_shares.into_paragraphs())), + ) + .with_cancel_button() + // .with_footer(TR::instructions__swipe_up.into(), None) + .with_swipe(SwipeDirection::Up, SwipeSettings::default()) + .with_swipe(SwipeDirection::Left, SwipeSettings::default()) + .with_vertical_pages() + .map(|msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Cancelled)) + .repeated_button_request(ButtonRequest::new( + ButtonRequestCode::Other, + "show_shares".into(), + )) + .with_pages(move |_| n_remaining_shares); + + SwipeFlow::new(&ContinueRecoveryBetweenSharesAdvanced::Main)? + .with_page(&ContinueRecoveryBetweenSharesAdvanced::Main, content_main)? + .with_page(&ContinueRecoveryBetweenSharesAdvanced::Menu, content_menu)? + .with_page( + &ContinueRecoveryBetweenSharesAdvanced::CancelIntro, + content_cancel_intro, + )? + .with_page( + &ContinueRecoveryBetweenSharesAdvanced::CancelConfirm, + content_cancel_confirm, + )? + .with_page( + &ContinueRecoveryBetweenSharesAdvanced::RemainingShares, + content_remaining_shares, + )? + }; + Ok(LayoutObj::new(res)?.into()) } diff --git a/core/embed/rust/src/ui/model_mercury/layout.rs b/core/embed/rust/src/ui/model_mercury/layout.rs index 13f2a4eb2..57375acb5 100644 --- a/core/embed/rust/src/ui/model_mercury/layout.rs +++ b/core/embed/rust/src/ui/model_mercury/layout.rs @@ -1013,31 +1013,6 @@ extern "C" fn new_show_group_share_success( unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } } -extern "C" fn new_show_remaining_shares(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { - let block = move |_args: &[Obj], kwargs: &Map| { - let pages_iterable: Obj = kwargs.get(Qstr::MP_QSTR_pages)?; - - let mut paragraphs = ParagraphVecLong::new(); - for page in IterBuf::new().try_iterate(pages_iterable)? { - let [title, description]: [TString; 2] = util::iter_into_array(page)?; - paragraphs - .add(Paragraph::new(&theme::TEXT_DEMIBOLD, title)) - .add(Paragraph::new(&theme::TEXT_NORMAL, description).break_after()); - } - - let obj = LayoutObj::new(SwipeUpScreen::new( - Frame::left_aligned( - TR::recovery__title_remaining_shares.into(), - SwipeContent::new(paragraphs.into_paragraphs()), - ) - .with_footer(TR::instructions__swipe_up.into(), None) - .with_swipe(SwipeDirection::Up, SwipeSettings::default()), - ))?; - Ok(obj.into()) - }; - unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } -} - extern "C" fn new_show_progress(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { let block = move |_args: &[Obj], kwargs: &Map| { let description: TString = kwargs.get(Qstr::MP_QSTR_description)?.try_into()?; @@ -1594,6 +1569,7 @@ pub static mp_module_trezorui2: Module = obj_module! { /// recovery_type: RecoveryType, /// text: str, /// subtext: str | None = None, + /// pages: Iterable[tuple[str, str]] | None = None, /// ) -> LayoutObj[UiResult]: /// """Device recovery homescreen.""" Qstr::MP_QSTR_flow_continue_recovery => obj_fn_kw!(0, flow::continue_recovery::new_continue_recovery).as_obj(), @@ -1613,13 +1589,6 @@ pub static mp_module_trezorui2: Module = obj_module! { /// """Shown after successfully finishing a group.""" Qstr::MP_QSTR_show_group_share_success => obj_fn_kw!(0, new_show_group_share_success).as_obj(), - /// def show_remaining_shares( - /// *, - /// pages: Iterable[tuple[str, str]], - /// ) -> LayoutObj[UiResult]: - /// """Shows SLIP39 state after info button is pressed on `confirm_recovery`.""" - Qstr::MP_QSTR_show_remaining_shares => obj_fn_kw!(0, new_show_remaining_shares).as_obj(), - /// def show_progress( /// *, /// title: str, diff --git a/core/mocks/generated/trezorui2.pyi b/core/mocks/generated/trezorui2.pyi index 8f5b25ba4..f3b8ea88c 100644 --- a/core/mocks/generated/trezorui2.pyi +++ b/core/mocks/generated/trezorui2.pyi @@ -452,6 +452,7 @@ def flow_continue_recovery( recovery_type: RecoveryType, text: str, subtext: str | None = None, + pages: Iterable[tuple[str, str]] | None = None, ) -> LayoutObj[UiResult]: """Device recovery homescreen.""" @@ -473,14 +474,6 @@ def show_group_share_success( """Shown after successfully finishing a group.""" -# rust/src/ui/model_mercury/layout.rs -def show_remaining_shares( - *, - pages: Iterable[tuple[str, str]], -) -> LayoutObj[UiResult]: - """Shows SLIP39 state after info button is pressed on `confirm_recovery`.""" - - # rust/src/ui/model_mercury/layout.rs def show_progress( *, diff --git a/core/src/apps/management/recovery_device/homescreen.py b/core/src/apps/management/recovery_device/homescreen.py index 375e4704a..f78bd38e7 100644 --- a/core/src/apps/management/recovery_device/homescreen.py +++ b/core/src/apps/management/recovery_device/homescreen.py @@ -13,6 +13,8 @@ from . import layout, recover if TYPE_CHECKING: from trezor.enums import BackupType, RecoveryType + from .layout import RemainingSharesInfo + async def recovery_homescreen() -> None: from trezor import workflow @@ -297,7 +299,7 @@ async def _request_share_next_screen() -> None: await layout.homescreen_dialog( TR.buttons__enter, TR.recovery__more_shares_needed, - info_func=_show_remaining_groups_and_shares, + remaining_shares_info=_get_remaining_groups_and_shares(), ) else: still_needed_shares = remaining[0] @@ -315,21 +317,21 @@ async def _request_share_next_screen() -> None: await layout.homescreen_dialog(TR.buttons__enter_share, entered, needed) -async def _show_remaining_groups_and_shares() -> None: +def _get_remaining_groups_and_shares() -> "RemainingSharesInfo": """ - Show info dialog for Slip39 Advanced - what shares are to be entered. + Prepare data for Slip39 Advanced - what shares are to be entered. """ from trezor.crypto import slip39 shares_remaining = storage_recovery.fetch_slip39_remaining_shares() - # should be stored at this point - assert shares_remaining + assert shares_remaining # should be stored at this point groups = set() first_entered_index = -1 for i, group_count in enumerate(shares_remaining): if group_count < slip39.MAX_SHARE_COUNT: first_entered_index = i + break share = None for index, remaining in enumerate(shares_remaining): @@ -337,15 +339,15 @@ async def _show_remaining_groups_and_shares() -> None: m = storage_recovery_shares.fetch_group(index)[0] if not share: share = slip39.decode_mnemonic(m) - identifier = m.split(" ")[0:3] - groups.add((remaining, tuple(identifier))) + identifier = tuple(m.split(" ")[0:3]) + groups.add(identifier) elif remaining == slip39.MAX_SHARE_COUNT: # no shares yet - identifier = storage_recovery_shares.fetch_group(first_entered_index)[ - 0 - ].split(" ")[0:2] - groups.add((remaining, tuple(identifier))) + identifier = tuple( + storage_recovery_shares.fetch_group(first_entered_index)[0].split(" ")[ + 0:2 + ] + ) + groups.add(identifier) assert share # share needs to be set - return await layout.show_remaining_shares( - groups, shares_remaining, share.group_threshold - ) + return groups, shares_remaining, share.group_threshold diff --git a/core/src/apps/management/recovery_device/layout.py b/core/src/apps/management/recovery_device/layout.py index d602b98a8..db2f0a588 100644 --- a/core/src/apps/management/recovery_device/layout.py +++ b/core/src/apps/management/recovery_device/layout.py @@ -6,16 +6,19 @@ from trezor.ui.layouts.recovery import ( # noqa: F401 request_word_count, show_group_share_success, show_recovery_warning, - show_remaining_shares, ) from apps.common import backup_types if TYPE_CHECKING: - from typing import Callable - from trezor.enums import BackupType + # RemainingSharesInfo represents the data structure for remaining shares in SLIP-39 recovery: + # - Set of tuples, each containing 2 or 3 words identifying a group + # - List of remaining share counts for each group + # - Group threshold (minimum number of groups required) + RemainingSharesInfo = tuple[set[tuple[str, ...]], list[int], int] + async def request_mnemonic( word_count: int, backup_type: BackupType | None @@ -123,8 +126,8 @@ async def homescreen_dialog( button_label: str, text: str, subtext: str | None = None, - info_func: Callable | None = None, show_info: bool = False, + remaining_shares_info: "RemainingSharesInfo | None" = None, ) -> None: import storage.recovery as storage_recovery from trezor.ui.layouts.recovery import continue_recovery @@ -133,6 +136,11 @@ async def homescreen_dialog( recovery_type = storage_recovery.get_type() if not await continue_recovery( - button_label, text, subtext, info_func, recovery_type, show_info + button_label, + text, + subtext, + recovery_type, + show_info, + remaining_shares_info, ): raise RecoveryAborted diff --git a/core/src/trezor/ui/layouts/mercury/recovery.py b/core/src/trezor/ui/layouts/mercury/recovery.py index 1399d01ad..c76574420 100644 --- a/core/src/trezor/ui/layouts/mercury/recovery.py +++ b/core/src/trezor/ui/layouts/mercury/recovery.py @@ -1,4 +1,4 @@ -from typing import Callable, Iterable +from typing import TYPE_CHECKING import trezorui2 from trezor import TR @@ -11,6 +11,9 @@ CONFIRMED = trezorui2.CONFIRMED # global_import_cache CANCELLED = trezorui2.CANCELLED # global_import_cache INFO = trezorui2.INFO # global_import_cache +if TYPE_CHECKING: + from apps.management.recovery_device.layout import RemainingSharesInfo + async def request_word_count(recovery_type: RecoveryType) -> int: selector = RustLayout(trezorui2.select_word_count(recovery_type=recovery_type)) @@ -40,16 +43,18 @@ async def request_word( return word -async def show_remaining_shares( - groups: Iterable[tuple[int, tuple[str, ...]]], # remaining + list 3 words - shares_remaining: list[int], - group_threshold: int, -) -> None: +def format_remaining_shares_info( + remaining_shares_info: "RemainingSharesInfo", +) -> list[tuple[str, str]]: from trezor import strings from trezor.crypto.slip39 import MAX_SHARE_COUNT + groups, shares_remaining, group_threshold = remaining_shares_info + pages: list[tuple[str, str]] = [] - for remaining, group in groups: + completed_groups = shares_remaining.count(0) + + for group, remaining in zip(groups, shares_remaining): if 0 < remaining < MAX_SHARE_COUNT: title = strings.format_plural( TR.recovery__x_more_items_starting_template_plural, @@ -58,10 +63,8 @@ async def show_remaining_shares( ) words = "\n".join(group) pages.append((title, words)) - elif ( - remaining == MAX_SHARE_COUNT and shares_remaining.count(0) < group_threshold - ): - groups_remaining = group_threshold - shares_remaining.count(0) + elif remaining == MAX_SHARE_COUNT and completed_groups < group_threshold: + groups_remaining = group_threshold - completed_groups title = strings.format_plural( TR.recovery__x_more_items_starting_template_plural, groups_remaining, @@ -70,13 +73,7 @@ async def show_remaining_shares( words = "\n".join(group) pages.append((title, words)) - await raise_if_not_confirmed( - interact( - RustLayout(trezorui2.show_remaining_shares(pages=pages)), - "show_shares", - ButtonRequestType.Other, - ) - ) + return pages async def show_group_share_success(share_index: int, group_index: int) -> None: @@ -102,26 +99,25 @@ async def continue_recovery( button_label: str, # unused on mercury text: str, subtext: str | None, - info_func: Callable | None, # TODO: see below recovery_type: RecoveryType, show_info: bool = False, + remaining_shares_info: "RemainingSharesInfo | None" = None, ) -> bool: - # TODO: info_func should be changed to return data to be shown (and not show - # them) so that individual models can implement showing logic on their own. - # T3T1 should move the data to `flow_continue_recovery` and hide them - # in the context menu - # NOTE: show_info can be understood as first screen before any shares # NOTE: button request sent from the flow - homepage = RustLayout( + result = await RustLayout( trezorui2.flow_continue_recovery( first_screen=show_info, recovery_type=recovery_type, text=text, subtext=subtext, + pages=( + format_remaining_shares_info(remaining_shares_info) + if remaining_shares_info + else None + ), ) ) - result = await homepage return result is CONFIRMED diff --git a/core/src/trezor/ui/layouts/tr/recovery.py b/core/src/trezor/ui/layouts/tr/recovery.py index 41ceae221..df1064e19 100644 --- a/core/src/trezor/ui/layouts/tr/recovery.py +++ b/core/src/trezor/ui/layouts/tr/recovery.py @@ -1,4 +1,4 @@ -from typing import Callable, Iterable +from typing import TYPE_CHECKING, Iterable import trezorui2 from trezor import TR @@ -7,6 +7,9 @@ from trezor.enums import ButtonRequestType, RecoveryType from ..common import interact from . import RustLayout, raise_if_not_confirmed, show_warning +if TYPE_CHECKING: + from apps.management.recovery_device.layout import RemainingSharesInfo + async def request_word_count(recovery_type: RecoveryType) -> int: count = await interact( @@ -97,9 +100,9 @@ async def continue_recovery( button_label: str, text: str, subtext: str | None, - info_func: Callable | None, recovery_type: RecoveryType, show_info: bool = False, + remaining_shares_info: "RemainingSharesInfo | None" = None, # unused on TR ) -> bool: # TODO: implement info_func? # There is very limited space on the screen diff --git a/core/src/trezor/ui/layouts/tt/recovery.py b/core/src/trezor/ui/layouts/tt/recovery.py index 9163987c8..379eeb1f8 100644 --- a/core/src/trezor/ui/layouts/tt/recovery.py +++ b/core/src/trezor/ui/layouts/tt/recovery.py @@ -1,4 +1,4 @@ -from typing import Callable, Iterable +from typing import TYPE_CHECKING, Callable import trezorui2 from trezor import TR @@ -10,6 +10,9 @@ from . import RustLayout, raise_if_not_confirmed CONFIRMED = trezorui2.CONFIRMED # global_import_cache INFO = trezorui2.INFO # global_import_cache +if TYPE_CHECKING: + from apps.management.recovery_device.layout import RemainingSharesInfo + async def _homepage_with_info( dialog: RustLayout, @@ -54,7 +57,7 @@ async def request_word( async def show_remaining_shares( - groups: Iterable[tuple[int, tuple[str, ...]]], # remaining + list 3 words + groups: set[tuple[str, ...]], shares_remaining: list[int], group_threshold: int, ) -> None: @@ -62,7 +65,9 @@ async def show_remaining_shares( from trezor.crypto.slip39 import MAX_SHARE_COUNT pages: list[tuple[str, str]] = [] - for remaining, group in groups: + completed_groups = shares_remaining.count(0) + + for group, remaining in zip(groups, shares_remaining): if 0 < remaining < MAX_SHARE_COUNT: title = strings.format_plural( TR.recovery__x_more_items_starting_template_plural, @@ -71,10 +76,8 @@ async def show_remaining_shares( ) words = "\n".join(group) pages.append((title, words)) - elif ( - remaining == MAX_SHARE_COUNT and shares_remaining.count(0) < group_threshold - ): - groups_remaining = group_threshold - shares_remaining.count(0) + elif remaining == MAX_SHARE_COUNT and completed_groups < group_threshold: + groups_remaining = group_threshold - completed_groups title = strings.format_plural( TR.recovery__x_more_items_starting_template_plural, groups_remaining, @@ -139,9 +142,9 @@ async def continue_recovery( button_label: str, text: str, subtext: str | None, - info_func: Callable | None, recovery_type: RecoveryType, show_info: bool = False, + remaining_shares_info: "RemainingSharesInfo | None" = None, ) -> bool: from trezor.wire import ActionCancelled @@ -160,19 +163,26 @@ async def continue_recovery( description=description, button=button_label, recovery_type=recovery_type, - info_button=info_func is not None, + info_button=remaining_shares_info is not None, ) ) await button_request("recovery", ButtonRequestType.RecoveryHomepage) - result = ( - await homepage - if info_func is None - else await _homepage_with_info(homepage, info_func) - ) + if remaining_shares_info is None: + result = await homepage + else: + groups, shares_remaining, group_threshold = remaining_shares_info + result = await _homepage_with_info( + homepage, + lambda: show_remaining_shares( + groups, shares_remaining, group_threshold + ), + ) + if result is CONFIRMED: return True + try: await _confirm_abort(recovery_type != RecoveryType.NormalRecovery) except ActionCancelled: diff --git a/tests/device_tests/reset_recovery/test_recovery_slip39_advanced.py b/tests/device_tests/reset_recovery/test_recovery_slip39_advanced.py index 9d5bed002..8aaa4eb74 100644 --- a/tests/device_tests/reset_recovery/test_recovery_slip39_advanced.py +++ b/tests/device_tests/reset_recovery/test_recovery_slip39_advanced.py @@ -73,7 +73,6 @@ def test_secret(client: Client, shares: list[str], secret: str): _test_secret(client, shares, secret) -@pytest.mark.skip_t3t1(reason="currently broken on T3T1") @pytest.mark.parametrize("shares, secret", VECTORS) @pytest.mark.setup_client(uninitialized=True) def test_secret_click_info_button(client: Client, shares: list[str], secret: str): diff --git a/tests/input_flows_helpers.py b/tests/input_flows_helpers.py index 7abe27fcf..38e06fefb 100644 --- a/tests/input_flows_helpers.py +++ b/tests/input_flows_helpers.py @@ -304,7 +304,7 @@ class RecoveryFlow: if self.client.model is models.T2T1: yield from self.tt_click_info() elif self.client.model is models.T3T1: - self.mercury_click_info() + yield from self.mercury_click_info() yield from self.success_more_shares_needed() def tt_click_info( @@ -316,10 +316,19 @@ class RecoveryFlow: self.debug.swipe_up() self.debug.press_yes() - def mercury_click_info(self): + def mercury_click_info(self) -> BRGeneratorType: + # Starting on the homepage, handle the repeated button request + br = yield + assert br.name == "recovery" + assert br.code == B.RecoveryHomepage + # Moving through the menu into the show_shares screen self.debug.click(buttons.CORNER_BUTTON, wait=True) self.debug.synchronize_at("VerticalMenu") self.debug.click(buttons.VERTICAL_MENU[0], wait=True) + br = yield + assert br.name == "show_shares" + assert br.code == B.Other + # Getting back to the homepage self.debug.click(buttons.CORNER_BUTTON, wait=True) self.debug.click(buttons.CORNER_BUTTON, wait=True) diff --git a/tests/ui_tests/fixtures.json b/tests/ui_tests/fixtures.json index 5efa0f1f3..f6b594756 100644 --- a/tests/ui_tests/fixtures.json +++ b/tests/ui_tests/fixtures.json @@ -16511,6 +16511,8 @@ "T3T1_cs_reset_recovery-test_recovery_slip39_advanced.py::test_same_share": "f207194fc64f068391f521c2069ef1fcf55c581c1c241c49c620f9462557fcf1", "T3T1_cs_reset_recovery-test_recovery_slip39_advanced.py::test_secret[shares0-c2d2e26ad06023c60145f1-afc2dad5": "ad415d9e066ae572abc1e81df83758f1eb884aa75d33464ad5cf393edd17c2ac", "T3T1_cs_reset_recovery-test_recovery_slip39_advanced.py::test_secret[shares1-c41d5cf80fed71a008a3a0-eb47093e": "e8a1825029d6cd6fccf2d9433df5153cc7494c00350c0cab09300fc05bff8a87", +"T3T1_cs_reset_recovery-test_recovery_slip39_advanced.py::test_secret_click_info_button[shares0-c2d2-850ffa77": "987214495bb12d72ef099fcf6a5e3e671eda37ed5905e552256c300a7934d919", +"T3T1_cs_reset_recovery-test_recovery_slip39_advanced.py::test_secret_click_info_button[shares1-c41d-ca9ddec8": "2a1e35ba924989ed4d0aa87b9ddfd61ff91084d142bb517a255c517e82f8463f", "T3T1_cs_reset_recovery-test_recovery_slip39_advanced_dryrun.py::test_2of3_dryrun": "06b9b90d151e8eb32f824b25c379e38dccd2732592a9d54cbe963b75b88cef24", "T3T1_cs_reset_recovery-test_recovery_slip39_advanced_dryrun.py::test_2of3_invalid_seed_dryrun": "908dfde70e1269935c7add3c2edaea223724fc78bc8e030e5aeb08dd4fd0ec9f", "T3T1_cs_reset_recovery-test_recovery_slip39_basic.py::test_1of1": "65a86c715390ceca75c9eb2f56a54bf85e2b915489bc080ca45f15a69c5dc55d", @@ -17853,6 +17855,8 @@ "T3T1_de_reset_recovery-test_recovery_slip39_advanced.py::test_same_share": "d585bf013c0c6526bd9dc17f33bb21c0de19e2d5e653159d5821921f32da2705", "T3T1_de_reset_recovery-test_recovery_slip39_advanced.py::test_secret[shares0-c2d2e26ad06023c60145f1-afc2dad5": "03437b32a72c828dbb6365be52090ca3bdabdc969cd8e238b88b8fff868b0417", "T3T1_de_reset_recovery-test_recovery_slip39_advanced.py::test_secret[shares1-c41d5cf80fed71a008a3a0-eb47093e": "aca43fcd50b60741f3dda0e06adb01d74c41cf92853368759c476e6fe55326a9", +"T3T1_de_reset_recovery-test_recovery_slip39_advanced.py::test_secret_click_info_button[shares0-c2d2-850ffa77": "a8b0d2f295238aa40db569320fd715a017f98244887d3366d0a9b7a4ac5460cd", +"T3T1_de_reset_recovery-test_recovery_slip39_advanced.py::test_secret_click_info_button[shares1-c41d-ca9ddec8": "379fc0b4041a4085e2cb41dfd922c1dcddaca4a08da9afe6e72dd0fce01137e4", "T3T1_de_reset_recovery-test_recovery_slip39_advanced_dryrun.py::test_2of3_dryrun": "af836476467f13d07ca7689fef5e9703e85f0ec489c0f3528b5fa9ccc9531041", "T3T1_de_reset_recovery-test_recovery_slip39_advanced_dryrun.py::test_2of3_invalid_seed_dryrun": "f238d4b9f4f6d13fb082a2b048a7aed6a3f25281ce637d973f3a3cdfedbb5909", "T3T1_de_reset_recovery-test_recovery_slip39_basic.py::test_1of1": "2fc4b3a3ab644a339238a7291a162b1e1fbed9714cae4de49b7e1e10814af2bf", @@ -19195,6 +19199,8 @@ "T3T1_en_reset_recovery-test_recovery_slip39_advanced.py::test_same_share": "fb29b0dcf08f0ac969197d48ab9547850846959d09ac215aa5a831b2432e3d3b", "T3T1_en_reset_recovery-test_recovery_slip39_advanced.py::test_secret[shares0-c2d2e26ad06023c60145f1-afc2dad5": "e7748f6864c05f4a81677d0c70baf12c16eb3463f1f6f1f6caf99b89752318ca", "T3T1_en_reset_recovery-test_recovery_slip39_advanced.py::test_secret[shares1-c41d5cf80fed71a008a3a0-eb47093e": "f26e7ad1434f037331503f436e82780c7f3e4c9d6389b064680f1098d82fb2fc", +"T3T1_en_reset_recovery-test_recovery_slip39_advanced.py::test_secret_click_info_button[shares0-c2d2-850ffa77": "6b1dab1a11ae8853e1dbfbdea8362843f9b31b069824f6484d6e8cb00eeaa1f0", +"T3T1_en_reset_recovery-test_recovery_slip39_advanced.py::test_secret_click_info_button[shares1-c41d-ca9ddec8": "b9a12d0387b2f74769366176b53fe405e557f4af329fb4dcd3b2eecccf8ad07a", "T3T1_en_reset_recovery-test_recovery_slip39_advanced_dryrun.py::test_2of3_dryrun": "ff9fd97ab6ca805596bba50ff532a5decded71da4888456d6bc4f6aa6992cc4a", "T3T1_en_reset_recovery-test_recovery_slip39_advanced_dryrun.py::test_2of3_invalid_seed_dryrun": "49149d6d43bbf9fd59f92685569e541fc01a8765e60580518a9b75063bad89ba", "T3T1_en_reset_recovery-test_recovery_slip39_basic.py::test_1of1": "a23cd874215a2944ef6282f9e396715d3f629b9afa3da0dd534049ce6cc323cd", @@ -20537,6 +20543,8 @@ "T3T1_es_reset_recovery-test_recovery_slip39_advanced.py::test_same_share": "bfcc90188c0e704f2b2ee4afd7c616e116493bf0f9ef08aeb006c8eae23e3130", "T3T1_es_reset_recovery-test_recovery_slip39_advanced.py::test_secret[shares0-c2d2e26ad06023c60145f1-afc2dad5": "c86b3f84b601aa162ebfa59e49f7a83ece8b3d03f780ca6cffba01e257f9e631", "T3T1_es_reset_recovery-test_recovery_slip39_advanced.py::test_secret[shares1-c41d5cf80fed71a008a3a0-eb47093e": "739e3359b4c0d22da0ce39c1949d36e50942fa6855941c19bb5ef8a33b10e63a", +"T3T1_es_reset_recovery-test_recovery_slip39_advanced.py::test_secret_click_info_button[shares0-c2d2-850ffa77": "d8b4392d7b9c05436504d714e5b5be17229afe0020db212d5bad30618570ae52", +"T3T1_es_reset_recovery-test_recovery_slip39_advanced.py::test_secret_click_info_button[shares1-c41d-ca9ddec8": "e223d9a2b11fb83b76dc6266c285d9a853db04fbeeecd5d794206042a06159e6", "T3T1_es_reset_recovery-test_recovery_slip39_advanced_dryrun.py::test_2of3_dryrun": "95dc3c3561517b60adb1c2021ea23b9ec11e6b63851e11b4586fbf1762bea32b", "T3T1_es_reset_recovery-test_recovery_slip39_advanced_dryrun.py::test_2of3_invalid_seed_dryrun": "83bbf55f0a13974b9f7832554aa3019ae16d56555f017ecb5130c7594637e775", "T3T1_es_reset_recovery-test_recovery_slip39_basic.py::test_1of1": "e046d946894f3308666456016642ae88826ddcfb4253e7d90f29e410b3dc14fe", @@ -21879,6 +21887,8 @@ "T3T1_fr_reset_recovery-test_recovery_slip39_advanced.py::test_same_share": "c2a537f28eac80fcf674c4f2c1f00c82dd618b06d5b030ad86cf5a7f54178c27", "T3T1_fr_reset_recovery-test_recovery_slip39_advanced.py::test_secret[shares0-c2d2e26ad06023c60145f1-afc2dad5": "fc949c187da623bf33b5bfdb034d9933d3edd0551b4355f70c6347420989939d", "T3T1_fr_reset_recovery-test_recovery_slip39_advanced.py::test_secret[shares1-c41d5cf80fed71a008a3a0-eb47093e": "e8a0a40d53e0f359d1b4178bbf14d7a372f4f56918819fdd9ed5806d515e1b7a", +"T3T1_fr_reset_recovery-test_recovery_slip39_advanced.py::test_secret_click_info_button[shares0-c2d2-850ffa77": "804b8a27ecdde765cf636c8358f30cb51d1cbfa9020c2ed1690bb881d9e6d649", +"T3T1_fr_reset_recovery-test_recovery_slip39_advanced.py::test_secret_click_info_button[shares1-c41d-ca9ddec8": "0eee15f6168a094643bd299c9d7a9d5e5942340ee46a27556b0efb081110ab73", "T3T1_fr_reset_recovery-test_recovery_slip39_advanced_dryrun.py::test_2of3_dryrun": "3c43ed6e7e58eabe8b7db49124c2ec0202fcfc3f50cb15449e24ffeb716c090d", "T3T1_fr_reset_recovery-test_recovery_slip39_advanced_dryrun.py::test_2of3_invalid_seed_dryrun": "08306b13088a2fcca5beaeb6b97c4071a149b399fb607b82e1c57352d4ba78e0", "T3T1_fr_reset_recovery-test_recovery_slip39_basic.py::test_1of1": "7eca9e95d1798c4762fd052d976eb080374b7e8cf96ce1fc01b7f9ad25717bcf",