1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2025-03-17 16:46:05 +00:00

feat(core/ui): highlight repeated words on T3T1

ShowShareWords flow now informs the user if the word is repeated. The
most typical usecase in 1-of-1 shamir (SingleShare) where 3rd and 4th
word is "academic".
This commit is contained in:
obrusvit 2024-06-11 16:31:50 +02:00 committed by matejcik
parent 3a039c505f
commit aaa2ece3ba
14 changed files with 218 additions and 30 deletions

View File

@ -246,6 +246,7 @@ static void _librust_qstrs(void) {
MP_QSTR_haptic_feedback__enable;
MP_QSTR_haptic_feedback__subtitle;
MP_QSTR_haptic_feedback__title;
MP_QSTR_highlight_repeated;
MP_QSTR_hold;
MP_QSTR_hold_danger;
MP_QSTR_homescreen__click_to_connect;
@ -528,6 +529,7 @@ static void _librust_qstrs(void) {
MP_QSTR_reset__slip39_checklist_write_down;
MP_QSTR_reset__slip39_checklist_write_down_recovery;
MP_QSTR_reset__the_threshold_sets_the_number_of_shares;
MP_QSTR_reset__the_word_is_repeated;
MP_QSTR_reset__threshold_info;
MP_QSTR_reset__title_backup_is_done;
MP_QSTR_reset__title_create_wallet;

View File

@ -1339,6 +1339,7 @@ pub enum TranslatedString {
reset__repeat_for_all_shares = 938, // "Repeat for all shares."
homescreen__settings_subtitle = 939, // "Settings"
homescreen__settings_title = 940, // "Homescreen"
reset__the_word_is_repeated = 941, // "The word is repeated"
}
impl TranslatedString {
@ -2672,6 +2673,7 @@ impl TranslatedString {
Self::reset__repeat_for_all_shares => "Repeat for all shares.",
Self::homescreen__settings_subtitle => "Settings",
Self::homescreen__settings_title => "Homescreen",
Self::reset__the_word_is_repeated => "The word is repeated",
}
}
@ -4006,6 +4008,7 @@ impl TranslatedString {
Qstr::MP_QSTR_reset__repeat_for_all_shares => Some(Self::reset__repeat_for_all_shares),
Qstr::MP_QSTR_homescreen__settings_subtitle => Some(Self::homescreen__settings_subtitle),
Qstr::MP_QSTR_homescreen__settings_title => Some(Self::homescreen__settings_title),
Qstr::MP_QSTR_reset__the_word_is_repeated => Some(Self::reset__the_word_is_repeated),
_ => None,
}
}

View File

@ -66,6 +66,10 @@ impl<'a> Label<'a> {
self.text = text;
}
pub fn set_style(&mut self, style: TextStyle) {
self.layout.style = style;
}
pub fn font(&self) -> Font {
self.layout.style.text_font
}

View File

@ -86,11 +86,6 @@ where
self
}
pub fn title_styled(mut self, style: TextStyle) -> Self {
self.title = self.title.styled(style);
self
}
#[inline(never)]
pub fn with_subtitle(mut self, subtitle: TString<'static>) -> Self {
let style = theme::TEXT_SUB_GREY;
@ -125,6 +120,18 @@ where
.button_styled(theme::button_danger())
}
pub fn title_styled(mut self, style: TextStyle) -> Self {
self.title = self.title.styled(style);
self
}
pub fn subtitle_styled(mut self, style: TextStyle) -> Self {
if let Some(subtitle) = self.subtitle.take() {
self.subtitle = Some(subtitle.styled(style))
}
self
}
pub fn button_styled(mut self, style: ButtonStyleSheet) -> Self {
if self.button.is_some() {
self.button = Some(self.button.unwrap().styled(style));
@ -160,6 +167,25 @@ where
ctx.request_paint();
}
pub fn update_subtitle(
&mut self,
ctx: &mut EventCtx,
new_subtitle: TString<'static>,
new_style: Option<TextStyle>,
) {
let style = new_style.unwrap_or(theme::TEXT_SUB_GREY);
match &mut self.subtitle {
Some(subtitle) => {
subtitle.set_style(style);
subtitle.set_text(new_subtitle);
}
None => {
self.subtitle = Some(Label::new(new_subtitle, self.title.alignment(), style));
}
}
ctx.request_paint();
}
pub fn update_content<F, R>(&mut self, ctx: &mut EventCtx, update_fn: F) -> R
where
F: Fn(&mut EventCtx, &mut T) -> R,

View File

@ -5,10 +5,13 @@ use crate::{
translations::TR,
ui::{
animation::Animation,
component::{Component, Event, EventCtx, Never, SwipeDirection},
component::{
swipe_detect::{SwipeConfig, SwipeSettings},
Component, Event, EventCtx, Never, SwipeDirection,
},
event::SwipeEvent,
geometry::{Alignment, Alignment2D, Insets, Offset, Rect},
model_mercury::component::Footer,
model_mercury::component::{Footer, Frame, FrameMsg},
shape::{self, Renderer},
util,
},
@ -17,12 +20,112 @@ use heapless::Vec;
const MAX_WORDS: usize = 33; // super-shamir has 33 words, all other have less
const ANIMATION_DURATION_MS: Duration = Duration::from_millis(166);
type IndexVec = Vec<u8, MAX_WORDS>;
/// Component showing mnemonic/share words during backup procedure. Model T3T1
/// contains one word per screen. A user is instructed to swipe up/down to see
/// next/previous word.
/// This is a wrapper around a Frame so that the subtitle and Footer of the
/// Frame can be updated based on the index of the word shown. Actual share
/// words are rendered within `ShareWordsInner` component,
pub struct ShareWords<'a> {
area: Rect,
subtitle: TString<'static>,
frame: Frame<ShareWordsInner<'a>>,
repeated_indices: Option<IndexVec>,
}
impl<'a> ShareWords<'a> {
pub fn new(
title: TString<'static>,
subtitle: TString<'static>,
share_words: Vec<TString<'a>, MAX_WORDS>,
highlight_repeated: bool,
) -> Self {
let repeated_indices = if highlight_repeated {
Some(Self::find_repeated(share_words.as_slice()))
} else {
None
};
Self {
subtitle,
frame: Frame::left_aligned(title, ShareWordsInner::new(share_words))
.with_swipe(SwipeDirection::Up, SwipeSettings::default())
.with_swipe(SwipeDirection::Down, SwipeSettings::default())
.with_vertical_pages()
.with_subtitle(subtitle),
repeated_indices,
}
}
fn find_repeated(share_words: &[TString]) -> IndexVec {
let mut repeated_indices = IndexVec::new();
for i in (0..share_words.len()).rev() {
let word = share_words[i];
if share_words[..i].contains(&word) {
unwrap!(repeated_indices.push(i as u8));
}
}
repeated_indices.reverse();
repeated_indices
}
}
impl<'a> Component for ShareWords<'a> {
type Msg = FrameMsg<Never>;
fn place(&mut self, bounds: Rect) -> Rect {
self.frame.place(bounds);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
let page_index = self.frame.inner().page_index as u8;
if let Some(repeated_indices) = &self.repeated_indices {
if repeated_indices.contains(&(page_index as usize)) {
let updated_subtitle = TString::from_translation(TR::reset__the_word_is_repeated);
self.frame
.update_subtitle(ctx, updated_subtitle, Some(theme::TEXT_SUB_GREEN_LIME));
} else {
self.frame
.update_subtitle(ctx, self.subtitle, Some(theme::TEXT_SUB_GREY));
}
}
self.frame.update_footer_counter(ctx, page_index + 1);
self.frame.event(ctx, event)
}
fn paint(&mut self) {
// TODO: remove when ui-t3t1 done
}
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
self.frame.render(target);
}
#[cfg(feature = "ui_bounds")]
fn bounds(&self, _sink: &mut dyn FnMut(Rect)) {}
}
#[cfg(feature = "micropython")]
impl<'a> crate::ui::flow::Swipable for ShareWords<'a> {
fn get_swipe_config(&self) -> SwipeConfig {
self.frame.get_swipe_config()
}
fn get_internal_page_count(&self) -> usize {
self.frame.get_internal_page_count()
}
}
#[cfg(feature = "ui_debug")]
impl<'a> crate::trace::Trace for ShareWords<'a> {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("ShareWords");
t.child("inner", &self.frame);
}
}
struct ShareWordsInner<'a> {
share_words: Vec<TString<'a>, MAX_WORDS>,
page_index: i16,
next_index: i16,
@ -35,12 +138,11 @@ pub struct ShareWords<'a> {
progress: i16,
}
impl<'a> ShareWords<'a> {
impl<'a> ShareWordsInner<'a> {
const AREA_WORD_HEIGHT: i16 = 91;
pub fn new(share_words: Vec<TString<'a>, MAX_WORDS>) -> Self {
fn new(share_words: Vec<TString<'a>, MAX_WORDS>) -> Self {
Self {
area: Rect::zero(),
share_words,
page_index: 0,
next_index: 0,
@ -76,25 +178,21 @@ impl<'a> ShareWords<'a> {
}
}
impl<'a> Component for ShareWords<'a> {
impl<'a> Component for ShareWordsInner<'a> {
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
self.area = bounds;
let used_area = bounds
.inset(Insets::sides(theme::SPACING))
.inset(Insets::bottom(theme::SPACING));
self.area_word = Rect::snap(
used_area.center(),
Offset::new(used_area.width(), ShareWords::AREA_WORD_HEIGHT),
Offset::new(used_area.width(), ShareWordsInner::AREA_WORD_HEIGHT),
Alignment2D::CENTER,
);
self.footer
.place(used_area.split_bottom(Footer::HEIGHT_SIMPLE).1);
self.area
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
@ -199,12 +297,61 @@ impl<'a> Component for ShareWords<'a> {
}
#[cfg(feature = "ui_debug")]
impl<'a> crate::trace::Trace for ShareWords<'a> {
impl<'a> crate::trace::Trace for ShareWordsInner<'a> {
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.component("ShareWords");
t.component("ShareWordsInner");
let word = &self.share_words[self.page_index as usize];
let content = word.map(|w| uformat!("{}. {}\n", self.page_index + 1, w));
t.string("screen_content", content.as_str().into());
t.int("page_count", self.share_words.len() as i64)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_repeated_indices() {
let words0 = [];
let words1 = [
TString::from_str("aaa"),
TString::from_str("bbb"),
TString::from_str("ccc"),
];
let words2 = [
TString::from_str("aaa"),
TString::from_str("aaa"),
TString::from_str("bbb"),
];
let words3 = [
TString::from_str("aaa"),
TString::from_str("aaa"),
TString::from_str("bbb"),
TString::from_str("bbb"),
TString::from_str("aaa"),
];
let words4 = [
TString::from_str("aaa"),
TString::from_str("aaa"),
TString::from_str("aaa"),
TString::from_str("aaa"),
TString::from_str("aaa"),
];
assert_eq!(ShareWords::find_repeated(&words0), IndexVec::new());
assert_eq!(ShareWords::find_repeated(&words1), IndexVec::new());
assert_eq!(
ShareWords::find_repeated(&words2),
IndexVec::from_slice(&[1]).unwrap()
);
assert_eq!(
ShareWords::find_repeated(&words3),
IndexVec::from_slice(&[1, 3, 4]).unwrap()
);
assert_eq!(
ShareWords::find_repeated(&words4),
IndexVec::from_slice(&[1, 2, 3, 4]).unwrap()
);
}
}

View File

@ -85,6 +85,7 @@ impl ShowShareWords {
.and_then(|desc: TString| if desc.is_empty() { None } else { Some(desc) });
let text_info: Obj = kwargs.get(Qstr::MP_QSTR_text_info)?;
let text_confirm: TString = kwargs.get(Qstr::MP_QSTR_text_confirm)?.try_into()?;
let highlight_repeated: bool = kwargs.get(Qstr::MP_QSTR_highlight_repeated)?.try_into()?;
let nwords = share_words_vec.len();
let mut instructions_paragraphs = ParagraphVecShort::new();
@ -108,12 +109,8 @@ impl ShowShareWords {
.one_button_request(ButtonRequestCode::ResetDevice.with_type("share_words"))
.with_pages(move |_| nwords + 2);
let content_words = Frame::left_aligned(title, ShareWords::new(share_words_vec))
.with_subtitle(subtitle)
.with_swipe(SwipeDirection::Up, SwipeSettings::default())
.with_swipe(SwipeDirection::Down, SwipeSettings::default())
.with_vertical_pages()
.map(|_| None);
let content_words =
ShareWords::new(title, subtitle, share_words_vec, highlight_repeated).map(|_| None);
let content_confirm =
Frame::left_aligned(text_confirm, PromptScreen::new_hold_to_confirm())

View File

@ -1710,6 +1710,7 @@ pub static mp_module_trezorui2: Module = obj_module! {
/// description: str,
/// text_info: Iterable[str],
/// text_confirm: str,
/// highlight_repeated: bool,
/// ) -> LayoutObj[UiResult]:
/// """Show wallet backup words preceded by an instruction screen and followed by
/// confirmation."""

View File

@ -717,6 +717,8 @@ pub const TEXT_MAIN_GREY_LIGHT: TextStyle =
TextStyle::new(Font::NORMAL, GREY_LIGHT, BG, GREY, GREY);
pub const TEXT_SUB_GREY_LIGHT: TextStyle = TextStyle::new(Font::SUB, GREY_LIGHT, BG, GREY, GREY);
pub const TEXT_SUB_GREY: TextStyle = TextStyle::new(Font::SUB, GREY, BG, GREY, GREY);
pub const TEXT_SUB_GREEN_LIME: TextStyle =
TextStyle::new(Font::SUB, GREEN_LIME, BG, GREEN_LIME, GREEN_LIME);
pub const TEXT_WARNING: TextStyle = TextStyle::new(Font::NORMAL, ORANGE_LIGHT, BG, GREY, GREY);
pub const TEXT_MONO: TextStyle = TextStyle::new(Font::MONO, GREY_EXTRA_LIGHT, BG, GREY, GREY)
.with_line_breaking(LineBreaking::BreakWordsNoHyphen)

View File

@ -405,6 +405,7 @@ def flow_show_share_words(
description: str,
text_info: Iterable[str],
text_confirm: str,
highlight_repeated: bool,
) -> LayoutObj[UiResult]:
"""Show wallet backup words preceded by an instruction screen and followed by
confirmation."""

View File

@ -664,6 +664,7 @@ class TR:
reset__slip39_checklist_write_down: str = "Write down and check all shares"
reset__slip39_checklist_write_down_recovery: str = "Write down & check all wallet backup shares"
reset__the_threshold_sets_the_number_of_shares: str = "The threshold sets the number of shares "
reset__the_word_is_repeated: str = "The word is repeated"
reset__threshold_info: str = "= minimum number of unique word lists used for recovery."
reset__title_backup_is_done: str = "Backup is done"
reset__title_create_wallet: str = "Create wallet"

View File

@ -23,6 +23,7 @@ async def show_share_words(
) -> None:
title = TR.reset__recovery_wallet_backup_title
highlight_repeated = True
if share_index is None:
subtitle = ""
elif group_index is None:
@ -51,6 +52,7 @@ async def show_share_words(
description=description,
text_info=text_info,
text_confirm=text_confirm,
highlight_repeated=highlight_repeated,
)
)

View File

@ -666,6 +666,7 @@
"reset__slip39_checklist_write_down": "Write down and check all shares",
"reset__slip39_checklist_write_down_recovery": "Write down & check all wallet backup shares",
"reset__the_threshold_sets_the_number_of_shares": "The threshold sets the number of shares ",
"reset__the_word_is_repeated": "The word is repeated",
"reset__threshold_info": "= minimum number of unique word lists used for recovery.",
"reset__title_backup_is_done": "Backup is done",
"reset__title_create_wallet": "Create wallet",

View File

@ -939,5 +939,6 @@
"937": "reset__words_may_repeat",
"938": "reset__repeat_for_all_shares",
"939": "homescreen__settings_subtitle",
"940": "homescreen__settings_title"
"940": "homescreen__settings_title",
"941": "reset__the_word_is_repeated"
}

View File

@ -1,8 +1,8 @@
{
"current": {
"merkle_root": "5b5781c3374ff27125228c121ab97436a2ba1f0581657ee56b9fa601fe3bde97",
"datetime": "2024-06-24T13:47:55.544267",
"commit": "d79768cec726c26ac1f82e62fc71b6d4568786a2"
"merkle_root": "e283dd87dd502e2b2c3e2cbe1f52efcbe99c2bf6cd8ed883ed26800a6885e4e7",
"datetime": "2024-06-24T12:47:16.181365",
"commit": "c66a73b895b37f2a9de3ac6427f372c649ecea8d"
},
"history": [
{