diff --git a/core/src/apps/common/layout.py b/core/src/apps/common/layout.py index 16d78e4d0..1fb13c42e 100644 --- a/core/src/apps/common/layout.py +++ b/core/src/apps/common/layout.py @@ -7,14 +7,14 @@ from trezor.ui.button import ButtonDefault from trezor.ui.container import Container from trezor.ui.qr import Qr from trezor.ui.scroll import Paginated -from trezor.ui.text import Text +from trezor.ui.text import TEXT_MAX_LINES, Span, Text from trezor.utils import chunks from apps.common import HARDENED from apps.common.confirm import confirm, require_confirm if False: - from typing import Iterable, Iterator, List + from typing import Iterable, Iterator, List, Union from trezor import wire @@ -136,3 +136,48 @@ async def show_success( await require_confirm( ctx, text, ButtonRequestType.Success, confirm=button, cancel=None ) + + +def paginate_text( + text: str, + header: str, + font: int = ui.NORMAL, + header_icon: str = ui.ICON_DEFAULT, + icon_color: int = ui.ORANGE_ICON, + break_words: bool = False, +) -> Union[Text, Paginated]: + span = Span(text, 0, font, break_words=break_words) + if span.count_lines() <= TEXT_MAX_LINES: + result = Text( + header, + header_icon=header_icon, + icon_color=icon_color, + new_lines=False, + ) + result.content = [font, text] + return result + + else: + pages: List[ui.Component] = [] + span.reset(text, 0, font, break_words=break_words, line_width=204) + while span.has_more_content(): + # advance to first line of the page + span.next_line() + page = Text( + header, + header_icon=header_icon, + icon_color=icon_color, + new_lines=False, + content_offset=0, + char_offset=span.start, + line_width=204, + render_page_overflow=False, + ) + page.content = [font, text] + pages.append(page) + + # roll over the remaining lines on the page + for _ in range(TEXT_MAX_LINES - 1): + span.next_line() + + return Paginated(pages) diff --git a/core/src/apps/common/signverify.py b/core/src/apps/common/signverify.py index c05f0e484..3fa5b0788 100644 --- a/core/src/apps/common/signverify.py +++ b/core/src/apps/common/signverify.py @@ -1,15 +1,14 @@ from ubinascii import hexlify -from trezor import utils, wire +from trezor import ui, utils, wire from trezor.crypto.hashlib import blake256, sha256 from trezor.ui.text import Text from apps.common.confirm import require_confirm -from apps.common.layout import split_address +from apps.common.layout import paginate_text, split_address from apps.common.writers import write_bitcoin_varint if False: - from typing import List from apps.common.coininfo import CoinInfo @@ -30,24 +29,18 @@ def message_digest(coin: CoinInfo, message: bytes) -> bytes: return ret -def split_message(message: bytes) -> List[str]: +def decode_message(message: bytes) -> str: try: - m = bytes(message).decode() - words = m.split(" ") + return bytes(message).decode() except UnicodeError: - m = "hex(%s)" % hexlify(message).decode() - words = [m] - return words + return "hex(%s)" % hexlify(message).decode() async def require_confirm_sign_message( ctx: wire.Context, coin: str, message: bytes ) -> None: header = "Sign {} message".format(coin) - message_lines = split_message(message) - text = Text(header, new_lines=False) - text.normal(*message_lines) - await require_confirm(ctx, text) + await require_confirm(ctx, paginate_text(decode_message(message), header)) async def require_confirm_verify_message( @@ -60,6 +53,7 @@ async def require_confirm_verify_message( text.mono(*split_address(address)) await require_confirm(ctx, text) - text = Text(header, new_lines=False) - text.mono(*split_message(message)) - await require_confirm(ctx, text) + await require_confirm( + ctx, + paginate_text(decode_message(message), header, font=ui.MONO), + ) diff --git a/tests/device_tests/test_msg_signmessage.py b/tests/device_tests/test_msg_signmessage.py index 220f27de9..a48857399 100644 --- a/tests/device_tests/test_msg_signmessage.py +++ b/tests/device_tests/test_msg_signmessage.py @@ -206,3 +206,79 @@ def test_signmessage(client, coin_name, path, script_type, address, message, sig ) assert sig.address == address assert sig.signature.hex() == signature + + +MESSAGE_LENGTHS = ( + pytest.param("This is a very long message. " * 16, id="normal_text"), + pytest.param("ThisIsAMessageWithoutSpaces" * 16, id="no_spaces"), + pytest.param("ThisIsAMessageWithLongWords " * 16, id="long_words"), + pytest.param("This\nmessage\nhas\nnewlines\nafter\nevery\nword", id="newlines"), + pytest.param("Příšerně žluťoučký kůň úpěl ďábelské ódy. " * 16, id="utf_text"), + pytest.param("PříšerněŽluťoučkýKůňÚpělĎábelskéÓdy" * 16, id="utf_nospace"), + pytest.param("1\n2\n3\n4\n5\n6", id="single_line_over"), +) + + +@pytest.mark.skip_t1 +@pytest.mark.parametrize("message", MESSAGE_LENGTHS) +def test_signmessage_pagination(client, message): + message_read = "" + message += "End." + + def input_flow(): + # collect screen contents into `message_read`. + # Join lines that are separated by a single "-" string, space-separate lines otherwise. + nonlocal message_read + + yield + # start assuming there was a word break; this avoids prepending space at start + word_break = True + max_attempts = 100 + while max_attempts: + layout = client.debug.wait_layout() + for line in layout.lines[1:]: + if line == "-": + # next line will be attached without space + word_break = True + elif word_break: + # attach without space, reset word_break + message_read += line + word_break = False + else: + # attach with space + message_read += " " + line + + if not message_read.endswith("End."): + client.debug.swipe_up() + else: + client.debug.press_yes() + break + + max_attempts -= 1 + + assert max_attempts > 0, "failed to scroll through message" + + with client: + client.set_input_flow(input_flow) + client.debug.watch_layout(True) + btc.sign_message( + client, + coin_name="Bitcoin", + n=parse_path("m/44h/0h/0h/0/0"), + message=message, + ) + assert message.replace("\n", " ") == message_read + + +@pytest.mark.skip_t1 +def test_signmessage_pagination_trailing_newline(client): + # This can currently only be tested by a human via the UI test diff: + message = "THIS\nMUST\nNOT\nBE\nPAGINATED\n" + # The trailing newline must not cause a new paginated screen to appear. + # The UI must be a single dialog without pagination. + btc.sign_message( + client, + coin_name="Bitcoin", + n=parse_path("m/44h/0h/0h/0/0"), + message=message, + )