1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2024-12-23 14:58:09 +00:00

feat(core/ui): T3T1 instruction screens between shares

Changes the content and visual appearance of the screens between shares
during multi-share (shamir) recovery. Context menu with the option to
cancel is added to the screen.
This commit is contained in:
obrusvit 2024-06-27 16:26:03 +02:00 committed by Vít Obrusník
parent 6c75d9f97a
commit 11308f578d
10 changed files with 373 additions and 152 deletions

View File

@ -0,0 +1 @@
[T3T1] Improve instruction screens during multi-share recovery process

View File

@ -239,10 +239,12 @@ static void _librust_qstrs(void) {
MP_QSTR_fingerprint;
MP_QSTR_firmware_update__title;
MP_QSTR_firmware_update__title_fingerprint;
MP_QSTR_first_screen;
MP_QSTR_flow_confirm_output;
MP_QSTR_flow_confirm_reset;
MP_QSTR_flow_confirm_set_new_pin;
MP_QSTR_flow_confirm_summary;
MP_QSTR_flow_continue_recovery;
MP_QSTR_flow_get_address;
MP_QSTR_flow_prompt_backup;
MP_QSTR_flow_request_number;
@ -659,7 +661,9 @@ static void _librust_qstrs(void) {
MP_QSTR_storage_msg__verifying_pin;
MP_QSTR_storage_msg__wrong_pin;
MP_QSTR_subprompt;
MP_QSTR_subtext;
MP_QSTR_subtitle;
MP_QSTR_text;
MP_QSTR_text_confirm;
MP_QSTR_text_info;
MP_QSTR_text_mono;

View File

@ -0,0 +1,232 @@
use crate::{
error,
micropython::{map::Map, obj::Obj, qstr::Qstr, util},
strutil::TString,
translations::TR,
ui::{
button_request::{ButtonRequest, ButtonRequestCode},
component::{
swipe_detect::SwipeSettings,
text::paragraphs::{Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs, VecExt},
ComponentExt, SwipeDirection,
},
flow::{
base::{DecisionBuilder as _, StateChange},
FlowMsg, FlowState, SwipeFlow,
},
layout::obj::LayoutObj,
model_mercury::component::{CancelInfoConfirmMsg, PromptScreen, SwipeContent},
},
};
use super::super::{
component::{Frame, FrameMsg, VerticalMenu, VerticalMenuChoiceMsg},
theme,
};
const RECOVERY_TYPE_NORMAL: u32 = 0;
const RECOVERY_TYPE_DRY_RUN: u32 = 1;
const RECOVERY_TYPE_UNLOCK_REPEATED_BACKUP: u32 = 2;
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum ContinueRecoveryBeforeShares {
Main,
Menu,
}
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum ContinueRecoveryBetweenShares {
Main,
Menu,
CancelIntro,
CancelConfirm,
}
impl FlowState for ContinueRecoveryBeforeShares {
#[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.do_nothing(),
}
}
fn handle_event(&'static self, msg: FlowMsg) -> StateChange {
match (self, msg) {
(Self::Main, FlowMsg::Info) => Self::Menu.transit(),
(Self::Menu, FlowMsg::Cancelled) => Self::Main.swipe_right(),
(Self::Menu, FlowMsg::Choice(0)) => self.return_msg(FlowMsg::Cancelled),
_ => self.do_nothing(),
}
}
}
impl FlowState for ContinueRecoveryBetweenShares {
#[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.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::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.do_nothing(),
}
}
}
#[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 ContinueRecoveryBeforeShares {
fn new_obj(_args: &[Obj], kwargs: &Map) -> Result<Obj, error::Error> {
let first_screen: bool = kwargs.get(Qstr::MP_QSTR_first_screen)?.try_into()?;
let recovery_type: u32 = 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<TString> = kwargs.get(Qstr::MP_QSTR_subtext)?.try_into_option()?; // #shares remaining
let (title, cancel_btn, cancel_title, cancel_intro) =
if recovery_type == RECOVERY_TYPE_NORMAL {
(
TR::recovery__title,
TR::recovery__title_cancel_recovery,
TR::recovery__title_cancel_recovery,
TR::recovery__wanna_cancel_recovery,
)
} else {
// dry-run
(
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 = 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 paragraphs_main = Paragraphs::new(pars);
let content_main = Frame::left_aligned(title.into(), SwipeContent::new(paragraphs_main))
.with_subtitle(TR::words__instructions.into())
.with_menu_button()
.with_footer(footer_instruction, footer_description)
.with_swipe(SwipeDirection::Up, SwipeSettings::default())
.with_swipe(SwipeDirection::Left, SwipeSettings::default())
.map(|msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Info))
.repeated_button_request(ButtonRequest::new(
ButtonRequestCode::RecoveryHomepage,
"recovery".into(),
));
let content_menu = Frame::left_aligned(
TString::empty(),
VerticalMenu::empty().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 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(CancelInfoConfirmMsg::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(()) => Some(FlowMsg::Confirmed),
FrameMsg::Button(CancelInfoConfirmMsg::Cancelled) => Some(FlowMsg::Cancelled),
_ => None,
});
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())
}
}

View File

@ -4,6 +4,7 @@ pub mod confirm_output;
pub mod confirm_reset;
pub mod confirm_set_new_pin;
pub mod confirm_summary;
pub mod continue_recovery;
pub mod get_address;
pub mod prompt_backup;
pub mod request_number;
@ -20,6 +21,7 @@ pub use confirm_output::new_confirm_output;
pub use confirm_reset::new_confirm_reset;
pub use confirm_set_new_pin::SetNewPin;
pub use confirm_summary::new_confirm_summary;
pub use continue_recovery::new_continue_recovery;
pub use get_address::GetAddress;
pub use prompt_backup::PromptBackup;
pub use request_number::RequestNumber;

View File

@ -395,9 +395,6 @@ impl ConfirmBlobParams {
}
}
const RECOVERY_TYPE_DRY_RUN: u32 = 1;
const RECOVERY_TYPE_UNLOCK_REPEATED_BACKUP: u32 = 2;
extern "C" fn new_confirm_blob(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| {
let title: TString = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?;
@ -1053,34 +1050,6 @@ extern "C" fn new_show_checklist(n_args: usize, args: *const Obj, kwargs: *mut M
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
}
extern "C" fn new_confirm_recovery(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| {
let _title: TString = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?;
let description: TString = kwargs.get(Qstr::MP_QSTR_description)?.try_into()?;
let _button: TString = kwargs.get(Qstr::MP_QSTR_button)?.try_into()?;
let recovery_type: u32 = kwargs.get(Qstr::MP_QSTR_recovery_type)?.try_into()?;
let _info_button: bool = kwargs.get_or(Qstr::MP_QSTR_info_button, false)?;
let paragraphs = Paragraphs::new(Paragraph::new(&theme::TEXT_NORMAL, description));
let notification = match recovery_type {
RECOVERY_TYPE_DRY_RUN => TR::recovery__title_dry_run.into(),
RECOVERY_TYPE_UNLOCK_REPEATED_BACKUP => TR::recovery__title_dry_run.into(),
_ => TR::recovery__title.into(),
};
let obj = LayoutObj::new(SwipeUpScreen::new(
Frame::left_aligned(notification, SwipeContent::new(paragraphs))
.with_cancel_button()
.with_footer(TR::instructions__swipe_up.into(), None)
.with_subtitle(TR::words__instructions.into())
.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_select_word_count(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], _kwargs: &Map| {
let obj = LayoutObj::new(Frame::left_aligned(
@ -1688,16 +1657,15 @@ pub static mp_module_trezorui2: Module = obj_module! {
/// mark next to them."""
Qstr::MP_QSTR_show_checklist => obj_fn_kw!(0, new_show_checklist).as_obj(),
/// def confirm_recovery(
/// def flow_continue_recovery(
/// *,
/// title: str,
/// description: str,
/// button: str,
/// first_screen: bool,
/// recovery_type: RecoveryType,
/// info_button: bool = False,
/// text: str,
/// subtext: str | None = None,
/// ) -> LayoutObj[UiResult]:
/// """Device recovery homescreen."""
Qstr::MP_QSTR_confirm_recovery => obj_fn_kw!(0, new_confirm_recovery).as_obj(),
Qstr::MP_QSTR_flow_continue_recovery => obj_fn_kw!(0, flow::continue_recovery::new_continue_recovery).as_obj(),
/// def select_word_count(
/// *,

View File

@ -445,13 +445,12 @@ def show_checklist(
# rust/src/ui/model_mercury/layout.rs
def confirm_recovery(
def flow_continue_recovery(
*,
title: str,
description: str,
button: str,
first_screen: bool,
recovery_type: RecoveryType,
info_button: bool = False,
text: str,
subtext: str | None = None,
) -> LayoutObj[UiResult]:
"""Device recovery homescreen."""

View File

@ -2,7 +2,6 @@ from typing import TYPE_CHECKING
from trezor import TR
from trezor.enums import ButtonRequestType
from trezor.ui.layouts import confirm_action
from trezor.ui.layouts.recovery import ( # noqa: F401
request_word_count,
show_group_share_success,
@ -18,28 +17,6 @@ if TYPE_CHECKING:
from trezor.enums import BackupType
async def _confirm_abort(dry_run: bool = False) -> None:
if dry_run:
await confirm_action(
"abort_recovery",
TR.recovery__title_cancel_dry_run,
TR.recovery__cancel_dry_run,
description=TR.recovery__wanna_cancel_dry_run,
verb=TR.buttons__cancel,
br_code=ButtonRequestType.ProtectCall,
)
else:
await confirm_action(
"abort_recovery",
TR.recovery__title_cancel_recovery,
TR.recovery__progress_will_be_lost,
TR.recovery__wanna_cancel_recovery,
verb=TR.buttons__cancel,
reverse=True,
br_code=ButtonRequestType.ProtectCall,
)
async def request_mnemonic(
word_count: int, backup_type: BackupType | None
) -> str | None:
@ -149,24 +126,12 @@ async def homescreen_dialog(
show_info: bool = False,
) -> None:
import storage.recovery as storage_recovery
from trezor.enums import RecoveryType
from trezor.ui.layouts.recovery import continue_recovery
from trezor.wire import ActionCancelled
from .recover import RecoveryAborted
recovery_type = storage_recovery.get_type()
while True:
if await continue_recovery(
button_label, text, subtext, info_func, recovery_type, show_info
):
# go forward in the recovery process
break
# user has chosen to abort, confirm the choice
try:
await _confirm_abort(recovery_type != RecoveryType.NormalRecovery)
except ActionCancelled:
pass
else:
raise RecoveryAborted
if not await continue_recovery(
button_label, text, subtext, info_func, recovery_type, show_info
):
raise RecoveryAborted

View File

@ -8,23 +8,10 @@ from ..common import interact
from . import RustLayout, raise_if_not_confirmed
CONFIRMED = trezorui2.CONFIRMED # global_import_cache
CANCELLED = trezorui2.CANCELLED # global_import_cache
INFO = trezorui2.INFO # global_import_cache
async def _is_confirmed_info(
dialog: RustLayout,
info_func: Callable,
) -> bool:
while True:
result = await dialog
if result is trezorui2.INFO:
await info_func()
dialog.request_complete_repaint()
else:
return result is CONFIRMED
async def request_word_count(recovery_type: RecoveryType) -> int:
selector = RustLayout(trezorui2.select_word_count(recovery_type=recovery_type))
count = await interact(selector, "word_count", ButtonRequestType.MnemonicWordCount)
@ -112,38 +99,30 @@ async def show_group_share_success(share_index: int, group_index: int) -> None:
async def continue_recovery(
button_label: str,
button_label: str, # unused on mercury
text: str,
subtext: str | None,
info_func: Callable | None,
info_func: Callable | None, # TODO: see below
recovery_type: RecoveryType,
show_info: bool = False, # unused on TT
show_info: bool = False,
) -> bool:
from ..common import button_request
if show_info:
# Show this just one-time
description = TR.recovery__enter_each_word
else:
description = subtext or ""
# 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
homepage = RustLayout(
trezorui2.confirm_recovery(
title=text,
description=description,
button=button_label,
info_button=info_func is not None,
trezorui2.flow_continue_recovery(
first_screen=show_info,
recovery_type=recovery_type,
text=text,
subtext=subtext,
)
)
await button_request("recovery", ButtonRequestType.RecoveryHomepage)
if info_func is not None:
return await _is_confirmed_info(homepage, info_func)
else:
result = await homepage
return result is CONFIRMED
# TODO: the button request might go to rust
result = await interact(homepage, "recovery", ButtonRequestType.RecoveryHomepage)
return result is CONFIRMED
async def show_recovery_warning(

View File

@ -69,6 +69,30 @@ async def show_group_share_success(share_index: int, group_index: int) -> None:
)
async def _confirm_abort(dry_run: bool = False) -> None:
from . import confirm_action
if dry_run:
await confirm_action(
"abort_recovery",
TR.recovery__title_cancel_dry_run,
TR.recovery__cancel_dry_run,
description=TR.recovery__wanna_cancel_dry_run,
verb=TR.buttons__cancel,
br_code=ButtonRequestType.ProtectCall,
)
else:
await confirm_action(
"abort_recovery",
TR.recovery__title_cancel_recovery,
TR.recovery__progress_will_be_lost,
TR.recovery__wanna_cancel_recovery,
verb=TR.buttons__cancel,
reverse=True,
br_code=ButtonRequestType.ProtectCall,
)
async def continue_recovery(
button_label: str,
text: str,
@ -81,6 +105,8 @@ async def continue_recovery(
# There is very limited space on the screen
# (and having middle button would mean shortening the right button text)
from trezor.wire import ActionCancelled
# Never showing info for dry-run, user already saw it and it is disturbing
if recovery_type in (RecoveryType.DryRun, RecoveryType.UnlockRepeatedBackup):
show_info = False
@ -88,22 +114,32 @@ async def continue_recovery(
if subtext:
text += f"\n\n{subtext}"
homepage = RustLayout(
trezorui2.confirm_recovery(
title="",
description=text,
button=button_label,
recovery_type=recovery_type,
info_button=False,
show_info=show_info, # type: ignore [No parameter named "show_info"]
while True:
homepage = RustLayout(
trezorui2.confirm_recovery(
title="",
description=text,
button=button_label,
recovery_type=recovery_type,
info_button=False,
show_info=show_info, # type: ignore [No parameter named "show_info"]
)
)
)
result = await interact(
homepage,
"recovery",
ButtonRequestType.RecoveryHomepage,
)
return result is trezorui2.CONFIRMED
result = await interact(
homepage,
"recovery",
ButtonRequestType.RecoveryHomepage,
)
if result is trezorui2.CONFIRMED:
return True
# user has chosen to abort, confirm the choice
try:
await _confirm_abort(recovery_type != RecoveryType.NormalRecovery)
except ActionCancelled:
pass
else:
return False
async def show_recovery_warning(

View File

@ -11,18 +11,18 @@ CONFIRMED = trezorui2.CONFIRMED # global_import_cache
INFO = trezorui2.INFO # global_import_cache
async def _is_confirmed_info(
async def _homepage_with_info(
dialog: RustLayout,
info_func: Callable,
) -> bool:
) -> trezorui2.UiResult:
while True:
result = await dialog
if result is trezorui2.INFO:
if result is INFO:
await info_func()
dialog.request_complete_repaint()
else:
return result is CONFIRMED
return result
async def request_word_count(recovery_type: RecoveryType) -> int:
@ -111,14 +111,40 @@ async def show_group_share_success(share_index: int, group_index: int) -> None:
)
async def _confirm_abort(dry_run: bool = False) -> None:
from . import confirm_action
if dry_run:
await confirm_action(
"abort_recovery",
TR.recovery__title_cancel_dry_run,
TR.recovery__cancel_dry_run,
description=TR.recovery__wanna_cancel_dry_run,
verb=TR.buttons__cancel,
br_code=ButtonRequestType.ProtectCall,
)
else:
await confirm_action(
"abort_recovery",
TR.recovery__title_cancel_recovery,
TR.recovery__progress_will_be_lost,
TR.recovery__wanna_cancel_recovery,
verb=TR.buttons__cancel,
reverse=True,
br_code=ButtonRequestType.ProtectCall,
)
async def continue_recovery(
button_label: str,
text: str,
subtext: str | None,
info_func: Callable | None,
recovery_type: RecoveryType,
show_info: bool = False, # unused on TT
show_info: bool = False,
) -> bool:
from trezor.wire import ActionCancelled
from ..common import button_request
if show_info:
@ -127,23 +153,32 @@ async def continue_recovery(
else:
description = subtext or ""
homepage = RustLayout(
trezorui2.confirm_recovery(
title=text,
description=description,
button=button_label,
recovery_type=recovery_type,
info_button=info_func is not None,
while True:
homepage = RustLayout(
trezorui2.confirm_recovery(
title=text,
description=description,
button=button_label,
recovery_type=recovery_type,
info_button=info_func is not None,
)
)
)
await button_request("recovery", ButtonRequestType.RecoveryHomepage)
await button_request("recovery", ButtonRequestType.RecoveryHomepage)
if info_func is not None:
return await _is_confirmed_info(homepage, info_func)
else:
result = await homepage
return result is CONFIRMED
result = (
await homepage
if info_func is None
else await _homepage_with_info(homepage, info_func)
)
if result is CONFIRMED:
return True
try:
await _confirm_abort(recovery_type != RecoveryType.NormalRecovery)
except ActionCancelled:
pass
else:
return False
async def show_recovery_warning(