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

feat(core/ui): redesigned receive flow

[no changelog]
This commit is contained in:
Martin Milata 2023-02-14 23:47:20 +01:00
parent 1b94a7cb7b
commit 4af5939a0b
38 changed files with 1452 additions and 576 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
core/assets/info-circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

BIN
core/assets/octa-bang.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

View File

@ -21,6 +21,7 @@ static void _librust_qstrs(void) {
MP_QSTR_jpeg_test; MP_QSTR_jpeg_test;
MP_QSTR_confirm_action; MP_QSTR_confirm_action;
MP_QSTR_confirm_homescreen; MP_QSTR_confirm_homescreen;
MP_QSTR_confirm_address;
MP_QSTR_confirm_blob; MP_QSTR_confirm_blob;
MP_QSTR_confirm_properties; MP_QSTR_confirm_properties;
MP_QSTR_confirm_coinjoin; MP_QSTR_confirm_coinjoin;
@ -40,6 +41,7 @@ static void _librust_qstrs(void) {
MP_QSTR_show_success; MP_QSTR_show_success;
MP_QSTR_show_warning; MP_QSTR_show_warning;
MP_QSTR_show_info; MP_QSTR_show_info;
MP_QSTR_show_mismatch;
MP_QSTR_show_simple; MP_QSTR_show_simple;
MP_QSTR_request_number; MP_QSTR_request_number;
MP_QSTR_request_pin; MP_QSTR_request_pin;
@ -56,6 +58,7 @@ static void _librust_qstrs(void) {
MP_QSTR_show_remaining_shares; MP_QSTR_show_remaining_shares;
MP_QSTR_show_share_words; MP_QSTR_show_share_words;
MP_QSTR_show_progress; MP_QSTR_show_progress;
MP_QSTR_show_address_details;
MP_QSTR_attach_timer_fn; MP_QSTR_attach_timer_fn;
MP_QSTR_touch_event; MP_QSTR_touch_event;
@ -107,6 +110,7 @@ static void _librust_qstrs(void) {
MP_QSTR_time_ms; MP_QSTR_time_ms;
MP_QSTR_app_name; MP_QSTR_app_name;
MP_QSTR_icon_name; MP_QSTR_icon_name;
MP_QSTR_account;
MP_QSTR_accounts; MP_QSTR_accounts;
MP_QSTR_indeterminate; MP_QSTR_indeterminate;
MP_QSTR_notification; MP_QSTR_notification;
@ -114,4 +118,5 @@ static void _librust_qstrs(void) {
MP_QSTR_bootscreen; MP_QSTR_bootscreen;
MP_QSTR_skip_first_paint; MP_QSTR_skip_first_paint;
MP_QSTR_wrong_pin; MP_QSTR_wrong_pin;
MP_QSTR_xpubs;
} }

View File

@ -87,6 +87,12 @@ impl BlendedImage {
} }
} }
// NOTE: currently this function is used too rarely to justify writing special
// case for unblended image.
pub fn single(icon: Icon, color: Color, area_color: Color) -> Self {
Self::new(icon, icon, color, color, area_color)
}
fn paint_image(&self) { fn paint_image(&self) {
display::icon_over_icon( display::icon_over_icon(
None, None,

View File

@ -24,9 +24,9 @@ pub use map::Map;
pub use marquee::Marquee; pub use marquee::Marquee;
pub use maybe::Maybe; pub use maybe::Maybe;
pub use pad::Pad; pub use pad::Pad;
pub use paginated::{PageMsg, Paginate}; pub use paginated::{AuxPageMsg, PageMsg, Paginate};
pub use painter::Painter; pub use painter::Painter;
pub use placed::{FixedHeightBar, GridPlaced}; pub use placed::{FixedHeightBar, Floating, GridPlaced, VSplit};
pub use qr_code::Qr; pub use qr_code::Qr;
pub use text::{ pub use text::{
formatted::FormattedText, formatted::FormattedText,

View File

@ -3,6 +3,15 @@ use crate::ui::component::{
FormattedText, FormattedText,
}; };
pub enum AuxPageMsg {
/// Page component was instantiated with BACK button on every page and it
/// was pressed.
GoBack,
/// Page component was configured to react to swipes and user swiped left.
SwipeLeft,
}
/// Common message type for pagination components. /// Common message type for pagination components.
pub enum PageMsg<T, U> { pub enum PageMsg<T, U> {
/// Pass-through from paged component. /// Pass-through from paged component.
@ -12,9 +21,8 @@ pub enum PageMsg<T, U> {
/// "OK" and "Cancel" buttons. /// "OK" and "Cancel" buttons.
Controls(U), Controls(U),
/// Page component was instantiated with BACK button on every page and it /// Auxilliary events used by exotic pages on touchscreens.
/// was pressed. Aux(AuxPageMsg),
GoBack,
} }
pub trait Paginate { pub trait Paginate {

View File

@ -1,6 +1,6 @@
use crate::ui::{ use crate::ui::{
component::{Component, Event, EventCtx}, component::{Component, Event, EventCtx},
geometry::{Grid, GridCellSpan, Rect}, geometry::{Alignment, Alignment2D, Grid, GridCellSpan, Insets, Offset, Rect, TOP_RIGHT},
}; };
pub struct GridPlaced<T> { pub struct GridPlaced<T> {
@ -121,3 +121,135 @@ where
d.close(); d.close();
} }
} }
pub struct Floating<T> {
inner: T,
size: Offset,
border: Offset,
align: Alignment2D,
}
impl<T> Floating<T> {
pub const fn new(size: Offset, border: Offset, align: Alignment2D, inner: T) -> Self {
Self {
inner,
size,
border,
align,
}
}
pub const fn top_right(side: i16, border: i16, inner: T) -> Self {
let size = Offset::uniform(side);
let border = Offset::uniform(border);
Self::new(size, border, TOP_RIGHT, inner)
}
}
impl<T> Component for Floating<T>
where
T: Component,
{
type Msg = T::Msg;
fn place(&mut self, bounds: Rect) -> Rect {
let mut border = self.border;
let area = match self.align.0 {
Alignment::Start => bounds.split_left(self.size.x).0,
Alignment::Center => panic!("alignment not supported"),
Alignment::End => {
border.x = -border.x;
bounds.split_right(self.size.x).1
}
};
let area = match self.align.1 {
Alignment::Start => area.split_top(self.size.y).0,
Alignment::Center => panic!("alignment not supported"),
Alignment::End => {
border.y = -border.y;
area.split_bottom(self.size.y).1
}
};
self.inner.place(area.translate(border))
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.inner.event(ctx, event)
}
fn paint(&mut self) {
self.inner.paint()
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for Floating<T>
where
T: Component,
T: crate::trace::Trace,
{
fn trace(&self, d: &mut dyn crate::trace::Tracer) {
d.open("Floating");
d.field("inner", &self.inner);
d.close();
}
}
pub struct VSplit<T, U> {
first: T,
second: U,
width: i16,
spacing: i16,
}
impl<T, U> VSplit<T, U> {
pub const fn new(width: i16, spacing: i16, first: T, second: U) -> Self {
Self {
first,
second,
width,
spacing,
}
}
}
impl<M, T, U> Component for VSplit<T, U>
where
T: Component<Msg = M>,
U: Component<Msg = M>,
{
type Msg = M;
fn place(&mut self, bounds: Rect) -> Rect {
let (left, right) = bounds.split_left(self.width);
let right = right.inset(Insets::left(self.spacing));
self.first.place(left);
self.second.place(right);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
self.first
.event(ctx, event)
.or_else(|| self.second.event(ctx, event))
}
fn paint(&mut self) {
self.first.paint();
self.second.paint();
}
}
#[cfg(feature = "ui_debug")]
impl<T, U> crate::trace::Trace for VSplit<T, U>
where
T: Component + crate::trace::Trace,
U: Component + crate::trace::Trace,
{
fn trace(&self, d: &mut dyn crate::trace::Tracer) {
d.open("VSplit");
d.field("first", &self.first);
d.field("second", &self.second);
d.close();
}
}

View File

@ -32,7 +32,7 @@ where
PageMsg::Content(_) => Err(Error::TypeError), PageMsg::Content(_) => Err(Error::TypeError),
PageMsg::Controls(true) => Ok(CONFIRMED.as_obj()), PageMsg::Controls(true) => Ok(CONFIRMED.as_obj()),
PageMsg::Controls(false) => Ok(CANCELLED.as_obj()), PageMsg::Controls(false) => Ok(CANCELLED.as_obj()),
PageMsg::GoBack => unreachable!(), PageMsg::Aux(_) => Err(Error::TypeError),
} }
} }
} }

View File

@ -0,0 +1,158 @@
use heapless::Vec;
use crate::{
error::Error,
ui::{
component::{
text::paragraphs::{
Paragraph, ParagraphSource, ParagraphStrType, ParagraphVecShort, Paragraphs, VecExt,
},
Component, Event, EventCtx, Never, Paginate, Qr,
},
geometry::Rect,
},
};
use super::{theme, Frame};
pub struct AddressDetails<T> {
qr_code: Frame<Qr, T>,
details: Frame<Paragraphs<ParagraphVecShort<T>>, T>,
xpub_view: Frame<Paragraphs<Paragraph<T>>, T>,
xpubs: Vec<(T, T), 16>,
current_page: usize,
}
impl<T> AddressDetails<T>
where
T: ParagraphStrType + From<&'static str>,
{
pub fn new(
qr_address: T,
case_sensitive: bool,
account: Option<T>,
path: Option<T>,
) -> Result<Self, Error> {
let mut para = ParagraphVecShort::new();
if let Some(a) = account {
para.add(Paragraph::new(&theme::TEXT_NORMAL, "Account:".into()));
para.add(Paragraph::new(&theme::TEXT_MONO, a));
}
if let Some(p) = path {
para.add(Paragraph::new(
&theme::TEXT_NORMAL,
"Derivation path:".into(),
));
para.add(Paragraph::new(&theme::TEXT_MONO, p));
}
let result = Self {
qr_code: Frame::left_aligned(
theme::label_title(),
"RECEIVE ADDRESS".into(),
Qr::new(qr_address, case_sensitive)?.with_border(7),
)
.with_border(theme::borders_horizontal_scroll()),
details: Frame::left_aligned(
theme::label_title(),
"RECEIVING TO".into(),
para.into_paragraphs(),
)
.with_border(theme::borders_horizontal_scroll()),
xpub_view: Frame::left_aligned(
theme::label_title(),
" \n ".into(),
Paragraph::new(&theme::TEXT_XPUB, "".into()).into_paragraphs(),
)
.with_border(theme::borders_horizontal_scroll()),
xpubs: Vec::new(),
current_page: 0,
};
Ok(result)
}
pub fn add_xpub(&mut self, title: T, xpub: T) -> Result<(), Error> {
self.xpubs
.push((title, xpub))
.map_err(|_| Error::OutOfRange)
}
}
impl<T> Paginate for AddressDetails<T>
where
T: ParagraphStrType + Clone,
{
fn page_count(&mut self) -> usize {
2 + self.xpubs.len()
}
fn change_page(&mut self, to_page: usize) {
self.current_page = to_page;
if to_page > 1 {
let i = to_page - 2;
// Context is needed for updating child so that it can request repaint. In this
// case the parent component that handles paging always requests complete
// repaint after page change so we can use a dummy context here.
let mut dummy_ctx = EventCtx::new();
self.xpub_view
.update_title(&mut dummy_ctx, self.xpubs[i].0.clone());
self.xpub_view.update_content(&mut dummy_ctx, |p| {
p.inner_mut().update(self.xpubs[i].1.clone());
p.change_page(0)
});
}
}
}
impl<T> Component for AddressDetails<T>
where
T: ParagraphStrType,
{
type Msg = Never;
fn place(&mut self, bounds: Rect) -> Rect {
self.qr_code.place(bounds);
self.details.place(bounds);
self.xpub_view.place(bounds);
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
match self.current_page {
0 => self.qr_code.event(ctx, event),
1 => self.details.event(ctx, event),
_ => self.xpub_view.event(ctx, event),
}
}
fn paint(&mut self) {
match self.current_page {
0 => self.qr_code.paint(),
1 => self.details.paint(),
_ => self.xpub_view.paint(),
}
}
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
match self.current_page {
0 => self.qr_code.bounds(sink),
1 => self.details.bounds(sink),
_ => self.xpub_view.bounds(sink),
}
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for AddressDetails<T>
where
T: ParagraphStrType + crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.open("AddressDetails");
match self.current_page {
0 => self.qr_code.trace(t),
1 => self.details.trace(t),
_ => self.xpub_view.trace(t),
}
t.close();
}
}

View File

@ -2,7 +2,8 @@ use crate::{
time::Duration, time::Duration,
ui::{ ui::{
component::{ component::{
Component, ComponentExt, Event, EventCtx, FixedHeightBar, GridPlaced, Map, TimerToken, Component, ComponentExt, Event, EventCtx, FixedHeightBar, Floating, GridPlaced, Map,
Paginate, TimerToken, VSplit,
}, },
display::{self, toif::Icon, Color, Font}, display::{self, toif::Icon, Color, Font},
event::TouchEvent, event::TouchEvent,
@ -34,7 +35,7 @@ impl<T> Button<T> {
/// (positive). /// (positive).
pub const BASELINE_OFFSET: i16 = -3; pub const BASELINE_OFFSET: i16 = -3;
pub fn new(content: ButtonContent<T>) -> Self { pub const fn new(content: ButtonContent<T>) -> Self {
Self { Self {
content, content,
area: Rect::zero(), area: Rect::zero(),
@ -46,19 +47,19 @@ impl<T> Button<T> {
} }
} }
pub fn with_text(text: T) -> Self { pub const fn with_text(text: T) -> Self {
Self::new(ButtonContent::Text(text)) Self::new(ButtonContent::Text(text))
} }
pub fn with_icon(icon: Icon) -> Self { pub const fn with_icon(icon: Icon) -> Self {
Self::new(ButtonContent::Icon(icon)) Self::new(ButtonContent::Icon(icon))
} }
pub fn with_icon_and_text(content: IconText) -> Self { pub const fn with_icon_and_text(content: IconText) -> Self {
Self::new(ButtonContent::IconAndText(content)) Self::new(ButtonContent::IconAndText(content))
} }
pub fn with_icon_blend(bg: Icon, fg: Icon, fg_offset: Offset) -> Self { pub const fn with_icon_blend(bg: Icon, fg: Icon, fg_offset: Offset) -> Self {
Self::new(ButtonContent::IconBlend(bg, fg, fg_offset)) Self::new(ButtonContent::IconBlend(bg, fg, fg_offset))
} }
@ -425,6 +426,29 @@ impl<T> Button<T> {
Self::cancel_confirm(left, right, right_size_factor) Self::cancel_confirm(left, right, right_size_factor)
} }
pub fn cancel_confirm_square(
left: Button<T>,
right: Button<T>,
) -> CancelConfirmSquare<
T,
impl Fn(ButtonMsg) -> Option<CancelConfirmMsg>,
impl Fn(ButtonMsg) -> Option<CancelConfirmMsg>,
>
where
T: AsRef<str>,
{
theme::button_bar(VSplit::new(
theme::BUTTON_HEIGHT,
theme::BUTTON_SPACING,
left.map(|msg| {
(matches!(msg, ButtonMsg::Clicked)).then(|| CancelConfirmMsg::Cancelled)
}),
right.map(|msg| {
(matches!(msg, ButtonMsg::Clicked)).then(|| CancelConfirmMsg::Confirmed)
}),
))
}
pub fn cancel_info_confirm( pub fn cancel_info_confirm(
confirm: T, confirm: T,
info: T, info: T,
@ -542,6 +566,9 @@ type CancelInfoConfirm<T, F0, F1, F2> = FixedHeightBar<(
Map<GridPlaced<Button<T>>, F2>, Map<GridPlaced<Button<T>>, F2>,
)>; )>;
type CancelConfirmSquare<T, F0, F1> =
FixedHeightBar<VSplit<Map<Button<T>, F0>, Map<Button<T>, F1>>>;
pub enum CancelInfoConfirmMsg { pub enum CancelInfoConfirmMsg {
Cancelled, Cancelled,
Info, Info,
@ -610,3 +637,87 @@ impl IconText {
} }
} }
} }
pub struct FloatingButton<T> {
inner: T,
button: Floating<Button<&'static str>>,
}
pub enum FloatingButtonMsg<T> {
ButtonClicked,
Content(T),
}
impl<T> FloatingButton<T>
where
T: Component,
{
pub const fn top_right_corner(icon: Icon, inner: T) -> Self {
Self {
inner,
button: Floating::top_right(
theme::CORNER_BUTTON_SIDE,
theme::CORNER_BUTTON_SPACING,
Button::with_icon(icon),
),
}
}
pub fn inner(&self) -> &T {
&self.inner
}
}
impl<T> Component for FloatingButton<T>
where
T: Component,
{
type Msg = FloatingButtonMsg<T::Msg>;
fn place(&mut self, bounds: Rect) -> Rect {
self.button.place(bounds);
self.inner.place(bounds)
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
if let Some(ButtonMsg::Clicked) = self.button.event(ctx, event) {
return Some(FloatingButtonMsg::ButtonClicked);
}
self.inner.event(ctx, event).map(FloatingButtonMsg::Content)
}
fn paint(&mut self) {
self.inner.paint();
self.button.paint();
}
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
self.inner.bounds(sink);
self.button.bounds(sink);
}
}
impl<T> Paginate for FloatingButton<T>
where
T: Paginate,
{
fn page_count(&mut self) -> usize {
self.inner.page_count()
}
fn change_page(&mut self, to_page: usize) {
self.inner.change_page(to_page)
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for FloatingButton<T>
where
T: Component + crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.open("FloatingButton");
t.field("inner", self.inner());
t.close();
}
}

View File

@ -1,8 +1,11 @@
use crate::ui::{ use crate::ui::{
component::{ component::{
image::BlendedImage, image::BlendedImage,
text::paragraphs::{ text::{
Paragraph, ParagraphSource, ParagraphStrType, ParagraphVecShort, Paragraphs, VecExt, paragraphs::{
Paragraph, ParagraphSource, ParagraphStrType, ParagraphVecShort, Paragraphs, VecExt,
},
TextStyle,
}, },
Child, Component, Event, EventCtx, Never, Child, Component, Event, EventCtx, Never,
}, },
@ -116,15 +119,19 @@ where
} }
} }
pub fn with_description(mut self, description: T) -> Self { pub fn with_text(mut self, style: &'static TextStyle, text: T) -> Self {
if !description.as_ref().is_empty() { if !text.as_ref().is_empty() {
self.paragraphs self.paragraphs
.inner_mut() .inner_mut()
.add(Paragraph::new(&theme::TEXT_NORMAL_OFF_WHITE, description).centered()); .add(Paragraph::new(style, text).centered());
} }
self self
} }
pub fn with_description(self, description: T) -> Self {
self.with_text(&theme::TEXT_NORMAL_OFF_WHITE, description)
}
pub fn new_shares(lines: [T; 4], controls: U) -> Self { pub fn new_shares(lines: [T; 4], controls: U) -> Self {
let [l0, l1, l2, l3] = lines; let [l0, l1, l2, l3] = lines;
Self { Self {

View File

@ -1,6 +1,8 @@
use super::theme; use super::theme;
use crate::ui::{ use crate::ui::{
component::{label::Label, text::TextStyle, Child, Component, Event, EventCtx}, component::{
base::ComponentExt, label::Label, text::TextStyle, Child, Component, Event, EventCtx,
},
display::{self, toif::Icon, Color}, display::{self, toif::Icon, Color},
geometry::{Alignment, Insets, Offset, Rect}, geometry::{Alignment, Insets, Offset, Rect},
util::icon_text_center, util::icon_text_center,
@ -45,6 +47,23 @@ where
pub fn inner(&self) -> &T { pub fn inner(&self) -> &T {
self.content.inner() self.content.inner()
} }
pub fn update_title(&mut self, ctx: &mut EventCtx, new_title: U) {
self.title.mutate(ctx, |ctx, t| {
t.set_text(new_title);
t.request_complete_repaint(ctx)
})
}
pub fn update_content<F>(&mut self, ctx: &mut EventCtx, update_fn: F)
where
F: Fn(&mut T),
{
self.content.mutate(ctx, |ctx, c| {
update_fn(c);
c.request_complete_repaint(ctx)
})
}
} }
impl<T, U> Component for Frame<T, U> impl<T, U> Component for Frame<T, U>

View File

@ -0,0 +1,144 @@
use crate::ui::{
component::{
base::ComponentExt, AuxPageMsg, Component, Event, EventCtx, Never, Pad, PageMsg, Paginate,
},
display::{self, Color},
geometry::Rect,
};
use super::{theme, ScrollBar, Swipe, SwipeDirection};
const SCROLLBAR_HEIGHT: i16 = 32;
pub struct HorizontalPage<T> {
content: T,
pad: Pad,
swipe: Swipe,
scrollbar: ScrollBar,
swipe_right_to_go_back: bool,
fade: Option<u16>,
}
impl<T> HorizontalPage<T>
where
T: Paginate,
T: Component,
{
pub fn new(content: T, background: Color) -> Self {
Self {
content,
swipe: Swipe::new(),
pad: Pad::with_background(background),
scrollbar: ScrollBar::horizontal(),
swipe_right_to_go_back: false,
fade: None,
}
}
pub fn with_swipe_right_to_go_back(mut self) -> Self {
self.swipe_right_to_go_back = true;
self
}
pub fn inner(&self) -> &T {
&self.content
}
fn setup_swipe(&mut self) {
self.swipe.allow_left = self.scrollbar.has_next_page();
self.swipe.allow_right = self.scrollbar.has_previous_page() || self.swipe_right_to_go_back;
}
fn on_page_change(&mut self, ctx: &mut EventCtx) {
// Adjust the swipe parameters according to the scrollbar.
self.setup_swipe();
// Change the page in the content, make sure it gets completely repainted and
// clear the background under it.
self.content.change_page(self.scrollbar.active_page);
self.content.request_complete_repaint(ctx);
self.pad.clear();
// Swipe has dimmed the screen, so fade back to normal backlight after the next
// paint.
self.fade = Some(theme::BACKLIGHT_NORMAL);
}
}
impl<T> Component for HorizontalPage<T>
where
T: Paginate,
T: Component,
{
type Msg = PageMsg<T::Msg, Never>;
fn place(&mut self, bounds: Rect) -> Rect {
self.swipe.place(bounds);
let (content, scrollbar) = bounds.split_bottom(SCROLLBAR_HEIGHT);
self.pad.place(content);
self.content.place(content);
self.scrollbar.place(scrollbar);
self.scrollbar
.set_count_and_active_page(self.content.page_count(), 0);
self.setup_swipe();
bounds
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
ctx.set_page_count(self.scrollbar.page_count);
if let Some(swipe) = self.swipe.event(ctx, event) {
match swipe {
SwipeDirection::Left => {
self.scrollbar.go_to_next_page();
self.on_page_change(ctx);
return None;
}
SwipeDirection::Right => {
if self.swipe_right_to_go_back && self.scrollbar.active_page == 0 {
return Some(PageMsg::Aux(AuxPageMsg::GoBack));
}
self.scrollbar.go_to_previous_page();
self.on_page_change(ctx);
return None;
}
_ => {
// Ignore other directions.
}
}
}
self.content.event(ctx, event).map(PageMsg::Content)
}
fn paint(&mut self) {
self.pad.paint();
self.content.paint();
self.scrollbar.paint();
if let Some(val) = self.fade.take() {
// Note that this is blocking and takes some time.
display::fade_backlight(val);
}
}
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
sink(self.pad.area);
self.scrollbar.bounds(sink);
self.content.bounds(sink);
}
}
#[cfg(feature = "ui_debug")]
impl<T> crate::trace::Trace for HorizontalPage<T>
where
T: crate::trace::Trace,
{
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
t.open("HorizontalPage");
t.field("active_page", &self.scrollbar.active_page);
t.field("page_count", &self.scrollbar.page_count);
t.field("content", &self.content);
t.close();
}
}

View File

@ -1,3 +1,4 @@
mod address_details;
mod button; mod button;
mod dialog; mod dialog;
mod fido; mod fido;
@ -7,6 +8,7 @@ mod frame;
mod hold_to_confirm; mod hold_to_confirm;
#[cfg(feature = "dma2d")] #[cfg(feature = "dma2d")]
mod homescreen; mod homescreen;
mod horizontal_page;
mod keyboard; mod keyboard;
mod loader; mod loader;
mod number_input; mod number_input;
@ -17,9 +19,10 @@ mod scroll;
mod swipe; mod swipe;
mod welcome_screen; mod welcome_screen;
pub use address_details::AddressDetails;
pub use button::{ pub use button::{
Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet, CancelConfirmMsg, Button, ButtonContent, ButtonMsg, ButtonStyle, ButtonStyleSheet, CancelConfirmMsg,
CancelInfoConfirmMsg, IconText, SelectWordMsg, CancelInfoConfirmMsg, FloatingButton, FloatingButtonMsg, IconText, SelectWordMsg,
}; };
pub use dialog::{Dialog, DialogMsg, IconDialog}; pub use dialog::{Dialog, DialogMsg, IconDialog};
pub use fido::{FidoConfirm, FidoMsg}; pub use fido::{FidoConfirm, FidoMsg};
@ -27,6 +30,7 @@ pub use frame::{Frame, NotificationFrame};
pub use hold_to_confirm::{HoldToConfirm, HoldToConfirmMsg}; pub use hold_to_confirm::{HoldToConfirm, HoldToConfirmMsg};
#[cfg(feature = "dma2d")] #[cfg(feature = "dma2d")]
pub use homescreen::{Homescreen, HomescreenMsg, Lockscreen}; pub use homescreen::{Homescreen, HomescreenMsg, Lockscreen};
pub use horizontal_page::HorizontalPage;
pub use keyboard::{ pub use keyboard::{
bip39::Bip39Input, bip39::Bip39Input,
mnemonic::{MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg}, mnemonic::{MnemonicInput, MnemonicKeyboard, MnemonicKeyboardMsg},

View File

@ -1,7 +1,8 @@
use crate::ui::{ use crate::ui::{
component::{ component::{
base::ComponentExt, paginated::PageMsg, Component, Event, EventCtx, FixedHeightBar, Label, base::ComponentExt,
Pad, Paginate, paginated::{AuxPageMsg, PageMsg},
Component, Event, EventCtx, FixedHeightBar, Label, Pad, Paginate,
}, },
display::{self, toif::Icon, Color}, display::{self, toif::Icon, Color},
geometry::{Insets, Rect}, geometry::{Insets, Rect},
@ -21,6 +22,7 @@ pub struct SwipePage<T, U> {
scrollbar: ScrollBar, scrollbar: ScrollBar,
hint: Label<&'static str>, hint: Label<&'static str>,
button_back: Option<Button<&'static str>>, button_back: Option<Button<&'static str>>,
swipe_left: bool,
fade: Option<u16>, fade: Option<u16>,
} }
@ -39,6 +41,7 @@ where
pad: Pad::with_background(background), pad: Pad::with_background(background),
hint: Label::centered("SWIPE TO CONTINUE", theme::label_page_hint()), hint: Label::centered("SWIPE TO CONTINUE", theme::label_page_hint()),
button_back: None, button_back: None,
swipe_left: false,
fade: None, fade: None,
} }
} }
@ -48,9 +51,15 @@ where
self self
} }
pub fn with_swipe_left(mut self) -> Self {
self.swipe_left = true;
self
}
fn setup_swipe(&mut self) { fn setup_swipe(&mut self) {
self.swipe.allow_up = self.scrollbar.has_next_page(); self.swipe.allow_up = self.scrollbar.has_next_page();
self.swipe.allow_down = self.scrollbar.has_previous_page(); self.swipe.allow_down = self.scrollbar.has_previous_page();
self.swipe.allow_left = self.swipe_left;
} }
fn on_page_change(&mut self, ctx: &mut EventCtx) { fn on_page_change(&mut self, ctx: &mut EventCtx) {
@ -138,6 +147,9 @@ where
self.on_page_change(ctx); self.on_page_change(ctx);
return None; return None;
} }
SwipeDirection::Left if self.swipe_left => {
return Some(PageMsg::Aux(AuxPageMsg::SwipeLeft));
}
_ => { _ => {
// Ignore other directions. // Ignore other directions.
} }
@ -152,7 +164,7 @@ where
} }
} else { } else {
if let Some(ButtonMsg::Clicked) = self.button_back.event(ctx, event) { if let Some(ButtonMsg::Clicked) = self.button_back.event(ctx, event) {
return Some(PageMsg::GoBack); return Some(PageMsg::Aux(AuxPageMsg::GoBack));
} }
self.hint.event(ctx, event); self.hint.event(ctx, event);
} }

View File

@ -18,7 +18,7 @@ use crate::{
component::{ component::{
base::ComponentExt, base::ComponentExt,
image::BlendedImage, image::BlendedImage,
paginated::{PageMsg, Paginate}, paginated::{AuxPageMsg, PageMsg, Paginate},
painter, painter,
placed::GridPlaced, placed::GridPlaced,
text::{ text::{
@ -45,9 +45,10 @@ use crate::{
use super::{ use super::{
component::{ component::{
Bip39Input, Button, ButtonMsg, ButtonStyleSheet, CancelConfirmMsg, CancelInfoConfirmMsg, AddressDetails, Bip39Input, Button, ButtonMsg, ButtonStyleSheet, CancelConfirmMsg,
Dialog, DialogMsg, FidoConfirm, FidoMsg, Frame, HoldToConfirm, HoldToConfirmMsg, CancelInfoConfirmMsg, Dialog, DialogMsg, FidoConfirm, FidoMsg, FloatingButton,
Homescreen, HomescreenMsg, IconDialog, Lockscreen, MnemonicInput, MnemonicKeyboard, FloatingButtonMsg, Frame, HoldToConfirm, HoldToConfirmMsg, Homescreen, HomescreenMsg,
HorizontalPage, IconDialog, Lockscreen, MnemonicInput, MnemonicKeyboard,
MnemonicKeyboardMsg, NotificationFrame, NumberInputDialog, NumberInputDialogMsg, MnemonicKeyboardMsg, NotificationFrame, NumberInputDialog, NumberInputDialogMsg,
PassphraseKeyboard, PassphraseKeyboardMsg, PinKeyboard, PinKeyboardMsg, Progress, PassphraseKeyboard, PassphraseKeyboardMsg, PinKeyboard, PinKeyboardMsg, Progress,
SelectWordCount, SelectWordCountMsg, SelectWordMsg, Slip39Input, SwipeHoldPage, SwipePage, SelectWordCount, SelectWordCountMsg, SelectWordMsg, Slip39Input, SwipeHoldPage, SwipePage,
@ -223,7 +224,8 @@ where
match msg { match msg {
PageMsg::Content(_) => Err(Error::TypeError), PageMsg::Content(_) => Err(Error::TypeError),
PageMsg::Controls(msg) => msg.try_into(), PageMsg::Controls(msg) => msg.try_into(),
PageMsg::GoBack => Ok(CANCELLED.as_obj()), PageMsg::Aux(AuxPageMsg::GoBack) => Ok(CANCELLED.as_obj()),
PageMsg::Aux(AuxPageMsg::SwipeLeft) => Ok(INFO.as_obj()),
} }
} }
} }
@ -236,7 +238,7 @@ where
match msg { match msg {
PageMsg::Content(_) => Err(Error::TypeError), PageMsg::Content(_) => Err(Error::TypeError),
PageMsg::Controls(msg) => msg.try_into(), PageMsg::Controls(msg) => msg.try_into(),
PageMsg::GoBack => unreachable!(), PageMsg::Aux(_) => Err(Error::TypeError),
} }
} }
} }
@ -338,6 +340,41 @@ impl ComponentMsgObj for Qr {
} }
} }
impl<T> ComponentMsgObj for FloatingButton<T>
where
T: ComponentMsgObj,
{
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
match msg {
FloatingButtonMsg::ButtonClicked => Ok(INFO.as_obj()),
FloatingButtonMsg::Content(c) => self.inner().msg_try_into_obj(c),
}
}
}
impl<T> ComponentMsgObj for HorizontalPage<T>
where
T: ComponentMsgObj + Paginate,
{
fn msg_try_into_obj(&self, msg: Self::Msg) -> Result<Obj, Error> {
match msg {
PageMsg::Content(inner_msg) => Ok(self.inner().msg_try_into_obj(inner_msg)?),
PageMsg::Controls(_) => Err(Error::TypeError),
PageMsg::Aux(AuxPageMsg::GoBack) => Ok(CANCELLED.as_obj()),
PageMsg::Aux(_) => Err(Error::TypeError),
}
}
}
impl<T> ComponentMsgObj for AddressDetails<T>
where
T: ParagraphStrType,
{
fn msg_try_into_obj(&self, _msg: Self::Msg) -> Result<Obj, Error> {
unreachable!();
}
}
extern "C" fn new_confirm_action(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { extern "C" fn new_confirm_action(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| { let block = move |_args: &[Obj], kwargs: &Map| {
let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?;
@ -434,23 +471,54 @@ extern "C" fn new_confirm_blob(n_args: usize, args: *const Obj, kwargs: *mut Map
let description: Option<StrBuffer> = let description: Option<StrBuffer> =
kwargs.get(Qstr::MP_QSTR_description)?.try_into_option()?; kwargs.get(Qstr::MP_QSTR_description)?.try_into_option()?;
let extra: Option<StrBuffer> = kwargs.get(Qstr::MP_QSTR_extra)?.try_into_option()?; let extra: Option<StrBuffer> = kwargs.get(Qstr::MP_QSTR_extra)?.try_into_option()?;
let verb: Option<StrBuffer> = kwargs
.get(Qstr::MP_QSTR_verb)
.unwrap_or_else(|_| Obj::const_none())
.try_into_option()?;
let verb_cancel: Option<StrBuffer> = kwargs let verb_cancel: Option<StrBuffer> = kwargs
.get(Qstr::MP_QSTR_verb_cancel) .get(Qstr::MP_QSTR_verb_cancel)
.unwrap_or_else(|_| Obj::const_none()) .unwrap_or_else(|_| Obj::const_none())
.try_into_option()?; .try_into_option()?;
let hold: bool = kwargs.get_or(Qstr::MP_QSTR_hold, false)?; let hold: bool = kwargs.get_or(Qstr::MP_QSTR_hold, false)?;
let verb: StrBuffer = "CONFIRM".into(); confirm_blob(title, data, description, extra, verb, verb_cancel, hold)
};
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
}
confirm_blob( extern "C" fn new_confirm_address(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
title, let block = move |_args: &[Obj], kwargs: &Map| {
data, let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?;
description, let description: Option<StrBuffer> =
extra, kwargs.get(Qstr::MP_QSTR_description)?.try_into_option()?;
Some(verb), let extra: Option<StrBuffer> = kwargs.get(Qstr::MP_QSTR_extra)?.try_into_option()?;
verb_cancel, let data: Obj = kwargs.get(Qstr::MP_QSTR_data)?;
hold,
) let paragraphs = ConfirmBlob {
description: description.unwrap_or_else(StrBuffer::empty),
extra: extra.unwrap_or_else(StrBuffer::empty),
data: data.try_into()?,
description_font: &theme::TEXT_NORMAL,
extra_font: &theme::TEXT_BOLD,
data_font: &theme::TEXT_MONO,
}
.into_paragraphs();
let buttons = Button::cancel_confirm(
Button::<&'static str>::with_icon(Icon::new(theme::ICON_CANCEL)),
Button::<&'static str>::with_icon(Icon::new(theme::ICON_CONFIRM))
.styled(theme::button_confirm()),
1,
);
let obj = LayoutObj::new(FloatingButton::top_right_corner(
Icon::new(theme::ICON_INFO_CIRCLE),
Frame::left_aligned(
theme::label_title(),
title,
SwipePage::new(paragraphs, buttons, theme::BG).with_swipe_left(),
),
))?;
Ok(obj.into())
}; };
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
} }
@ -583,6 +651,36 @@ extern "C" fn new_show_qr(n_args: usize, args: *const Obj, kwargs: *mut Map) ->
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
} }
extern "C" fn new_show_address_details(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| {
let address: StrBuffer = kwargs.get(Qstr::MP_QSTR_address)?.try_into()?;
let case_sensitive: bool = kwargs.get(Qstr::MP_QSTR_case_sensitive)?.try_into()?;
let account: Option<StrBuffer> = kwargs.get(Qstr::MP_QSTR_account)?.try_into_option()?;
let path: Option<StrBuffer> = kwargs.get(Qstr::MP_QSTR_path)?.try_into_option()?;
let xpubs: Obj = kwargs.get(Qstr::MP_QSTR_xpubs)?;
let mut iter_buf = IterBuf::new();
let iter = Iter::try_from_obj_with_buf(xpubs, &mut iter_buf)?;
let mut ad = AddressDetails::new(address, case_sensitive, account, path)?;
for i in iter {
let [xtitle, text]: [StrBuffer; 2] = iter_into_array(i)?;
ad.add_xpub(xtitle, text)?;
}
let obj = LayoutObj::new(
HorizontalPage::new(
FloatingButton::top_right_corner(Icon::new(theme::ICON_CANCEL_LARGER), ad),
theme::BG,
)
.with_swipe_right_to_go_back(),
)?;
Ok(obj.into())
};
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
}
extern "C" fn new_confirm_value(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { extern "C" fn new_confirm_value(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| { let block = move |_args: &[Obj], kwargs: &Map| {
let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?;
@ -847,6 +945,33 @@ extern "C" fn new_show_info(n_args: usize, args: *const Obj, kwargs: *mut Map) -
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
} }
extern "C" fn new_show_mismatch() -> Obj {
let block = move || {
let title: StrBuffer = "Address mismatch?".into();
let description: StrBuffer = "Please contact Trezor support at".into();
let url: StrBuffer = "trezor.io/support".into();
let button = "QUIT";
let icon = BlendedImage::single(Icon::new(theme::ICON_OCTA), theme::WARN_COLOR, theme::BG);
let obj = LayoutObj::new(
IconDialog::new(
icon,
title,
Button::cancel_confirm_square(
Button::with_icon(Icon::new(theme::ICON_BACK)),
Button::with_text(button).styled(theme::button_reset()),
),
)
.with_description(description)
.with_text(&theme::TEXT_DEMIBOLD, url),
)?;
Ok(obj.into())
};
unsafe { util::try_or_raise(block) }
}
extern "C" fn new_show_simple(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { extern "C" fn new_show_simple(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
let block = move |_args: &[Obj], kwargs: &Map| { let block = move |_args: &[Obj], kwargs: &Map| {
let title: Option<StrBuffer> = kwargs.get(Qstr::MP_QSTR_title)?.try_into_option()?; let title: Option<StrBuffer> = kwargs.get(Qstr::MP_QSTR_title)?.try_into_option()?;
@ -1422,12 +1547,24 @@ pub static mp_module_trezorui2: Module = obj_module! {
/// data: str | bytes, /// data: str | bytes,
/// description: str | None, /// description: str | None,
/// extra: str | None, /// extra: str | None,
/// verb: str | None = None,
/// verb_cancel: str | None = None, /// verb_cancel: str | None = None,
/// hold: bool = False, /// hold: bool = False,
/// ) -> object: /// ) -> object:
/// """Confirm byte sequence data.""" /// """Confirm byte sequence data."""
Qstr::MP_QSTR_confirm_blob => obj_fn_kw!(0, new_confirm_blob).as_obj(), Qstr::MP_QSTR_confirm_blob => obj_fn_kw!(0, new_confirm_blob).as_obj(),
/// def confirm_address(
/// *,
/// title: str,
/// data: str | bytes,
/// description: str | None,
/// extra: str | None,
/// ) -> object:
/// """Confirm address. Similar to `confirm_blob` but has corner info button
/// and allows left swipe which does the same thing as the button."""
Qstr::MP_QSTR_confirm_address => obj_fn_kw!(0, new_confirm_address).as_obj(),
/// def confirm_properties( /// def confirm_properties(
/// *, /// *,
/// title: str, /// title: str,
@ -1456,6 +1593,17 @@ pub static mp_module_trezorui2: Module = obj_module! {
/// """Show QR code.""" /// """Show QR code."""
Qstr::MP_QSTR_show_qr => obj_fn_kw!(0, new_show_qr).as_obj(), Qstr::MP_QSTR_show_qr => obj_fn_kw!(0, new_show_qr).as_obj(),
/// def show_address_details(
/// *,
/// address: str,
/// case_sensitive: bool,
/// account: str | None,
/// path: str | None,
/// xpubs: list[tuple[str, str]],
/// ) -> object:
/// """Show address details - QR code, account, path, cosigner xpubs."""
Qstr::MP_QSTR_show_address_details => obj_fn_kw!(0, new_show_address_details).as_obj(),
/// def confirm_value( /// def confirm_value(
/// *, /// *,
/// title: str, /// title: str,
@ -1551,6 +1699,10 @@ pub static mp_module_trezorui2: Module = obj_module! {
/// """Info modal. No buttons shown when `button` is empty string.""" /// """Info modal. No buttons shown when `button` is empty string."""
Qstr::MP_QSTR_show_info => obj_fn_kw!(0, new_show_info).as_obj(), Qstr::MP_QSTR_show_info => obj_fn_kw!(0, new_show_info).as_obj(),
/// def show_mismatch() -> object:
/// """Warning modal, receiving address mismatch."""
Qstr::MP_QSTR_show_mismatch => obj_fn_0!(new_show_mismatch).as_obj(),
/// def show_simple( /// def show_simple(
/// *, /// *,
/// title: str | None, /// title: str | None,

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -55,6 +55,7 @@ pub const ICON_SIZE: i16 = 16;
// UI icons (greyscale). // UI icons (greyscale).
pub const ICON_CANCEL: &[u8] = include_res!("model_tt/res/cancel.toif"); pub const ICON_CANCEL: &[u8] = include_res!("model_tt/res/cancel.toif");
pub const ICON_CANCEL_LARGER: &[u8] = include_res!("model_tt/res/cancel-larger.toif");
pub const ICON_CONFIRM: &[u8] = include_res!("model_tt/res/confirm.toif"); pub const ICON_CONFIRM: &[u8] = include_res!("model_tt/res/confirm.toif");
pub const ICON_SPACE: &[u8] = include_res!("model_tt/res/space.toif"); pub const ICON_SPACE: &[u8] = include_res!("model_tt/res/space.toif");
pub const ICON_BACK: &[u8] = include_res!("model_tt/res/back.toif"); pub const ICON_BACK: &[u8] = include_res!("model_tt/res/back.toif");
@ -70,6 +71,8 @@ pub const ICON_SUCCESS_SMALL: &[u8] = include_res!("model_tt/res/success_bld.toi
pub const ICON_WARN_SMALL: &[u8] = include_res!("model_tt/res/warn_bld.toif"); pub const ICON_WARN_SMALL: &[u8] = include_res!("model_tt/res/warn_bld.toif");
pub const ICON_PAGE_NEXT: &[u8] = include_res!("model_tt/res/page-next.toif"); pub const ICON_PAGE_NEXT: &[u8] = include_res!("model_tt/res/page-next.toif");
pub const ICON_PAGE_PREV: &[u8] = include_res!("model_tt/res/page-prev.toif"); pub const ICON_PAGE_PREV: &[u8] = include_res!("model_tt/res/page-prev.toif");
pub const ICON_OCTA: &[u8] = include_res!("model_tt/res/octa-bang.toif");
pub const ICON_INFO_CIRCLE: &[u8] = include_res!("model_tt/res/info-circle.toif");
// Large, three-color icons. // Large, three-color icons.
pub const WARN_COLOR: Color = YELLOW; pub const WARN_COLOR: Color = YELLOW;
@ -139,7 +142,7 @@ pub const fn label_title() -> TextStyle {
TextStyle::new(Font::BOLD, GREY_LIGHT, BG, GREY_LIGHT, GREY_LIGHT) TextStyle::new(Font::BOLD, GREY_LIGHT, BG, GREY_LIGHT, GREY_LIGHT)
} }
pub fn button_default() -> ButtonStyleSheet { pub const fn button_default() -> ButtonStyleSheet {
ButtonStyleSheet { ButtonStyleSheet {
normal: &ButtonStyle { normal: &ButtonStyle {
font: Font::BOLD, font: Font::BOLD,
@ -171,7 +174,7 @@ pub fn button_default() -> ButtonStyleSheet {
} }
} }
pub fn button_confirm() -> ButtonStyleSheet { pub const fn button_confirm() -> ButtonStyleSheet {
ButtonStyleSheet { ButtonStyleSheet {
normal: &ButtonStyle { normal: &ButtonStyle {
font: Font::BOLD, font: Font::BOLD,
@ -203,7 +206,7 @@ pub fn button_confirm() -> ButtonStyleSheet {
} }
} }
pub fn button_cancel() -> ButtonStyleSheet { pub const fn button_cancel() -> ButtonStyleSheet {
ButtonStyleSheet { ButtonStyleSheet {
normal: &ButtonStyle { normal: &ButtonStyle {
font: Font::BOLD, font: Font::BOLD,
@ -235,11 +238,11 @@ pub fn button_cancel() -> ButtonStyleSheet {
} }
} }
pub fn button_danger() -> ButtonStyleSheet { pub const fn button_danger() -> ButtonStyleSheet {
button_cancel() button_cancel()
} }
pub fn button_reset() -> ButtonStyleSheet { pub const fn button_reset() -> ButtonStyleSheet {
ButtonStyleSheet { ButtonStyleSheet {
normal: &ButtonStyle { normal: &ButtonStyle {
font: Font::BOLD, font: Font::BOLD,
@ -271,7 +274,7 @@ pub fn button_reset() -> ButtonStyleSheet {
} }
} }
pub fn button_info() -> ButtonStyleSheet { pub const fn button_info() -> ButtonStyleSheet {
ButtonStyleSheet { ButtonStyleSheet {
normal: &ButtonStyle { normal: &ButtonStyle {
font: Font::BOLD, font: Font::BOLD,
@ -303,7 +306,7 @@ pub fn button_info() -> ButtonStyleSheet {
} }
} }
pub fn button_pin() -> ButtonStyleSheet { pub const fn button_pin() -> ButtonStyleSheet {
ButtonStyleSheet { ButtonStyleSheet {
normal: &ButtonStyle { normal: &ButtonStyle {
font: Font::MONO, font: Font::MONO,
@ -335,7 +338,7 @@ pub fn button_pin() -> ButtonStyleSheet {
} }
} }
pub fn button_counter() -> ButtonStyleSheet { pub const fn button_counter() -> ButtonStyleSheet {
ButtonStyleSheet { ButtonStyleSheet {
normal: &ButtonStyle { normal: &ButtonStyle {
font: Font::DEMIBOLD, font: Font::DEMIBOLD,
@ -367,11 +370,11 @@ pub fn button_counter() -> ButtonStyleSheet {
} }
} }
pub fn button_clear() -> ButtonStyleSheet { pub const fn button_clear() -> ButtonStyleSheet {
button_default() button_default()
} }
pub fn loader_default() -> LoaderStyleSheet { pub const fn loader_default() -> LoaderStyleSheet {
LoaderStyleSheet { LoaderStyleSheet {
normal: &LoaderStyle { normal: &LoaderStyle {
icon: None, icon: None,
@ -418,6 +421,7 @@ pub const TEXT_CHECKLIST_SELECTED: TextStyle =
TextStyle::new(Font::NORMAL, FG, BG, GREY_LIGHT, GREY_LIGHT); TextStyle::new(Font::NORMAL, FG, BG, GREY_LIGHT, GREY_LIGHT);
pub const TEXT_CHECKLIST_DONE: TextStyle = pub const TEXT_CHECKLIST_DONE: TextStyle =
TextStyle::new(Font::NORMAL, GREEN_DARK, BG, GREY_LIGHT, GREY_LIGHT); TextStyle::new(Font::NORMAL, GREEN_DARK, BG, GREY_LIGHT, GREY_LIGHT);
pub const TEXT_XPUB: TextStyle = TEXT_NORMAL.with_line_breaking(LineBreaking::BreakWordsNoHyphen);
pub const FORMATTED: FormattedFonts = FormattedFonts { pub const FORMATTED: FormattedFonts = FormattedFonts {
normal: Font::NORMAL, normal: Font::NORMAL,
@ -432,6 +436,8 @@ pub const BUTTON_HEIGHT: i16 = 38;
pub const BUTTON_SPACING: i16 = 6; pub const BUTTON_SPACING: i16 = 6;
pub const CHECKLIST_SPACING: i16 = 10; pub const CHECKLIST_SPACING: i16 = 10;
pub const RECOVERY_SPACING: i16 = 18; pub const RECOVERY_SPACING: i16 = 18;
pub const CORNER_BUTTON_SIDE: i16 = 32;
pub const CORNER_BUTTON_SPACING: i16 = 8;
/// Standard button height in pixels. /// Standard button height in pixels.
pub const fn button_rows(count: usize) -> i16 { pub const fn button_rows(count: usize) -> i16 {
@ -462,6 +468,10 @@ pub const fn borders_scroll() -> Insets {
Insets::new(13, 5, 14, 10) Insets::new(13, 5, 14, 10)
} }
pub const fn borders_horizontal_scroll() -> Insets {
Insets::new(13, 10, 0, 10)
}
pub const fn borders_notification() -> Insets { pub const fn borders_notification() -> Insets {
Insets::new(48, 10, 14, 10) Insets::new(48, 10, 14, 10)
} }

View File

@ -82,12 +82,25 @@ def confirm_blob(
data: str | bytes, data: str | bytes,
description: str | None, description: str | None,
extra: str | None, extra: str | None,
verb: str | None = None,
verb_cancel: str | None = None, verb_cancel: str | None = None,
hold: bool = False, hold: bool = False,
) -> object: ) -> object:
"""Confirm byte sequence data.""" """Confirm byte sequence data."""
# rust/src/ui/model_tt/layout.rs
def confirm_address(
*,
title: str,
data: str | bytes,
description: str | None,
extra: str | None,
) -> object:
"""Confirm address. Similar to `confirm_blob` but has corner info button
and allows left swipe which does the same thing as the button."""
# rust/src/ui/model_tt/layout.rs # rust/src/ui/model_tt/layout.rs
def confirm_properties( def confirm_properties(
*, *,
@ -119,6 +132,18 @@ def show_qr(
"""Show QR code.""" """Show QR code."""
# rust/src/ui/model_tt/layout.rs
def show_address_details(
*,
address: str,
case_sensitive: bool,
account: str | None,
path: str | None,
xpubs: list[tuple[str, str]],
) -> object:
"""Show address details - QR code, account, path, cosigner xpubs."""
# rust/src/ui/model_tt/layout.rs # rust/src/ui/model_tt/layout.rs
def confirm_value( def confirm_value(
*, *,
@ -222,6 +247,11 @@ def show_info(
"""Info modal. No buttons shown when `button` is empty string.""" """Info modal. No buttons shown when `button` is empty string."""
# rust/src/ui/model_tt/layout.rs
def show_mismatch() -> object:
"""Warning modal, receiving address mismatch."""
# rust/src/ui/model_tt/layout.rs # rust/src/ui/model_tt/layout.rs
def show_simple( def show_simple(
*, *,

View File

@ -28,7 +28,6 @@ async def get_address(
pubkey = node.public_key() pubkey = node.public_key()
address = address_from_public_key(pubkey, HRP) address = address_from_public_key(pubkey, HRP)
if msg.show_display: if msg.show_display:
title = paths.address_n_to_str(address_n) await show_address(ctx, address, path=paths.address_n_to_str(address_n))
await show_address(ctx, address, title=title)
return BinanceAddress(address=address) return BinanceAddress(address=address)

View File

@ -41,7 +41,7 @@ async def get_address(
from apps.common.paths import address_n_to_str, validate_path from apps.common.paths import address_n_to_str, validate_path
from . import addresses from . import addresses
from .keychain import validate_path_against_script_type from .keychain import address_n_to_name, validate_path_against_script_type
from .multisig import multisig_pubkey_index from .multisig import multisig_pubkey_index
multisig = msg.multisig # local_cache_attribute multisig = msg.multisig # local_cache_attribute
@ -95,6 +95,7 @@ async def get_address(
mac = get_address_mac(address, coin.slip44, keychain) mac = get_address_mac(address, coin.slip44, keychain)
if msg.show_display: if msg.show_display:
path = address_n_to_str(address_n)
if multisig: if multisig:
if multisig.nodes: if multisig.nodes:
pubnodes = multisig.nodes pubnodes = multisig.nodes
@ -102,23 +103,30 @@ async def get_address(
pubnodes = [hd.node for hd in multisig.pubkeys] pubnodes = [hd.node for hd in multisig.pubkeys]
multisig_index = multisig_pubkey_index(multisig, node.public_key()) multisig_index = multisig_pubkey_index(multisig, node.public_key())
title = f"Multisig {multisig.m} of {len(pubnodes)}"
await show_address( await show_address(
ctx, ctx,
address_short, address_short,
case_sensitive=address_case_sensitive, case_sensitive=address_case_sensitive,
title=title, path=path,
multisig_index=multisig_index, multisig_index=multisig_index,
xpubs=_get_xpubs(coin, multisig_xpub_magic, pubnodes), xpubs=_get_xpubs(coin, multisig_xpub_magic, pubnodes),
account=f"Multisig {multisig.m} of {len(pubnodes)}",
) )
else: else:
title = address_n_to_str(address_n) account_name = address_n_to_name(coin, address_n, script_type)
if account_name is None:
account = "Unknown path"
elif account_name == "":
account = coin.coin_shortcut
else:
account = f"{coin.coin_shortcut} {account_name}"
await show_address( await show_address(
ctx, ctx,
address_short, address_short,
address_qr=address, address_qr=address,
case_sensitive=address_case_sensitive, case_sensitive=address_case_sensitive,
title=title, path=path,
account=account,
) )
return Address(address=address, mac=mac) return Address(address=address, mac=mac)

View File

@ -384,7 +384,7 @@ def address_n_to_name(
) -> str | None: ) -> str | None:
ACCOUNT_TYPES = ( ACCOUNT_TYPES = (
AccountType( AccountType(
"Legacy account", "Legacy",
PATTERN_BIP44, PATTERN_BIP44,
InputScriptType.SPENDADDRESS, InputScriptType.SPENDADDRESS,
require_segwit=True, require_segwit=True,
@ -392,7 +392,7 @@ def address_n_to_name(
require_taproot=False, require_taproot=False,
), ),
AccountType( AccountType(
"Account", "",
PATTERN_BIP44, PATTERN_BIP44,
InputScriptType.SPENDADDRESS, InputScriptType.SPENDADDRESS,
require_segwit=False, require_segwit=False,
@ -400,7 +400,7 @@ def address_n_to_name(
require_taproot=False, require_taproot=False,
), ),
AccountType( AccountType(
"Legacy SegWit account", "L. SegWit",
PATTERN_BIP49, PATTERN_BIP49,
InputScriptType.SPENDP2SHWITNESS, InputScriptType.SPENDP2SHWITNESS,
require_segwit=True, require_segwit=True,
@ -408,7 +408,7 @@ def address_n_to_name(
require_taproot=False, require_taproot=False,
), ),
AccountType( AccountType(
"SegWit account", "SegWit",
PATTERN_BIP84, PATTERN_BIP84,
InputScriptType.SPENDWITNESS, InputScriptType.SPENDWITNESS,
require_segwit=True, require_segwit=True,
@ -416,7 +416,7 @@ def address_n_to_name(
require_taproot=False, require_taproot=False,
), ),
AccountType( AccountType(
"Taproot account", "Taproot",
PATTERN_BIP86, PATTERN_BIP86,
InputScriptType.SPENDTAPROOT, InputScriptType.SPENDTAPROOT,
require_segwit=False, require_segwit=False,
@ -424,7 +424,7 @@ def address_n_to_name(
require_taproot=True, require_taproot=True,
), ),
AccountType( AccountType(
"Coinjoin account", "Coinjoin",
PATTERN_SLIP25_TAPROOT, PATTERN_SLIP25_TAPROOT,
InputScriptType.SPENDTAPROOT, InputScriptType.SPENDTAPROOT,
require_segwit=False, require_segwit=False,

View File

@ -975,9 +975,8 @@ async def show_cardano_address(
if not protocol_magics.is_mainnet(protocol_magic): if not protocol_magics.is_mainnet(protocol_magic):
network_name = protocol_magics.to_ui_string(protocol_magic) network_name = protocol_magics.to_ui_string(protocol_magic)
title = f"{ADDRESS_TYPE_NAMES[address_parameters.address_type]} address" path = None
address_extra = None account = ADDRESS_TYPE_NAMES[address_parameters.address_type]
title_qr = title
if address_parameters.address_type in ( if address_parameters.address_type in (
CAT.BYRON, CAT.BYRON,
CAT.BASE, CAT.BASE,
@ -987,17 +986,14 @@ async def show_cardano_address(
CAT.REWARD, CAT.REWARD,
): ):
if address_parameters.address_n: if address_parameters.address_n:
address_extra = address_n_to_str(address_parameters.address_n) path = address_n_to_str(address_parameters.address_n)
title_qr = address_n_to_str(address_parameters.address_n)
elif address_parameters.address_n_staking: elif address_parameters.address_n_staking:
address_extra = address_n_to_str(address_parameters.address_n_staking) path = address_n_to_str(address_parameters.address_n_staking)
title_qr = address_n_to_str(address_parameters.address_n_staking)
await layouts.show_address( await layouts.show_address(
ctx, ctx,
address, address,
title=title, path=path,
account=account,
network=network_name, network=network_name,
address_extra=address_extra,
title_qr=title_qr,
) )

View File

@ -32,7 +32,6 @@ async def get_address(
address = address_from_bytes(node.ethereum_pubkeyhash(), network) address = address_from_bytes(node.ethereum_pubkeyhash(), network)
if msg.show_display: if msg.show_display:
title = paths.address_n_to_str(address_n) await show_address(ctx, address, path=paths.address_n_to_str(address_n))
await show_address(ctx, address, title=title)
return EthereumAddress(address=address) return EthereumAddress(address=address)

View File

@ -68,12 +68,11 @@ async def get_address(
) )
if msg.show_display: if msg.show_display:
title = paths.address_n_to_str(msg.address_n)
await show_address( await show_address(
ctx, ctx,
addr, addr,
address_qr="monero:" + addr, address_qr="monero:" + addr,
title=title, path=paths.address_n_to_str(msg.address_n),
) )
return MoneroAddress(address=addr.encode()) return MoneroAddress(address=addr.encode())

View File

@ -30,12 +30,11 @@ async def get_address(
address = node.nem_address(network) address = node.nem_address(network)
if msg.show_display: if msg.show_display:
title = address_n_to_str(address_n)
await show_address( await show_address(
ctx, ctx,
address, address,
case_sensitive=False, case_sensitive=False,
title=title, path=address_n_to_str(address_n),
network=get_network_str(network), network=get_network_str(network),
) )

View File

@ -25,7 +25,6 @@ async def get_address(
address = address_from_public_key(pubkey) address = address_from_public_key(pubkey)
if msg.show_display: if msg.show_display:
title = paths.address_n_to_str(msg.address_n) await show_address(ctx, address, path=paths.address_n_to_str(msg.address_n))
await show_address(ctx, address, title=title)
return RippleAddress(address=address) return RippleAddress(address=address)

View File

@ -24,7 +24,7 @@ async def get_address(
address = helpers.address_from_public_key(pubkey) address = helpers.address_from_public_key(pubkey)
if msg.show_display: if msg.show_display:
title = paths.address_n_to_str(msg.address_n) path = paths.address_n_to_str(msg.address_n)
await show_address(ctx, address, case_sensitive=False, title=title) await show_address(ctx, address, case_sensitive=False, path=path)
return StellarAddress(address=address) return StellarAddress(address=address)

View File

@ -29,7 +29,6 @@ async def get_address(
address = helpers.base58_encode_check(pkh, helpers.TEZOS_ED25519_ADDRESS_PREFIX) address = helpers.base58_encode_check(pkh, helpers.TEZOS_ED25519_ADDRESS_PREFIX)
if msg.show_display: if msg.show_display:
title = paths.address_n_to_str(msg.address_n) await show_address(ctx, address, path=paths.address_n_to_str(msg.address_n))
await show_address(ctx, address, title=title)
return TezosAddress(address=address) return TezosAddress(address=address)

View File

@ -164,8 +164,6 @@ async def show_address(
network: str | None = None, network: str | None = None,
multisig_index: int | None = None, multisig_index: int | None = None,
xpubs: Sequence[str] = (), xpubs: Sequence[str] = (),
address_extra: str | None = None,
title_qr: str | None = None,
) -> None: ) -> None:
result = await interact( result = await interact(
ctx, ctx,

View File

@ -353,24 +353,20 @@ async def confirm_homescreen(
) )
def _show_xpub(xpub: str, title: str, cancel: str | None) -> ui.Layout:
content = RustLayout(
trezorui2.confirm_blob(
title=title,
data=xpub,
verb_cancel=cancel,
extra=None,
description=None,
)
)
return content
async def show_xpub(ctx: GenericContext, xpub: str, title: str) -> None: async def show_xpub(ctx: GenericContext, xpub: str, title: str) -> None:
await raise_if_not_confirmed( await raise_if_not_confirmed(
interact( interact(
ctx, ctx,
_show_xpub(xpub, title, None), RustLayout(
trezorui2.confirm_blob(
title=title,
data=xpub,
verb="CONFIRM",
verb_cancel=None,
extra=None,
description=None,
)
),
"show_xpub", "show_xpub",
ButtonRequestType.PublicKey, ButtonRequestType.PublicKey,
) )
@ -383,61 +379,72 @@ async def show_address(
*, *,
address_qr: str | None = None, address_qr: str | None = None,
case_sensitive: bool = True, case_sensitive: bool = True,
title: str = "Confirm address", path: str | None = None,
account: str | None = None,
network: str | None = None, network: str | None = None,
multisig_index: int | None = None, multisig_index: int | None = None,
xpubs: Sequence[str] = (), xpubs: Sequence[str] = (),
address_extra: str | None = None,
title_qr: str | None = None,
) -> None: ) -> None:
is_multisig = len(xpubs) > 0
while True: while True:
title = (
"RECEIVE ADDRESS\n(MULTISIG)"
if multisig_index is not None
else "RECEIVE ADDRESS"
)
result = await interact( result = await interact(
ctx, ctx,
RustLayout( RustLayout(
trezorui2.confirm_blob( trezorui2.confirm_address(
title=title.upper(), title=title,
data=address, data=address,
description=network or "", description=network or "",
extra=address_extra or "", extra=None,
verb_cancel="QR",
) )
), ),
"show_address", "show_address",
ButtonRequestType.Address, ButtonRequestType.Address,
) )
# User pressed right button.
if result is CONFIRMED: if result is CONFIRMED:
break break
result = await interact( # User pressed corner button or swiped left, go to address details.
ctx, elif result is INFO:
RustLayout(
trezorui2.show_qr(
address=address if address_qr is None else address_qr,
case_sensitive=case_sensitive,
title=title.upper() if title_qr is None else title_qr.upper(),
verb_cancel="XPUBs" if is_multisig else "ADDRESS",
)
),
"show_qr",
ButtonRequestType.Address,
)
if result is CONFIRMED:
break
if is_multisig: def xpub_title(i: int):
for i, xpub in enumerate(xpubs): result = f"MULTISIG XPUB #{i + 1}\n"
cancel = "NEXT" if i < len(xpubs) - 1 else "ADDRESS" result += " (YOURS)" if i == multisig_index else " (COSIGNER)"
title_xpub = f"XPUB #{i + 1}" return result
title_xpub += " (yours)" if i == multisig_index else " (cosigner)"
result = await interact( result = await interact(
ctx, ctx,
_show_xpub(xpub, title=title_xpub, cancel=cancel), RustLayout(
"show_xpub", trezorui2.show_address_details(
ButtonRequestType.PublicKey, address=address if address_qr is None else address_qr,
) case_sensitive=case_sensitive,
if result is CONFIRMED: account=account,
return path=path,
xpubs=[(xpub_title(i), xpub) for i, xpub in enumerate(xpubs)],
)
),
"show_address_details",
ButtonRequestType.Address,
)
# Can only go back from the address details but corner button returns INFO.
assert result in (INFO, CANCELLED)
else:
result = await interact(
ctx,
RustLayout(trezorui2.show_mismatch()),
"warning_address_mismatch",
ButtonRequestType.Warning,
)
assert result in (CONFIRMED, CANCELLED)
# Right button aborts action, left goes back to showing address.
if result is CONFIRMED:
raise ActionCancelled
def show_pubkey( def show_pubkey(
@ -693,6 +700,7 @@ async def confirm_blob(
data=data, data=data,
extra=None, extra=None,
hold=hold, hold=hold,
verb="CONFIRM",
) )
) )

View File

@ -129,9 +129,11 @@ class LayoutContent:
# First line should have content after the tag, last line does not store content # First line should have content after the tag, last line does not store content
tag = f"< {tag_name}" tag = f"< {tag_name}"
if tag in self.lines[0]: for i in range(len(self.lines)):
first_line = self.lines[0].split(tag)[1] if tag in self.lines[i]:
all_lines = [first_line] + self.lines[1:-1] first_line = self.lines[i].split(tag)[1]
all_lines = [first_line] + self.lines[i + 1 : -1]
break
else: else:
all_lines = self.lines[1:-1] all_lines = self.lines[1:-1]

View File

@ -18,7 +18,7 @@ import pytest
from trezorlib import btc, messages, tools from trezorlib import btc, messages, tools
from trezorlib.debuglink import TrezorClientDebugLink as Client from trezorlib.debuglink import TrezorClientDebugLink as Client
from trezorlib.exceptions import TrezorFailure from trezorlib.exceptions import Cancelled, TrezorFailure
VECTORS = ( # path, script_type, address VECTORS = ( # path, script_type, address
( (
@ -43,9 +43,12 @@ VECTORS = ( # path, script_type, address
), ),
) )
CORNER_BUTTON = (215, 25)
@pytest.mark.skip_t2
@pytest.mark.parametrize("path, script_type, address", VECTORS) @pytest.mark.parametrize("path, script_type, address", VECTORS)
def test_show( def test_show_t1(
client: Client, path: str, script_type: messages.InputScriptType, address: str client: Client, path: str, script_type: messages.InputScriptType, address: str
): ):
def input_flow(): def input_flow():
@ -68,6 +71,67 @@ def test_show(
) )
@pytest.mark.skip_t1
@pytest.mark.parametrize("path, script_type, address", VECTORS)
def test_show_tt(
client: Client, path: str, script_type: messages.InputScriptType, address: str
):
def input_flow():
yield
client.debug.click(CORNER_BUTTON, wait=True)
yield
client.debug.swipe_left(wait=True)
client.debug.swipe_right(wait=True)
client.debug.swipe_left(wait=True)
client.debug.click(CORNER_BUTTON, wait=True)
yield
client.debug.press_no()
yield
client.debug.press_no()
yield
client.debug.press_yes()
with client:
client.set_input_flow(input_flow)
assert (
btc.get_address(
client,
"Bitcoin",
tools.parse_path(path),
script_type=script_type,
show_display=True,
)
== address
)
@pytest.mark.skip_t1
@pytest.mark.parametrize("path, script_type, address", VECTORS)
def test_show_cancel(
client: Client, path: str, script_type: messages.InputScriptType, address: str
):
def input_flow():
yield
client.debug.click(CORNER_BUTTON, wait=True)
yield
client.debug.swipe_left(wait=True)
client.debug.click(CORNER_BUTTON, wait=True)
yield
client.debug.press_no()
yield
client.debug.press_yes()
with client, pytest.raises(Cancelled):
client.set_input_flow(input_flow)
btc.get_address(
client,
"Bitcoin",
tools.parse_path(path),
script_type=script_type,
show_display=True,
)
def test_show_unrecognized_path(client: Client): def test_show_unrecognized_path(client: Client):
with pytest.raises(TrezorFailure): with pytest.raises(TrezorFailure):
btc.get_address( btc.get_address(
@ -213,32 +277,36 @@ def test_show_multisig_xpubs(
def input_flow(): def input_flow():
yield # show address yield # show address
layout = client.debug.wait_layout() # TODO: do not need to *wait* now? layout = client.debug.wait_layout() # TODO: do not need to *wait* now?
assert layout.get_title() == "MULTISIG 2 OF 3" assert layout.get_title() == "RECEIVE ADDRESS (MULTISIG)"
assert layout.get_content().replace(" ", "") == address assert layout.get_content().replace(" ", "") == address
client.debug.press_no() client.debug.click(CORNER_BUTTON)
yield # show QR code yield # show QR code
assert "Painter" in client.debug.wait_layout().text assert "Qr" in client.debug.wait_layout().text
client.debug.swipe_left()
# address details
layout = client.debug.wait_layout()
assert "Multisig 2 of 3" in layout.text
# Three xpub pages with the same testing logic # Three xpub pages with the same testing logic
for xpub_num in range(3): for xpub_num in range(3):
expected_title = f"XPUB #{xpub_num + 1} " + ( expected_title = f"MULTISIG XPUB #{xpub_num + 1} " + (
"(yours)" if i == xpub_num else "(cosigner)" "(YOURS)" if i == xpub_num else "(COSIGNER)"
) )
client.debug.press_no() client.debug.swipe_left()
yield # show XPUB#{xpub_num} layout = client.debug.wait_layout()
layout1 = client.debug.wait_layout() assert layout.get_title() == expected_title
assert layout1.get_title() == expected_title content = layout.get_content().replace(" ", "")
client.debug.swipe_up() assert xpubs[xpub_num] in content
layout2 = client.debug.wait_layout()
assert layout2.get_title() == expected_title
content = (layout1.get_content() + layout2.get_content()).replace(
" ", ""
)
assert content == xpubs[xpub_num]
client.debug.click(CORNER_BUTTON)
yield # show address
client.debug.press_no()
yield # address mismatch
client.debug.press_no()
yield # show address
client.debug.press_yes() client.debug.press_yes()
with client: with client:

View File

@ -340,7 +340,7 @@ def test_signmessage_pagination(client: Client, message: str):
expected_message = ( expected_message = (
("Confirm message: " + message).replace("\n", "").replace(" ", "") ("Confirm message: " + message).replace("\n", "").replace(" ", "")
) )
message_read = message_read.replace(" ", "") message_read = message_read.replace(" ", "").replace("...", "")
assert expected_message == message_read assert expected_message == message_read

File diff suppressed because it is too large Load Diff