SeedQR transcription UI; generalized LargeIconStatusScreen

This commit is contained in:
kdmukai
2022-03-12 22:14:31 -06:00
parent 63e4de8662
commit aeb0279f87
12 changed files with 689 additions and 370 deletions
+2 -2
View File
@@ -150,8 +150,8 @@ class Controller(Singleton):
# TODO: Remove for v0.5.0 production release
WarningScreen(
title="Warning",
warning_headline="Pre-Release Code",
warning_text="Do not use this with real funds or to create new secure keys!",
status_headline="Pre-Release Code",
text="Do not use this with real funds or to create new secure keys!",
show_back_button=False,
).display()
+14 -6
View File
@@ -18,6 +18,7 @@ class GUIConstants:
BACKGROUND_COLOR = "black"
WARNING_COLOR = "#FFD60A"
DIRE_WARNING_COLOR = "red"
SUCCESS_COLOR = "#00dd00"
BITCOIN_ORANGE = "#ff9416"
ACCENT_COLOR = "orange"
@@ -320,12 +321,18 @@ class TextArea(BaseComponent):
# No whitespace chars to split on!
raise TextDoesNotFitException("Text cannot fit in target rect with this font/size")
words = self.text.split()
while words:
(index, tw) = _binary_len_search(0, len(words))
_add_text_line(" ".join(words[0:index]), tw)
words = words[index:]
for line in self.text.split("\n"):
words = line.split()
if not words:
# It's a blank line
_add_text_line("", 0)
else:
while words:
(index, tw) = _binary_len_search(0, len(words))
_add_text_line(" ".join(words[0:index]), tw)
words = words[index:]
# TODO: Don't render blank lines as full height
total_text_height = self.bbox_height * len(self.text_lines) + self.line_spacing * (len(self.text_lines) - 1)
if self.height is None:
# Autoscale height to text lines
@@ -412,6 +419,7 @@ class IconTextLine(BaseComponent):
font_name: str = GUIConstants.BODY_FONT_NAME
font_size: int = GUIConstants.BODY_FONT_SIZE
is_text_centered: bool = False
auto_line_break: bool = False
screen_x: int = 0
screen_y: int = 0
@@ -468,7 +476,7 @@ class IconTextLine(BaseComponent):
font_size=self.font_size,
edge_padding=0,
is_text_centered=False,
auto_line_break=False,
auto_line_break=self.auto_line_break,
screen_x=text_screen_x,
screen_y=value_textarea_screen_y,
)
+7 -7
View File
@@ -39,15 +39,16 @@ class Renderer(ConfigurableSingleton):
def show_image(self, image=None, alpha_overlay=None):
if image == None:
image = self.canvas
if alpha_overlay:
if image == None:
image = self.canvas
image = Image.alpha_composite(image, alpha_overlay)
# Always write to the current canvas, rather than trying to replace it
self.canvas.paste(image)
self.disp.ShowImage(image, 0, 0)
if image:
# Always write to the current canvas, rather than trying to replace it
self.canvas.paste(image)
self.disp.ShowImage(self.canvas, 0, 0)
def show_image_pan(self, image, start_x, start_y, end_x, end_y, rate, alpha_overlay=None):
@@ -87,7 +88,6 @@ class Renderer(ConfigurableSingleton):
# TODO: Remove all references
def show_image_with_text(self, image, text, font=None, text_color="GREY", text_background=None):
image_copy = image.copy().convert("RGBA")
draw = ImageDraw.Draw(image_copy)
text_overlay = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
text_overlay_draw = ImageDraw.Draw(text_overlay)
+1 -1
View File
@@ -721,7 +721,7 @@ class PSBTFinalizeScreen(ButtonListScreen):
icon = Icon(
icon_name=FontAwesomeIconConstants.PAPER_PLANE,
icon_color="#00dd00",
icon_color=GUIConstants.SUCCESS_COLOR,
icon_size=GUIConstants.ICON_LARGE_BUTTON_SIZE,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
)
+1 -1
View File
@@ -15,6 +15,7 @@ from seedsigner.models import DecodeQR, DecodeQRStatus
@dataclass
class ScanScreen(BaseTopNavScreen):
decoder: DecodeQR = None
instructions_text: str = "Scan a QR code"
def __post_init__(self):
from seedsigner.hardware.camera import Camera
@@ -33,7 +34,6 @@ class ScanScreen(BaseTopNavScreen):
self.instructions_background_y = self.canvas_height - self.instructions_background.height
# Pre-calc where the instruction text goes
self.instructions_text = "Scan a QR code"
self.instructions_font = Fonts.get_font(GUIConstants.BUTTON_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
# TODO: Add the QR code icon and adjust start_x
+55 -44
View File
@@ -694,6 +694,50 @@ class QRDisplayScreen(BaseScreen):
@dataclass
class LargeIconStatusScreen(ButtonListScreen):
title: str = "Success!"
status_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_CHECK
status_icon_size: int = GUIConstants.ICON_PRIMARY_SCREEN_SIZE
status_color: str = GUIConstants.SUCCESS_COLOR
status_headline: str = "Success!" # The colored text under the large icon
text: str = "" # The body text of the screen
button_data: list = None
def __post_init__(self):
self.is_bottom_list: bool = True
if not self.button_data:
self.button_data = ["OK"]
super().__post_init__()
self.status_icon = Icon(
icon_name=self.status_icon_name,
icon_size=self.status_icon_size,
icon_color=self.status_color,
)
self.status_icon.screen_y = self.top_nav.height - int(GUIConstants.COMPONENT_PADDING/2)
self.status_icon.screen_x = int((self.canvas_width - self.status_icon.width) / 2)
self.components.append(self.status_icon)
next_y = self.status_icon.screen_y + self.status_icon.height + 4
if self.status_headline:
self.warning_headline_textarea = TextArea(
text=self.status_headline,
width=self.canvas_width,
screen_y=next_y,
font_color=self.status_color,
)
self.components.append(self.warning_headline_textarea)
next_y = next_y + self.warning_headline_textarea.height
self.components.append(TextArea(
height=self.buttons[0].screen_y - next_y,
text=self.text,
width=self.canvas_width,
screen_y=next_y,
))
class WarningEdgesThread(BaseThread):
def __init__(self, args):
super().__init__()
@@ -707,15 +751,15 @@ class WarningEdgesThread(BaseThread):
inhale_hold = 8
cur_inhale_hold = 0
inhale_factor = 0
rgb = ImageColor.getrgb(screen.warning_color)
rgb = ImageColor.getrgb(screen.status_color)
def render_border(color, width):
screen.image_draw.rounded_rectangle(
screen.image_draw.rectangle(
(0, 0, screen.canvas_width, screen.canvas_height),
fill=None,
outline=color,
width=width,
radius=5
# radius=5
)
while self.keep_running:
@@ -755,7 +799,7 @@ class WarningEdgesThread(BaseThread):
@dataclass
class WarningEdgesMixin:
warning_color: str = GUIConstants.WARNING_COLOR
status_color: str = GUIConstants.WARNING_COLOR
def __post_init__(self):
super().__post_init__()
@@ -765,54 +809,21 @@ class WarningEdgesMixin:
@dataclass
class WarningScreen(WarningEdgesMixin, ButtonListScreen):
class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen):
title: str = "Caution"
warning_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION
warning_icon_size=GUIConstants.ICON_PRIMARY_SCREEN_SIZE
warning_headline: str = "Privacy Leak!" # The colored text under the alert icon
warning_text: str = "" # The body text of the warning
button_data: list = None
status_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION
status_color: str = "yellow"
status_headline: str = "Privacy Leak!" # The colored text under the alert icon
def __post_init__(self):
self.is_bottom_list: bool = True
if not self.button_data:
self.button_data = ["I Understand"]
super().__post_init__()
self.warning_icon = Icon(
icon_name=self.warning_icon_name,
icon_size=self.warning_icon_size,
icon_color=self.warning_color,
)
self.warning_icon.screen_y = self.top_nav.height - int(GUIConstants.COMPONENT_PADDING/2)
self.warning_icon.screen_x = int((self.canvas_width - self.warning_icon.width) / 2)
self.components.append(self.warning_icon)
next_y = self.warning_icon.screen_y + self.warning_icon.height + 4
if self.warning_headline:
self.warning_headline_textarea = TextArea(
text=self.warning_headline,
width=self.canvas_width,
screen_y=next_y,
font_color=self.warning_color,
)
self.components.append(self.warning_headline_textarea)
next_y = next_y + self.warning_headline_textarea.height
self.warning_text_textarea = TextArea(
height=self.buttons[0].screen_y - next_y,
text=self.warning_text,
width=self.canvas_width,
screen_y=next_y,
)
self.components.append(self.warning_text_textarea)
@dataclass
class DireWarningScreen(WarningScreen):
title: str = "Caution"
warning_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION
warning_headline: str = "Classified Info!" # The colored text under the alert icon
warning_color: str = GUIConstants.DIRE_WARNING_COLOR
status_headline: str = "Classified Info!" # The colored text under the alert icon
status_color: str = GUIConstants.DIRE_WARNING_COLOR
+274 -2
View File
@@ -6,12 +6,14 @@ from typing import List
from PIL import Image, ImageDraw, ImageFilter
from seedsigner.gui.renderer import Renderer
from seedsigner.helpers.qr import QR
from seedsigner.models.qr_type import QRType
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
from seedsigner.models.seed import Seed
from seedsigner.models.settings_definition import SettingsConstants
from .screen import RET_CODE__BACK_BUTTON, BaseTopNavScreen, ButtonListScreen, WarningEdgesMixin
from .screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, WarningEdgesMixin
from ..components import (FontAwesomeIconConstants, Fonts, FormattedAddress,
IconTextLine, SeedSignerCustomIconConstants, TextArea, GUIConstants,
calc_text_centering)
@@ -447,7 +449,7 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen):
page_index: int = 0
num_pages: int = 3
is_bottom_list: bool = True
warning_color: str = GUIConstants.DIRE_WARNING_COLOR
status_color: str = GUIConstants.DIRE_WARNING_COLOR
def __post_init__(self):
@@ -1072,6 +1074,276 @@ class SeedReviewPassphraseScreen(ButtonListScreen):
@dataclass
class SeedTranscribeSeedQRFormatScreen(ButtonListScreen):
def __post_init__(self):
self.is_bottom_list = True
super().__post_init__()
self.components.append(IconTextLine(
label_text="Standard",
value_text="BIP-39 wordlist indices",
is_text_centered=False,
auto_line_break=True,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
))
self.components.append(IconTextLine(
label_text="Compact",
value_text="Raw entropy bits",
is_text_centered=False,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
))
# self.components.append(TextArea(
# text="Learn more about the SeedQR formats at:",
# is_text_centered=True,
# screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
# ))
# self.components.append(TextArea(
# text="seedsigner.com",
# is_text_centered=True,
# screen_y=self.components[-1].screen_y + self.components[-1].height,
# height=self.buttons[0].screen_y - (self.components[-1].screen_y + self.components[-1].height),
# ))
@dataclass
class SeedTranscribeSeedQRWholeQRScreen(WarningEdgesMixin, ButtonListScreen):
qr_data: str = None
num_modules: int = None
def __post_init__(self):
self.title = "Transcribe SeedQR"
self.button_data = [f"Begin {self.num_modules}x{self.num_modules}"]
self.is_bottom_list = True
self.status_color = GUIConstants.DIRE_WARNING_COLOR
super().__post_init__()
qr_height = self.buttons[0].screen_y - self.top_nav.height - GUIConstants.COMPONENT_PADDING
qr_width = qr_height
qr = QR()
qr_image = qr.qrimage(
data=self.qr_data,
width=qr_width,
height=qr_height,
border=1,
style=QR.STYLE__ROUNDED
).convert("RGBA")
self.paste_images.append((qr_image, (int((self.canvas_width - qr_width)/2), self.top_nav.height)))
# self.instructions = TextArea(
# text="< back | click to begin",
# background_color=GUIConstants.BACKGROUND_COLOR,
# is_text_centered=False,
# screen_y=self.canvas_height - GUIConstants.BODY_FONT_SIZE - 2*GUIConstants.COMPONENT_PADDING,
# height=GUIConstants.BODY_FONT_SIZE + 2*GUIConstants.COMPONENT_PADDING,
# )
@dataclass
class SeedTranscribeSeedQRZoomedInScreen(BaseScreen):
qr_data: str = None
num_modules: int = None
def __post_init__(self):
super().__post_init__()
# Render an oversized QR code that we can view up close
self.pixels_per_block = 24
# Border must accommodate the 3 blocks outside the center 5x5 mask plus up to
# 1 empty block inside the 5x5 mask (29x29 has a 4-block final col/row).
self.qr_border = 4
if self.num_modules == 21:
# Optimize for 21x21
self.qr_blocks_per_zoom = 7
else:
self.qr_blocks_per_zoom = 5
self.qr_width = (self.qr_border + self.num_modules + self.qr_border) * self.pixels_per_block
self.height = self.qr_width
qr = QR()
self.qr_image = qr.qrimage(
self.qr_data,
width=self.qr_width,
height=self.height,
border=self.qr_border,
style=QR.STYLE__ROUNDED
).convert("RGBA")
# Render gridlines but leave the 1-block border as-is
draw = ImageDraw.Draw(self.qr_image)
for i in range(self.qr_border, math.floor(self.qr_width/self.pixels_per_block) - self.qr_border):
draw.line((i * self.pixels_per_block, self.qr_border * self.pixels_per_block, i * self.pixels_per_block, self.height - self.qr_border * self.pixels_per_block), fill="#bbb")
draw.line((self.qr_border * self.pixels_per_block, i * self.pixels_per_block, self.qr_width - self.qr_border * self.pixels_per_block, i * self.pixels_per_block), fill="#bbb")
# Prep the semi-transparent mask overlay
# make a blank image for the overlay, initialized to transparent
self.block_mask = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
draw = ImageDraw.Draw(self.block_mask)
self.mask_width = int((self.canvas_width - self.qr_blocks_per_zoom * self.pixels_per_block)/2)
self.mask_height = int((self.canvas_height - self.qr_blocks_per_zoom * self.pixels_per_block)/2)
mask_rgba = (0, 0, 0, 226)
draw.rectangle((0, 0, self.canvas_width, self.mask_height), fill=mask_rgba)
draw.rectangle((0, self.canvas_height - self.mask_height - 1, self.canvas_width, self.canvas_height), fill=mask_rgba)
draw.rectangle((0, self.mask_height, self.mask_width, self.canvas_height - self.mask_height), fill=mask_rgba)
draw.rectangle((self.canvas_width - self.mask_width - 1, self.mask_height, self.canvas_width, self.canvas_height - self.mask_height), fill=mask_rgba)
# Draw a box around the cutout portion of the mask for better visibility
draw.line((self.mask_width, self.mask_height, self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR)
draw.line((self.canvas_width - self.mask_width, self.mask_height, self.canvas_width - self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR)
draw.line((self.mask_width, self.mask_height, self.canvas_width - self.mask_width, self.mask_height), fill=GUIConstants.ACCENT_COLOR)
draw.line((self.mask_width, self.canvas_height - self.mask_height, self.canvas_width - self.mask_width, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR)
msg = "click to exit"
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BODY_FONT_SIZE)
(left, top, right, bottom) = font.getbbox(msg, anchor="ls")
msg_height = -1 * top
msg_width = right
# draw.rectangle(
# (
# int((self.canvas_width - msg_width)/2 - GUIConstants.COMPONENT_PADDING),
# self.canvas_height - msg_height - GUIConstants.COMPONENT_PADDING,
# int((self.canvas_width + msg_width)/2 + GUIConstants.COMPONENT_PADDING),
# self.canvas_height
# ),
# fill=GUIConstants.BACKGROUND_COLOR,
# )
# draw.text(
# (int(self.canvas_width/2), self.canvas_height - int(GUIConstants.COMPONENT_PADDING/2)),
# msg,
# fill=GUIConstants.BODY_FONT_COLOR,
# font=font,
# anchor="ms" # Middle, baSeline
# )
TextArea(
canvas=self.block_mask,
image_draw=draw,
text=msg,
background_color=GUIConstants.BACKGROUND_COLOR,
is_text_centered=True,
screen_y=self.canvas_height - GUIConstants.BODY_FONT_SIZE - GUIConstants.COMPONENT_PADDING,
height=GUIConstants.BODY_FONT_SIZE + GUIConstants.COMPONENT_PADDING,
).render()
def draw_block_labels(self, cur_block_x, cur_block_y):
# Create overlay for block labels (e.g. "D-5")
block_labels_x = ["1", "2", "3", "4", "5", "6"]
block_labels_y = ["A", "B", "C", "D", "E", "F"]
block_labels = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
draw = ImageDraw.Draw(block_labels)
draw.rectangle((self.mask_width, 0, self.canvas_width - self.mask_width, self.pixels_per_block), fill=GUIConstants.ACCENT_COLOR)
draw.rectangle((0, self.mask_height, self.pixels_per_block, self.canvas_height - self.mask_height), fill=GUIConstants.ACCENT_COLOR)
label_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 8)
x_label = block_labels_x[cur_block_x]
(left, top, right, bottom) = label_font.getbbox(x_label, anchor="ls")
x_label_height = -1 * top
draw.text(
(int(self.canvas_width/2), self.pixels_per_block - int((self.pixels_per_block - x_label_height)/2)),
text=x_label,
fill=GUIConstants.BUTTON_SELECTED_FONT_COLOR,
font=label_font,
anchor="ms", # Middle, baSeline
)
y_label = block_labels_y[cur_block_y]
(left, top, right, bottom) = label_font.getbbox(y_label, anchor="ls")
y_label_height = -1 * top
draw.text(
(int(self.pixels_per_block/2), int((self.canvas_height + y_label_height) / 2)),
text=y_label,
fill=GUIConstants.BUTTON_SELECTED_FONT_COLOR,
font=label_font,
anchor="ms", # Middle, baSeline
)
return block_labels
def _run(self):
# Track our current coordinates for the upper left corner of our view
cur_block_x = 0
cur_block_y = 0
cur_x = self.qr_border * self.pixels_per_block - self.mask_width
cur_y = self.qr_border * self.pixels_per_block - self.mask_height
next_x = cur_x
next_y = cur_y
block_labels = self.draw_block_labels(0, 0)
self.renderer.show_image(
self.qr_image.crop((cur_x, cur_y, cur_x + self.canvas_width, cur_y + self.canvas_height)),
alpha_overlay=Image.alpha_composite(self.block_mask, block_labels)
)
while True:
input = self.hw_inputs.wait_for(HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN + [HardwareButtonsConstants.KEY_PRESS])
if input == HardwareButtonsConstants.KEY_RIGHT:
next_x = cur_x + self.qr_blocks_per_zoom * self.pixels_per_block
cur_block_x += 1
if next_x > self.qr_width - self.canvas_width:
next_x = cur_x
cur_block_x -= 1
elif input == HardwareButtonsConstants.KEY_LEFT:
next_x = cur_x - self.qr_blocks_per_zoom * self.pixels_per_block
cur_block_x -= 1
if next_x < 0:
next_x = cur_x
cur_block_x += 1
elif input == HardwareButtonsConstants.KEY_DOWN:
next_y = cur_y + self.qr_blocks_per_zoom * self.pixels_per_block
cur_block_y += 1
if next_y > self.height - self.canvas_height:
next_y = cur_y
cur_block_y -= 1
elif input == HardwareButtonsConstants.KEY_UP:
next_y = cur_y - self.qr_blocks_per_zoom * self.pixels_per_block
cur_block_y -= 1
if next_y < 0:
next_y = cur_y
cur_block_y += 1
elif input == HardwareButtonsConstants.KEY_PRESS:
return
# Create overlay for block labels (e.g. "D-5")
block_labels = self.draw_block_labels(cur_block_x, cur_block_y)
if cur_x != next_x or cur_y != next_y:
self.renderer.show_image_pan(
self.qr_image,
cur_x, cur_y, next_x, next_y,
rate=self.pixels_per_block,
alpha_overlay=Image.alpha_composite(self.block_mask, block_labels)
)
cur_x = next_x
cur_y = next_y
@dataclass
class SeedTranscribeSeedQRConfirmQRPromptScreen(ButtonListScreen):
def __post_init__(self):
self.is_bottom_list = True
super().__post_init__()
self.components.append(TextArea(
text="Optionally scan your transcribed SeedQR to confirm that it reads back correctly.",
screen_y=self.top_nav.height,
height=self.buttons[0].screen_y - self.top_nav.height,
))
@dataclass
class SingleSigAddressVerificationScreen(ButtonListScreen):
"""
-219
View File
@@ -252,222 +252,3 @@ class SeedToolsView(View):
self.renderer.show_image()
self.dice_selected = dice_selected
###
### Display Seed Phrase
###
def display_seed_qr_options(self, seed_phrase) -> int:
if len(seed_phrase) == 24:
standard = "29x29"
compact = "25x25"
else:
standard = "25x25"
compact = "21x21"
r = self.controller.menu_view.display_generic_selection_menu(
["... [ Return to Seed Tools ]", f"Standard SeedQR: {standard}", f"Compact SeedQR: {compact}"],
"Transcribe SeedQR"
)
if r == 2:
return self.seed_phrase_as_qr(seed_phrase, is_compact_seedqr=False)
elif r == 3:
return self.seed_phrase_as_qr(seed_phrase, is_compact_seedqr=True)
else:
return None
def seed_phrase_as_qr(self, seed_phrase, is_compact_seedqr=False):
if is_compact_seedqr:
e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.SEED__COMPACTSEEDQR, wordlist=self.settings.wordlist)
else:
e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.SEED__SEEDQR, wordlist=self.settings.wordlist)
data = e.next_part()
qr = QR()
image = qr.qrimage(
data=data,
width=240,
height=240,
border=3,
style=QR.STYLE__ROUNDED).convert("RGBA")
View.DispShowImageWithText(image, "click to zoom, right to exit", font=View.ASSISTANT18, text_color="BLACK", text_background="ORANGE")
input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_PRESS])
if input == B.KEY_RIGHT:
return
elif input == B.KEY_PRESS:
# Render an oversized QR code that we can view up close
pixels_per_block = 24
# Border must accommodate the 3 blocks outside the center 5x5 mask plus up to
# 4 empty blocks inside the 5x5 mask (21x21 has a 1-block final col/row).
qr_border = 7
qr_blocks_per_zoom = 5
if len(seed_phrase) == 24:
if is_compact_seedqr:
num_modules = 25
else:
num_modules = 29
else:
if is_compact_seedqr:
num_modules = 21
# Optimize for 21x21
qr_blocks_per_zoom = 7
else:
num_modules = 25
width = (qr_border + num_modules + qr_border) * pixels_per_block
height = width
data = e.next_part()
qr = QR()
image = qr.qrimage(
data,
width=width,
height=height,
border=qr_border,
style=QR.STYLE__ROUNDED).convert("RGBA")
# Render gridlines but leave the 1-block border as-is
draw = ImageDraw.Draw(image)
for i in range(qr_border, math.floor(width/pixels_per_block) - qr_border):
draw.line((i * pixels_per_block, qr_border * pixels_per_block, i * pixels_per_block, height - qr_border * pixels_per_block), fill="#bbb")
draw.line((qr_border * pixels_per_block, i * pixels_per_block, width - qr_border * pixels_per_block, i * pixels_per_block), fill="#bbb")
# Prep the semi-transparent mask overlay
# make a blank image for the overlay, initialized to transparent
block_mask = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
draw = ImageDraw.Draw(block_mask)
mask_width = int((self.canvas_width - qr_blocks_per_zoom * pixels_per_block)/2)
mask_height = int((self.canvas_height - qr_blocks_per_zoom * pixels_per_block)/2)
mask_rgba = (0, 0, 0, 226)
draw.rectangle((0, 0, self.canvas_width, mask_height), fill=mask_rgba)
draw.rectangle((0, self.canvas_height - mask_height - 1, self.canvas_width, self.canvas_height), fill=mask_rgba)
draw.rectangle((0, mask_height, mask_width, self.canvas_height - mask_height), fill=mask_rgba)
draw.rectangle((self.canvas_width - mask_width - 1, mask_height, self.canvas_width, self.canvas_height - mask_height), fill=mask_rgba)
# Draw a box around the cutout portion of the mask for better visibility
draw.line((mask_width, mask_height, mask_width, self.canvas_height - mask_height), fill=self.color)
draw.line((self.canvas_width - mask_width, mask_height, self.canvas_width - mask_width, self.canvas_height - mask_height), fill=self.color)
draw.line((mask_width, mask_height, self.canvas_width - mask_width, mask_height), fill=self.color)
draw.line((mask_width, self.canvas_height - mask_height, self.canvas_width - mask_width, self.canvas_height - mask_height), fill=self.color)
msg = "click to exit"
tw, th = draw.textsize(msg, font=Fonts.get_font("Assistant-Medium", 18))
draw.text(((self.canvas_width - tw) / 2, self.canvas_height - th - 2), msg, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
def draw_block_labels(cur_block_x, cur_block_y):
# Create overlay for block labels (e.g. "D-5")
block_labels_x = ["1", "2", "3", "4", "5", "6"]
block_labels_y = ["A", "B", "C", "D", "E", "F"]
block_labels = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
draw = ImageDraw.Draw(block_labels)
draw.rectangle((mask_width, 0, self.canvas_width - mask_width, pixels_per_block), fill=self.color)
draw.rectangle((0, mask_height, pixels_per_block, self.canvas_height - mask_height), fill=self.color)
label_font = Fonts.get_font("Assistant-Medium", 26)
x_label = block_labels_x[cur_block_x]
tw, th = draw.textsize(x_label, font=label_font)
# note: have to nudge the y-coord up (the extra "- 4") for some reason
draw.text(((self.canvas_width - tw) / 2, ((pixels_per_block - th) / 2) - 4), x_label, fill="BLACK", font=label_font)
y_label = block_labels_y[cur_block_y]
tw, th = draw.textsize(y_label, font=label_font)
draw.text(((pixels_per_block - tw) / 2, (self.canvas_height - th) / 2), y_label, fill="BLACK", font=label_font)
return block_labels
block_labels = draw_block_labels(0, 0)
# Track our current coordinates for the upper left corner of our view
cur_block_x = 0
cur_block_y = 0
cur_x = qr_border * pixels_per_block - mask_width
cur_y = qr_border * pixels_per_block - mask_height
next_x = cur_x
next_y = cur_y
self.renderer.show_image(
image.crop((cur_x, cur_y, cur_x + self.canvas_width, cur_y + self.canvas_height)),
alpha_overlay=Image.alpha_composite(block_mask, block_labels)
)
while True:
input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT, B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS])
if input == B.KEY_RIGHT:
next_x = cur_x + qr_blocks_per_zoom * pixels_per_block
cur_block_x += 1
if next_x > width - self.canvas_width:
next_x = cur_x
cur_block_x -= 1
elif input == B.KEY_LEFT:
next_x = cur_x - qr_blocks_per_zoom * pixels_per_block
cur_block_x -= 1
if next_x < 0:
next_x = cur_x
cur_block_x += 1
elif input == B.KEY_DOWN:
next_y = cur_y + qr_blocks_per_zoom * pixels_per_block
cur_block_y += 1
if next_y > height - self.canvas_height:
next_y = cur_y
cur_block_y -= 1
elif input == B.KEY_UP:
next_y = cur_y - qr_blocks_per_zoom * pixels_per_block
cur_block_y -= 1
if next_y < 0:
next_y = cur_y
cur_block_y += 1
elif input == B.KEY_PRESS:
return
# Create overlay for block labels (e.g. "D-5")
block_labels = draw_block_labels(cur_block_x, cur_block_y)
if cur_x != next_x or cur_y != next_y:
self.renderer.show_image_pan(
image,
cur_x, cur_y, next_x, next_y,
rate=pixels_per_block,
alpha_overlay=Image.alpha_composite(block_mask, block_labels)
)
cur_x = next_x
cur_y = next_y
def read_seed_phrase_qr(self):
self.draw_modal(["Scanning..."], "Seed QR" ,"Right to Exit")
try:
self.controller.camera.start_video_stream_mode(resolution=(480, 480), framerate=12, format="rgb")
decoder = DecodeQR(wordlist=self.settings.wordlist)
while True:
frame = self.controller.camera.read_video_stream(as_image=True)
if frame is not None:
self.renderer.show_image_with_text(frame.resize((240,240)), "Scan Seed QR", font=Fonts.get_font("Assistant-Medium", 22), text_color=self.color, text_background=(0,0,0,225))
status = decoder.add_image(frame)
if status in (DecodeQRStatus.COMPLETE, DecodeQRStatus.INVALID):
break
if self.buttons.check_for_low(B.KEY_RIGHT) or self.buttons.check_for_low(B.KEY_LEFT):
self.controller.camera.stop_video_stream_mode()
self.words = []
return self.words[:]
if decoder.is_complete and decoder.is_seed:
self.words = decoder.get_seed_phrase()
elif not decoder.is_psbt:
self.draw_modal(["Not a valid Seed QR"], "", "Right to Exit")
input = self.buttons.wait_for([B.KEY_RIGHT])
else:
self.draw_modal(["QR Parsing Failed"], "", "Right to Exit")
input = self.buttons.wait_for([B.KEY_RIGHT])
self.words = []
finally:
self.controller.camera.stop_video_stream_mode()
return self.words[:]
+3 -3
View File
@@ -433,9 +433,9 @@ class PSBTSigningErrorView(View):
selected_menu_num = WarningScreen(
title="PSBT Error",
warning_icon_name=SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION,
warning_headline="Signing Failed",
warning_text="Signing with this seed did not add a valid signature.",
status_icon_name=SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION,
status_headline="Signing Failed",
text="Signing with this seed did not add a valid signature.",
button_data=["Select Diff Seed"],
).display()
+329 -77
View File
@@ -3,6 +3,7 @@ import embit
import time
from binascii import hexlify
from embit.networks import NETWORKS
from seedsigner.models.decode_qr import DecodeQR
from seedsigner.models.settings_definition import SettingsDefinition
@@ -11,7 +12,7 @@ from .view import NotYetImplementedView, View, Destination, BackStackView, MainM
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen,
LargeButtonScreen, WarningScreen, DireWarningScreen, seed_screens)
from seedsigner.gui.screens.screen import LoadingScreenThread, QRDisplayScreen
from seedsigner.gui.screens.screen import LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.models.psbt_parser import PSBTParser
@@ -35,7 +36,7 @@ class SeedsMenuView(View):
def run(self):
if not self.seeds:
# Nothing to do here unless we have a seed loaded
return Destination(LoadSeedView)
return Destination(LoadSeedView, clear_history=True)
button_data = []
for seed in self.seeds:
@@ -179,8 +180,8 @@ class SeedMnemonicInvalidView(View):
selected_menu_num = WarningScreen(
title="Invalid Mnemonic!",
warning_headline=None,
warning_text=f"Checksum failure; not a valid seed phrase.",
status_headline=None,
text=f"Checksum failure; not a valid seed phrase.",
show_back_button=False,
button_data=button_data,
).display()
@@ -304,8 +305,8 @@ class SeedDiscardView(View):
fingerprint = self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
selected_menu_num = WarningScreen(
title="Discard Seed?",
warning_headline=None,
warning_text=f"Wipe seed {fingerprint} from the device?",
status_headline=None,
text=f"Wipe seed {fingerprint} from the device?",
show_back_button=False,
button_data=button_data,
).display()
@@ -388,81 +389,13 @@ class SeedOptionsView(View):
return Destination(SeedExportXpubSigTypeView, view_args={"seed_num": self.seed_num})
elif button_data[selected_menu_num] == EXPORT_SEEDQR:
return Destination(NotYetImplementedView)
# return Destination(SeedExportSeedQRView, view_args={"seed_num": self.seed_num})
return Destination(SeedTranscribeSeedQRFormatView, view_args={"seed_num": self.seed_num})
elif button_data[selected_menu_num] == DISCARD:
return Destination(SeedDiscardView, view_args={"seed_num": self.seed_num})
"""****************************************************************************
View Seed Words flow
****************************************************************************"""
class SeedWordsWarningView(View):
def __init__(self, seed_num: int):
super().__init__()
self.seed_num = seed_num
def run(self):
destination = Destination(SeedWordsView, view_args={"seed_num": self.seed_num, "page_index": 0})
if self.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__DISABLED:
# Forward straight to showing the words
destination.skip_current_view = True
return destination
selected_menu_num = DireWarningScreen(
warning_text="""You must keep your seed words private & away from all online devices.""",
).display()
if selected_menu_num == 0:
# User clicked "I Understand"
return destination
elif selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class SeedWordsView(View):
def __init__(self, seed_num: int, page_index: int = 0):
super().__init__()
self.seed_num = seed_num
self.seed = self.controller.get_seed(self.seed_num)
self.page_index = page_index
self.num_pages=int(len(self.seed.mnemonic_list)/4)
def run(self):
NEXT = "Next"
DONE = "Done"
button_data = []
if self.page_index < self.num_pages - 1:
button_data.append(NEXT)
else:
button_data.append(DONE)
selected_menu_num = seed_screens.SeedWordsScreen(
seed=self.seed,
page_index=self.page_index,
num_pages=self.num_pages,
button_data=button_data,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == NEXT:
return Destination(SeedWordsView, view_args={"seed_num": self.seed_num, "page_index": self.page_index + 1})
elif button_data[selected_menu_num] == DONE:
# Must clear history to avoid BACK button returning to private info
return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}, clear_history=True)
"""****************************************************************************
Export Xpub flow
****************************************************************************"""
@@ -624,8 +557,8 @@ class SeedExportXpubWarningView(View):
return destination
selected_menu_num = WarningScreen(
warning_headline="Privacy Leak!",
warning_text="""Xpub can be used to view all future transactions.""",
status_headline="Privacy Leak!",
text="""Xpub can be used to view all future transactions.""",
).display()
if selected_menu_num == 0:
@@ -753,6 +686,325 @@ class SeedExportXpubQRDisplayView(View):
"""****************************************************************************
View Seed Words flow
****************************************************************************"""
class SeedWordsWarningView(View):
def __init__(self, seed_num: int):
super().__init__()
self.seed_num = seed_num
def run(self):
destination = Destination(SeedWordsView, view_args={"seed_num": self.seed_num, "page_index": 0})
if self.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__DISABLED:
# Forward straight to showing the words
destination.skip_current_view = True
return destination
selected_menu_num = DireWarningScreen(
text="""You must keep your seed words private & away from all online devices.""",
).display()
if selected_menu_num == 0:
# User clicked "I Understand"
return destination
elif selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class SeedWordsView(View):
def __init__(self, seed_num: int, page_index: int = 0):
super().__init__()
self.seed_num = seed_num
self.seed = self.controller.get_seed(self.seed_num)
self.page_index = page_index
self.num_pages=int(len(self.seed.mnemonic_list)/4)
def run(self):
NEXT = "Next"
DONE = "Done"
button_data = []
if self.page_index < self.num_pages - 1:
button_data.append(NEXT)
else:
button_data.append(DONE)
selected_menu_num = seed_screens.SeedWordsScreen(
seed=self.seed,
page_index=self.page_index,
num_pages=self.num_pages,
button_data=button_data,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == NEXT:
return Destination(SeedWordsView, view_args={"seed_num": self.seed_num, "page_index": self.page_index + 1})
elif button_data[selected_menu_num] == DONE:
# Must clear history to avoid BACK button returning to private info
return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}, clear_history=True)
"""****************************************************************************
Export as SeedQR
****************************************************************************"""
class SeedTranscribeSeedQRFormatView(View):
def __init__(self, seed_num: int):
super().__init__()
self.seed_num = seed_num
def run(self):
if self.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) != SettingsConstants.OPTION__ENABLED:
# Only configured for standard SeedQR
return Destination(
SeedTranscribeSeedQRWarningView,
view_args={
"seed_num": self.seed_num,
"seedqr_format": QRType.SEED__SEEDQR,
},
skip_current_view=True,
)
seed = self.controller.get_seed(self.seed_num)
if len(seed.mnemonic_list) == 12:
STANDARD = "Standard: 25x25"
COMPACT = "Compact: 21x21"
num_modules_standard = 25
num_modules_compact = 21
else:
STANDARD = "Standard: 29x29"
COMPACT = "Compact: 25x25"
num_modules_standard = 29
num_modules_compact = 25
button_data = [STANDARD, COMPACT]
selected_menu_num = seed_screens.SeedTranscribeSeedQRFormatScreen(
title="SeedQR Format",
button_data=button_data,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == STANDARD:
seedqr_format = QRType.SEED__SEEDQR
num_modules = num_modules_standard
else:
seedqr_format = QRType.SEED__COMPACTSEEDQR
num_modules = num_modules_compact
return Destination(
SeedTranscribeSeedQRWarningView,
view_args={
"seed_num": self.seed_num,
"seedqr_format": seedqr_format,
"num_modules": num_modules,
}
)
class SeedTranscribeSeedQRWarningView(View):
def __init__(self, seed_num: int, seedqr_format: str = QRType.SEED__SEEDQR, num_modules: int = 29):
super().__init__()
self.seed_num = seed_num
self.seedqr_format = seedqr_format
self.num_modules = num_modules
def run(self):
destination = Destination(
SeedTranscribeSeedQRWholeQRView,
view_args={
"seed_num": self.seed_num,
"seedqr_format": self.seedqr_format,
"num_modules": self.num_modules,
}
)
if self.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__DISABLED:
# Forward straight to transcribing the SeedQR
destination.skip_current_view = True
return destination
selected_menu_num = DireWarningScreen(
status_headline="SeedQR is your private key!",
text="""Never photograph it or scan it into an online device.""",
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
else:
# User clicked "I Understand"
return destination
class SeedTranscribeSeedQRWholeQRView(View):
def __init__(self, seed_num: int, seedqr_format: str, num_modules: int):
super().__init__()
self.seed_num = seed_num
self.seedqr_format = seedqr_format
self.num_modules = num_modules
self.seed = self.controller.get_seed(seed_num)
def run(self):
e = EncodeQR(
seed_phrase=self.seed.mnemonic_list,
qr_type=self.seedqr_format,
wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
)
data = e.next_part()
ret = seed_screens.SeedTranscribeSeedQRWholeQRScreen(
qr_data=data,
num_modules=self.num_modules,
).display()
if ret == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
else:
return Destination(
SeedTranscribeSeedQRZoomedInView,
view_args={
"seed_num": self.seed_num,
"seedqr_format": self.seedqr_format
}
)
class SeedTranscribeSeedQRZoomedInView(View):
def __init__(self, seed_num: int, seedqr_format: str):
super().__init__()
self.seed_num = seed_num
self.seedqr_format = seedqr_format
self.seed = self.controller.get_seed(seed_num)
def run(self):
e = EncodeQR(
seed_phrase=self.seed.mnemonic_list,
qr_type=self.seedqr_format,
wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
)
data = e.next_part()
if len(self.seed.mnemonic_list) == 24:
if self.seedqr_format == QRType.SEED__COMPACTSEEDQR:
num_modules = 25
else:
num_modules = 29
else:
if self.seedqr_format == QRType.SEED__COMPACTSEEDQR:
num_modules = 21
else:
num_modules = 25
seed_screens.SeedTranscribeSeedQRZoomedInScreen(
qr_data=data,
num_modules=num_modules,
).display()
return Destination(SeedTranscribeSeedQRConfirmQRPromptView, view_args={"seed_num": self.seed_num})
class SeedTranscribeSeedQRConfirmQRPromptView(View):
def __init__(self, seed_num: int):
super().__init__()
self.seed_num = seed_num
self.seed = self.controller.get_seed(seed_num)
def run(self):
SCAN = ("Confirm SeedQR", FontAwesomeIconConstants.QRCODE)
DONE = "Done"
button_data = [SCAN, DONE]
selected_menu_option = seed_screens.SeedTranscribeSeedQRConfirmQRPromptScreen(
title="Confirm SeedQR?",
button_data=button_data,
).display()
if selected_menu_option == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_option] == SCAN:
return Destination(SeedTranscribeSeedQRConfirmScanView, view_args={"seed_num": self.seed_num})
elif button_data[selected_menu_option] == DONE:
return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}, clear_history=True)
class SeedTranscribeSeedQRConfirmScanView(View):
def __init__(self, seed_num: int):
super().__init__()
self.seed_num = seed_num
self.seed = self.controller.get_seed(seed_num)
def run(self):
from seedsigner.gui.screens.scan_screens import ScanScreen
# Run the live preview and QR code capture process
# TODO: Does this belong in its own BaseThread?
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
self.decoder = DecodeQR(wordlist_language_code=wordlist_language_code)
ScanScreen(decoder=self.decoder, instructions_text="Scan your SeedQR").display()
if self.decoder.is_complete:
if self.decoder.is_seed:
seed_mnemonic = self.decoder.get_seed_phrase()
# Found a valid mnemonic seed! But does it match?
if seed_mnemonic != self.seed.mnemonic_list:
DireWarningScreen(
title="Confirm SeedQR",
status_headline="Error!",
text="Your transcribed SeedQR does not match your original seed!",
show_back_button=False,
button_data=["Review SeedQR"],
).display()
return Destination(BackStackView, skip_current_view=True)
else:
LargeIconStatusScreen(
title="Confirm SeedQR",
status_headline="Success!",
text="Your transcribed SeedQR successfully scanned and yielded the same seed.",
show_back_button=False,
button_data=["OK"],
).display()
return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num})
else:
# Will this case ever happen? Will trigger if a different kind of QR code is scanned
DireWarningScreen(
title="Confirm SeedQR",
status_headline="Error!",
text="Your transcribed SeedQR could not be read!",
show_back_button=False,
button_data=["Review SeedQR"],
).display()
return Destination(BackStackView, skip_current_view=True)
class SeedSingleSigAddressVerificationView(View):
"""
TODO: Reserved for Nick.
+1 -6
View File
@@ -103,12 +103,7 @@ class ToolsImageEntropyMnemonicLengthView(View):
TWENTYFOUR_WORDS = "24 words"
button_data = [TWELVE_WORDS, TWENTYFOUR_WORDS]
# selected_menu_num = ButtonListScreen(
# title="Mnemonic Length?",
# button_data=button_data,
# ).display()
selected_menu_num = LargeButtonScreen(
selected_menu_num = ButtonListScreen(
title="Mnemonic Length?",
button_data=button_data,
).display()
+2 -2
View File
@@ -210,8 +210,8 @@ class NotYetImplementedView(View):
def run(self):
WarningScreen(
title="Work In Progress",
warning_headline="Not Yet Implemented",
warning_text="This is still on our to-do list!",
status_headline="Not Yet Implemented",
text="This is still on our to-do list!",
button_data=["Back to Main Menu"],
).display()