diff --git a/core/.changelog.d/3440.added b/core/.changelog.d/3440.added new file mode 100644 index 000000000..903b399bf --- /dev/null +++ b/core/.changelog.d/3440.added @@ -0,0 +1 @@ +[T2B1] Add loader to homescreen when locking the device diff --git a/core/assets/model_r/lock_small.png b/core/assets/model_r/lock_small.png new file mode 100644 index 000000000..50a2b75b2 Binary files /dev/null and b/core/assets/model_r/lock_small.png differ diff --git a/core/embed/rust/src/ui/component/base.rs b/core/embed/rust/src/ui/component/base.rs index 2c684d790..29e88cc9c 100644 --- a/core/embed/rust/src/ui/component/base.rs +++ b/core/embed/rust/src/ui/component/base.rs @@ -47,6 +47,10 @@ pub trait Component { /// Component can also optionally return a message as a result of the /// interaction. /// + /// For all components to work properly (e.g. react to `ctx.request_paint`), + /// it is required to call `event` function to them, even if they never + /// return a message. + /// /// No painting should be done in this phase. fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option; diff --git a/core/embed/rust/src/ui/model_tr/component/button.rs b/core/embed/rust/src/ui/model_tr/component/button.rs index da53990bf..2b26baad3 100644 --- a/core/embed/rust/src/ui/model_tr/component/button.rs +++ b/core/embed/rust/src/ui/model_tr/component/button.rs @@ -5,6 +5,7 @@ use crate::{ component::{Component, Event, EventCtx, Never}, constant, display::{self, Color, Font, Icon}, + event::PhysicalButton, geometry::{Alignment2D, Offset, Point, Rect}, }, }; @@ -20,6 +21,15 @@ pub enum ButtonPos { Right, } +impl From for ButtonPos { + fn from(btn: PhysicalButton) -> Self { + match btn { + PhysicalButton::Left => ButtonPos::Left, + PhysicalButton::Right => ButtonPos::Right, + } + } +} + pub struct Button where T: StringType, diff --git a/core/embed/rust/src/ui/model_tr/component/button_controller.rs b/core/embed/rust/src/ui/model_tr/component/button_controller.rs index be7304775..72bbf7165 100644 --- a/core/embed/rust/src/ui/model_tr/component/button_controller.rs +++ b/core/embed/rust/src/ui/model_tr/component/button_controller.rs @@ -46,6 +46,9 @@ pub enum ButtonControllerMsg { Triggered(ButtonPos, bool), /// Button was pressed and held for longer time (not released yet). LongPressed(ButtonPos), + /// Hold-to-confirm button was released prematurely - without triggering + /// LongPressed. + ReleasedWithoutLongPress(ButtonPos), } /// Defines what kind of button should be currently used. @@ -186,10 +189,11 @@ where self.long_pressed_timer = None; Some(ButtonControllerMsg::Triggered(self.pos, long_press)) } - _ => { + ButtonType::HoldToConfirm(_) => { self.hold_ended(ctx); - None + Some(ButtonControllerMsg::ReleasedWithoutLongPress(self.pos)) } + _ => None, } } @@ -269,6 +273,8 @@ where button_area: Rect, /// Handling optional ignoring of buttons after pressing the other button. ignore_btn_delay: Option, + /// Whether to count with middle button + handle_middle_button: bool, } impl ButtonController @@ -276,6 +282,7 @@ where T: StringType, { pub fn new(btn_layout: ButtonLayout) -> Self { + let handle_middle_button = btn_layout.btn_middle.is_some(); Self { pad: Pad::with_background(theme::BG).with_clear(), left_btn: ButtonContainer::new(ButtonPos::Left, btn_layout.btn_left), @@ -284,6 +291,7 @@ where state: ButtonState::Nothing, button_area: Rect::zero(), ignore_btn_delay: None, + handle_middle_button, } } @@ -296,6 +304,7 @@ where /// Updating all the three buttons to the wanted states. pub fn set(&mut self, btn_layout: ButtonLayout) { + self.handle_middle_button = btn_layout.btn_middle.is_some(); self.pad.clear(); self.left_btn.set(btn_layout.btn_left, self.button_area); self.middle_btn.set(btn_layout.btn_middle, self.button_area); @@ -471,13 +480,21 @@ where return None; } } - self.got_pressed(ctx, ButtonPos::Middle); - self.middle_hold_started(ctx); - ( - // ↓ ↓ - ButtonState::BothDown, - Some(ButtonControllerMsg::Pressed(ButtonPos::Middle)), - ) + // ↓ ↓ + if self.handle_middle_button { + self.got_pressed(ctx, ButtonPos::Middle); + self.middle_hold_started(ctx); + ( + ButtonState::BothDown, + Some(ButtonControllerMsg::Pressed(ButtonPos::Middle)), + ) + } else { + self.got_pressed(ctx, b.into()); + ( + ButtonState::BothDown, + Some(ButtonControllerMsg::Pressed(b.into())), + ) + } } _ => (self.state, None), }, @@ -487,7 +504,14 @@ where ButtonEvent::ButtonReleased(b) => { self.middle_btn.hold_ended(ctx); // _ ↓ | ↓ _ - (ButtonState::OneReleased(b), None) + if self.handle_middle_button { + (ButtonState::OneReleased(b), None) + } else { + ( + ButtonState::OneReleased(b), + Some(ButtonControllerMsg::Triggered(b.into(), false)), + ) + } } _ => (self.state, None), }, @@ -507,7 +531,14 @@ where ignore_btn_delay.make_button_clickable(ButtonPos::Left); ignore_btn_delay.make_button_clickable(ButtonPos::Right); } - (ButtonState::Nothing, self.middle_btn.maybe_trigger(ctx)) + if self.handle_middle_button { + (ButtonState::Nothing, self.middle_btn.maybe_trigger(ctx)) + } else { + ( + ButtonState::Nothing, + Some(ButtonControllerMsg::Triggered(b.into(), false)), + ) + } } _ => (self.state, None), }, diff --git a/core/embed/rust/src/ui/model_tr/component/homescreen.rs b/core/embed/rust/src/ui/model_tr/component/homescreen.rs index dfa46da8a..0fc496765 100644 --- a/core/embed/rust/src/ui/model_tr/component/homescreen.rs +++ b/core/embed/rust/src/ui/model_tr/component/homescreen.rs @@ -5,7 +5,7 @@ use crate::{ component::{Child, Component, Event, EventCtx, Label}, constant::{HEIGHT, WIDTH}, display::{ - rect_fill, + self, rect_fill, toif::{Toif, ToifFormat}, Font, Icon, }, @@ -17,7 +17,7 @@ use crate::{ use super::{ super::constant, common::display_center, theme, ButtonController, ButtonControllerMsg, - ButtonLayout, ButtonPos, CancelConfirmMsg, + ButtonLayout, ButtonPos, CancelConfirmMsg, LoaderMsg, ProgressLoader, }; const AREA: Rect = constant::screen(); @@ -34,6 +34,8 @@ const NOTIFICATION_FONT: Font = Font::NORMAL; const NOTIFICATION_ICON: Icon = theme::ICON_WARNING; const COINJOIN_CORNER: Point = AREA.top_right().ofs(Offset::new(-2, 2)); +const HOLD_TO_LOCK_MS: u32 = 1000; + fn paint_default_image() { theme::ICON_LOGO.draw( TOP_CENTER + Offset::y(LOGO_ICON_TOP_MARGIN), @@ -43,6 +45,12 @@ fn paint_default_image() { ); } +enum CurrentScreen { + EmptyAtStart, + Homescreen, + Loader, +} + pub struct Homescreen where T: StringType, @@ -53,18 +61,31 @@ where notification: Option<(T, u8)>, /// Used for HTC functionality to lock device from homescreen invisible_buttons: Child>, + /// Holds the loader component + loader: Option>>, + /// Whether to show the loader or not + show_loader: bool, + /// Which screen is currently shown + current_screen: CurrentScreen, } impl Homescreen where T: StringType + Clone, { - pub fn new(label: T, notification: Option<(T, u8)>) -> Self { - let invisible_btn_layout = ButtonLayout::htc_none_htc("".into(), "".into()); + pub fn new(label: T, notification: Option<(T, u8)>, loader_description: Option) -> Self { + // Buttons will not be visible, we only need both left and right to be existing + // so we can get the events from them. + let invisible_btn_layout = ButtonLayout::text_none_text("".into(), "".into()); + let loader = + loader_description.map(|desc| Child::new(ProgressLoader::new(desc, HOLD_TO_LOCK_MS))); Self { label: Label::centered(label, theme::TEXT_BIG), notification, invisible_buttons: Child::new(ButtonController::new(invisible_btn_layout)), + loader, + show_loader: false, + current_screen: CurrentScreen::EmptyAtStart, } } @@ -152,24 +173,64 @@ where fn place(&mut self, bounds: Rect) -> Rect { self.label.place(LABEL_AREA); + self.loader.place(AREA); bounds } fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { Self::event_usb(self, ctx, event); - // HTC press of any button will lock the device - if let Some(ButtonControllerMsg::Triggered(..)) = self.invisible_buttons.event(ctx, event) { - return Some(()); + + // Only care about button and loader events when there is a possibility of + // locking the device + if let Some(self_loader) = &mut self.loader { + // When loader has completely grown, we can lock the device + if let Some(LoaderMsg::GrownCompletely) = self_loader.event(ctx, event) { + return Some(()); + } + + // Longer hold of any button will lock the device. + // Normal/quick presses and releases will show/hide the loader. + let button_event = self.invisible_buttons.event(ctx, event); + if let Some(ButtonControllerMsg::Pressed(..)) = button_event { + if !self.show_loader { + self.show_loader = true; + self_loader.mutate(ctx, |ctx, loader| { + loader.start(ctx); + ctx.request_paint(); + }); + } + } + if let Some(ButtonControllerMsg::Triggered(..)) = button_event { + self.show_loader = false; + self_loader.mutate(ctx, |ctx, loader| { + loader.stop(ctx); + ctx.request_paint(); + }); + } } + None } fn paint(&mut self) { - // Painting the homescreen image first, as the notification and label - // should be "on top of it" - self.paint_homescreen_image(); - self.paint_notification(); - self.paint_label(); + // Redraw the whole screen when the screen changes (loader vs homescreen) + if self.show_loader { + if !matches!(self.current_screen, CurrentScreen::Loader) { + display::clear(); + self.current_screen = CurrentScreen::Loader; + } + self.loader.paint(); + } else { + if !matches!(self.current_screen, CurrentScreen::Homescreen) { + display::clear(); + self.current_screen = CurrentScreen::Homescreen; + } + // Painting the homescreen image first, as the notification and label + // should be "on top of it" + self.paint_homescreen_image(); + self.paint_notification(); + self.paint_label(); + } } } diff --git a/core/embed/rust/src/ui/model_tr/component/loader.rs b/core/embed/rust/src/ui/model_tr/component/loader.rs index 5846a4c1d..23bf16463 100644 --- a/core/embed/rust/src/ui/model_tr/component/loader.rs +++ b/core/embed/rust/src/ui/model_tr/component/loader.rs @@ -3,14 +3,15 @@ use crate::{ time::{Duration, Instant}, ui::{ animation::Animation, - component::{Component, Event, EventCtx}, - display::{self, Color, Font}, + component::{Child, Component, Event, EventCtx}, + constant, + display::{self, Color, Font, LOADER_MAX}, geometry::{Offset, Rect}, util::animation_disabled, }, }; -use super::theme; +use super::{theme, Progress}; pub const DEFAULT_DURATION_MS: u32 = 1000; pub const SHRINKING_DURATION_MS: u32 = 500; @@ -186,10 +187,9 @@ where } fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { - let now = Instant::now(); - if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event { if self.is_animating() { + let now = Instant::now(); if self.is_completely_grown(now) { self.state = State::Grown; ctx.request_paint(); @@ -256,6 +256,93 @@ impl LoaderStyleSheet { } } +pub struct ProgressLoader +where + T: StringType, +{ + loader: Child>, + duration_ms: u32, + start_time: Option, +} + +impl ProgressLoader +where + T: StringType + Clone, +{ + const LOADER_FRAMES_DEFAULT: u32 = 20; + + pub fn new(loader_description: T, duration_ms: u32) -> Self { + Self { + loader: Child::new( + Progress::new(false, loader_description).with_icon(theme::ICON_LOCK_SMALL), + ), + duration_ms, + start_time: None, + } + } + + pub fn start(&mut self, ctx: &mut EventCtx) { + self.start_time = Some(Instant::now()); + self.loader.event(ctx, Event::Progress(0, "")); + self.loader.mutate(ctx, |ctx, loader| { + loader.request_paint(ctx); + }); + ctx.request_anim_frame(); + } + + pub fn stop(&mut self, _ctx: &mut EventCtx) { + self.start_time = None; + } + + fn is_animating(&self) -> bool { + self.start_time.is_some() + } + + fn percentage(&self, now: Instant) -> u32 { + if let Some(start_time) = self.start_time { + let elapsed = now.saturating_duration_since(start_time); + let elapsed_ms = elapsed.to_millis(); + (elapsed_ms * 100) / self.duration_ms + } else { + 0 + } + } +} + +impl Component for ProgressLoader +where + T: StringType + Clone, +{ + type Msg = LoaderMsg; + + fn place(&mut self, bounds: Rect) -> Rect { + self.loader.place(constant::screen()); + bounds + } + + fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event { + if self.is_animating() { + let now = Instant::now(); + let percentage = self.percentage(now); + let new_loader_value = (percentage * LOADER_MAX as u32) / 100; + self.loader + .event(ctx, Event::Progress(new_loader_value as u16, "")); + // Returning only after the loader was fully painted + if percentage >= 100 { + return Some(LoaderMsg::GrownCompletely); + } + ctx.request_anim_frame(); + } + } + None + } + + fn paint(&mut self) { + self.loader.paint(); + } +} + // DEBUG-ONLY SECTION BELOW #[cfg(feature = "ui_debug")] diff --git a/core/embed/rust/src/ui/model_tr/component/mod.rs b/core/embed/rust/src/ui/model_tr/component/mod.rs index 808659007..977d5ed39 100644 --- a/core/embed/rust/src/ui/model_tr/component/mod.rs +++ b/core/embed/rust/src/ui/model_tr/component/mod.rs @@ -22,7 +22,7 @@ pub use input_methods::{ choice::{Choice, ChoiceFactory, ChoicePage}, choice_item::ChoiceItem, }; -pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet}; +pub use loader::{Loader, LoaderMsg, LoaderStyle, LoaderStyleSheet, ProgressLoader}; pub use result::ResultScreen; pub use welcome_screen::WelcomeScreen; diff --git a/core/embed/rust/src/ui/model_tr/component/progress.rs b/core/embed/rust/src/ui/model_tr/component/progress.rs index 98bcad306..2346eb7e2 100644 --- a/core/embed/rust/src/ui/model_tr/component/progress.rs +++ b/core/embed/rust/src/ui/model_tr/component/progress.rs @@ -10,7 +10,7 @@ use crate::{ Child, Component, Event, EventCtx, Label, Never, Pad, }, constant, - display::{self, Font}, + display::{self, Font, Icon, LOADER_MAX}, geometry::Rect, util::animation_disabled, }, @@ -22,17 +22,21 @@ const BOTTOM_DESCRIPTION_MARGIN: i16 = 10; const LOADER_Y_OFFSET_TITLE: i16 = -10; const LOADER_Y_OFFSET_NO_TITLE: i16 = -20; +// Clippy was complaining about `very complex type used` +type UpdateDescriptionFn = fn(&str) -> Result; + pub struct Progress where T: StringType, { - title: Child>, + title: Option>>, value: u16, loader_y_offset: i16, indeterminate: bool, description: Child>>, description_pad: Pad, - update_description: fn(&str) -> Result, + update_description: Option>, + icon: Icon, } impl Progress @@ -41,14 +45,9 @@ where { const AREA: Rect = constant::screen(); - pub fn new( - title: T, - indeterminate: bool, - description: T, - update_description: fn(&str) -> Result, - ) -> Self { + pub fn new(indeterminate: bool, description: T) -> Self { Self { - title: Child::new(Label::centered(title, theme::TEXT_BOLD)), + title: None, value: 0, loader_y_offset: 0, indeterminate, @@ -56,9 +55,42 @@ where Paragraph::new(&theme::TEXT_NORMAL, description).centered(), )), description_pad: Pad::with_background(theme::BG), - update_description, + update_description: None, + icon: theme::ICON_TICK_FAT, } } + + pub fn with_title(mut self, title: T) -> Self { + self.title = Some(Child::new(Label::centered(title, theme::TEXT_BOLD))); + self + } + + pub fn with_update_description( + mut self, + update_description: UpdateDescriptionFn, + ) -> Self { + self.update_description = Some(update_description); + self + } + + pub fn with_icon(mut self, icon: Icon) -> Self { + self.icon = icon; + self + } + + pub fn request_paint(&self, ctx: &mut EventCtx) { + if !animation_disabled() { + ctx.request_paint(); + } + } + + pub fn value(&self) -> u16 { + self.value + } + + pub fn reached_max_value(&self) -> bool { + self.value >= LOADER_MAX + } } impl Component for Progress @@ -78,11 +110,16 @@ where .filter(|c| *c == '\n') .count() as i16; - let (title, rest, loader_y_offset) = if self.title.inner().text().as_ref().is_empty() { - (Rect::zero(), Self::AREA, LOADER_Y_OFFSET_NO_TITLE) + let no_title_case = (Rect::zero(), Self::AREA, LOADER_Y_OFFSET_NO_TITLE); + let (title, rest, loader_y_offset) = if let Some(self_title) = &self.title { + if !self_title.inner().text().as_ref().is_empty() { + let (title, rest) = Self::AREA.split_top(self_title.inner().max_size().y); + (title, rest, LOADER_Y_OFFSET_TITLE) + } else { + no_title_case + } } else { - let (title, rest) = Self::AREA.split_top(self.title.inner().max_size().y); - (title, rest, LOADER_Y_OFFSET_TITLE) + no_title_case }; let (_loader, description) = rest.split_bottom( @@ -96,18 +133,21 @@ where } fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option { + self.title.event(ctx, event); + self.description.event(ctx, event); + if let Event::Progress(new_value, new_description) = event { if mem::replace(&mut self.value, new_value) != new_value { - if !animation_disabled() { - ctx.request_paint(); - } + self.request_paint(ctx); + } + if let Some(update_description) = self.update_description { self.description.mutate(ctx, |ctx, para| { // NOTE: not doing any change for empty new descriptions // (currently, there is no use-case for deleting the description) if !new_description.is_empty() && para.inner_mut().content().as_ref() != new_description { - let new_description = unwrap!((self.update_description)(new_description)); + let new_description = unwrap!((update_description)(new_description)); para.inner_mut().update(new_description); para.change_page(0); // Recompute bounding box. ctx.request_paint(); @@ -135,7 +175,7 @@ where self.loader_y_offset, theme::FG, theme::BG, - Some((theme::ICON_TICK_FAT, theme::FG)), + Some((self.icon, theme::FG)), ); } self.description_pad.paint(); diff --git a/core/embed/rust/src/ui/model_tr/layout.rs b/core/embed/rust/src/ui/model_tr/layout.rs index 8332dc507..8bee3e033 100644 --- a/core/embed/rust/src/ui/model_tr/layout.rs +++ b/core/embed/rust/src/ui/model_tr/layout.rs @@ -1523,12 +1523,11 @@ extern "C" fn new_show_progress(n_args: usize, args: *const Obj, kwargs: *mut Ma // Description updates are received as &str and we need to provide a way to // convert them to StrBuffer. - let obj = LayoutObj::new(Progress::new( - title, - indeterminate, - description, - StrBuffer::alloc, - ))?; + let obj = LayoutObj::new( + Progress::new(indeterminate, description) + .with_title(title) + .with_update_description(StrBuffer::alloc), + )?; Ok(obj.into()) }; unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } @@ -1567,9 +1566,11 @@ extern "C" fn new_show_homescreen(n_args: usize, args: *const Obj, kwargs: *mut kwargs.get(Qstr::MP_QSTR_notification)?.try_into_option()?; let notification_level: u8 = kwargs.get_or(Qstr::MP_QSTR_notification_level, 0)?; let skip_first_paint: bool = kwargs.get(Qstr::MP_QSTR_skip_first_paint)?.try_into()?; + let hold: bool = kwargs.get(Qstr::MP_QSTR_hold)?.try_into()?; let notification = notification.map(|w| (w, notification_level)); - let obj = LayoutObj::new(Homescreen::new(label, notification))?; + let loader_description = hold.then_some("Locking the device...".into()); + let obj = LayoutObj::new(Homescreen::new(label, notification, loader_description))?; if skip_first_paint { obj.skip_first_paint(); } diff --git a/core/embed/rust/src/ui/model_tr/res/lock_small.toif b/core/embed/rust/src/ui/model_tr/res/lock_small.toif new file mode 100644 index 000000000..29cee42e5 Binary files /dev/null and b/core/embed/rust/src/ui/model_tr/res/lock_small.toif differ diff --git a/core/embed/rust/src/ui/model_tr/theme/mod.rs b/core/embed/rust/src/ui/model_tr/theme/mod.rs index c1a61b59b..c196eeaf5 100644 --- a/core/embed/rust/src/ui/model_tr/theme/mod.rs +++ b/core/embed/rust/src/ui/model_tr/theme/mod.rs @@ -91,6 +91,7 @@ include_icon!( include_icon!(ICON_DEVICE_NAME, "model_tr/res/device_name.toif"); // 116*18 include_icon!(ICON_EYE, "model_tr/res/eye_round.toif"); // 12*7 include_icon!(ICON_LOCK, "model_tr/res/lock.toif"); // 10*10 +include_icon!(ICON_LOCK_SMALL, "model_tr/res/lock_small.toif"); // 6*7 include_icon!(ICON_LOGO, "model_tr/res/logo_22_33.toif"); // 22*33 include_icon!(ICON_LOGO_EMPTY, "model_tr/res/logo_22_33_empty.toif"); include_icon!( diff --git a/rust/trezor-client/src/client/ethereum.rs b/rust/trezor-client/src/client/ethereum.rs index 365c12440..c6b78f532 100644 --- a/rust/trezor-client/src/client/ethereum.rs +++ b/rust/trezor-client/src/client/ethereum.rs @@ -45,7 +45,7 @@ impl Trezor { Box::new(|_, m: protos::EthereumMessageSignature| { let signature = m.signature(); if signature.len() != 65 { - return Err(Error::MalformedSignature); + return Err(Error::MalformedSignature) } let r = signature[0..32].try_into().unwrap(); let s = signature[32..64].try_into().unwrap();