Skip to content

Commit

Permalink
Merge pull request #186 from LedgerHQ/add/swipe
Browse files Browse the repository at this point in the history
Adding the swipe gesture
  • Loading branch information
lpascal-ledger authored May 14, 2024
2 parents 481b5ea + a13d90f commit eef2bea
Show file tree
Hide file tree
Showing 16 changed files with 152 additions and 43 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.19.0] - 2024-05-14

### Added
- firmware/navigation: Adding swipe capabilities for Flex

### Changed
- navigation: scenarios now use `Firmware` enum rather than strings

## [1.18.2] - 2024-05-06

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ doc = [
"docutils==0.16",
]
speculos = [
"speculos>=0.8.5",
"speculos>=0.9.1",
"mnemonic",
]
ledgercomm = [
Expand Down
31 changes: 31 additions & 0 deletions src/ragger/backend/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,37 @@ def finger_touch(self, x: int = 0, y: int = 0, delay: float = 0.5) -> None:
"""
raise NotImplementedError

@abstractmethod
def finger_swipe(self,
x: int = 0,
y: int = 0,
direction: str = "left",
delay: float = 0.5) -> None:
"""
Performs a finger swipe on the device screen.
This method may be left void on backends connecting to physical devices,
where a physical interaction must be performed instead.
This will prevent the instrumentation to fail (the void method won't
raise `NotImplementedError`), but the instrumentation flow will probably
get stuck (on further call to `receive` for instance) until the expected
action is performed on the device.
:param x: The x coordinate of the initial finger touch.
:type x: int
:param y: The y coordinate of the initial finger touch.
:type y: int
:param direction: The direction where to orientate the swipe. Must be in: ['up', 'down',
'left', 'right]
:type direction: str
:param delay: Delay between finger touch press and release actions.
:type delay: float
:return: None
:rtype: NoneType
"""
raise NotImplementedError

@abstractmethod
def compare_screen_with_snapshot(self,
golden_snap_path: Path,
Expand Down
9 changes: 9 additions & 0 deletions src/ragger/backend/physical_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ def finger_touch(self, x: int = 0, y: int = 0, delay: float = 0.5) -> None:
self.init_gui()
self._ui.ask_for_touch_action(x, y)

def finger_swipe(self,
x: int = 0,
y: int = 0,
direction: str = "left",
delay: float = 0.5) -> None:
if self._ui is None:
return
raise NotImplementedError

def compare_screen_with_snapshot(self,
golden_snap_path: Path,
crop: Optional[Crop] = None,
Expand Down
7 changes: 7 additions & 0 deletions src/ragger/backend/speculos.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,13 @@ def both_click(self) -> None:
def finger_touch(self, x: int = 0, y: int = 0, delay: float = 0.1) -> None:
self._client.finger_touch(x, y, delay=delay)

def finger_swipe(self,
x: int = 0,
y: int = 0,
direction: str = "left",
delay: float = 0.1) -> None:
self._client.finger_swipe(x, y, direction=direction, delay=delay)

def _save_screen_snapshot(self, snap: BytesIO, path: Path) -> None:
self.logger.info(f"Saving screenshot to image '{path}'")
img = Image.open(snap)
Expand Down
7 changes: 7 additions & 0 deletions src/ragger/backend/stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ def both_click(self) -> None:
def finger_touch(self, x: int = 0, y: int = 0, delay: float = 0.5) -> None:
pass

def finger_swipe(self,
x: int = 0,
y: int = 0,
direction: str = "left",
delay: float = 0.5) -> None:
pass

def compare_screen_with_snapshot(self,
golden_snap_path: Path,
crop: Optional[Crop] = None,
Expand Down
25 changes: 14 additions & 11 deletions src/ragger/conftest/base_conftest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import pytest
from typing import Optional
from typing import Generator, Optional
from pathlib import Path
from ledgered.manifest import Manifest
from ragger.firmware import Firmware
from ragger.backend import SpeculosBackend, LedgerCommBackend, LedgerWalletBackend
from ragger.navigator import NanoNavigator, TouchNavigator, NavigateWithScenario
from ragger.backend import BackendInterface, SpeculosBackend, LedgerCommBackend, LedgerWalletBackend
from ragger.navigator import Navigator, NanoNavigator, TouchNavigator, NavigateWithScenario
from ragger.utils import find_project_root_dir, find_library_application, find_application
from ragger.utils.misc import get_current_app_name_and_version, exit_current_app, open_app_from_dashboard
from ragger.logger import get_default_logger
Expand Down Expand Up @@ -69,18 +69,18 @@ def cli_user_seed(pytestconfig):


@pytest.fixture(scope="session")
def root_pytest_dir(request):
def root_pytest_dir(request) -> Path:
return Path(request.config.rootpath).resolve()


@pytest.fixture(autouse="session")
def default_screenshot_path(root_pytest_dir):
def default_screenshot_path(root_pytest_dir: Path) -> Path:
# Alias reflecting the use case to avoid exposing internal helper fixtures
return root_pytest_dir


@pytest.fixture
def test_name(request):
def test_name(request) -> str:
# Get the name of current pytest test
test_name = request.node.name

Expand Down Expand Up @@ -175,7 +175,7 @@ def prepare_speculos_args(root_pytest_dir: Path, firmware: Firmware, display: bo
# instantiated, and the tests will either run on Speculos or on a physical
# device depending on the backend
def create_backend(root_pytest_dir: Path, backend_name: str, firmware: Firmware, display: bool,
log_apdu_file: Optional[Path], cli_user_seed: str):
log_apdu_file: Optional[Path], cli_user_seed: str) -> BackendInterface:
if backend_name.lower() == "ledgercomm":
return LedgerCommBackend(firmware=firmware,
interface="hid",
Expand All @@ -196,7 +196,9 @@ def create_backend(root_pytest_dir: Path, backend_name: str, firmware: Firmware,

# Backend scope can be configured by the user
@pytest.fixture(scope=conf.OPTIONAL.BACKEND_SCOPE)
def backend(root_pytest_dir, backend_name, firmware, display, log_apdu_file, cli_user_seed):
def backend(root_pytest_dir: Path, backend_name: str, firmware: Firmware, display: bool,
log_apdu_file: Optional[Path],
cli_user_seed: str) -> Generator[BackendInterface, None, None]:
with create_backend(root_pytest_dir, backend_name, firmware, display, log_apdu_file,
cli_user_seed) as b:
if backend_name.lower() != "speculos" and conf.OPTIONAL.APP_NAME:
Expand All @@ -214,7 +216,7 @@ def backend(root_pytest_dir, backend_name, firmware, display, log_apdu_file, cli


@pytest.fixture(scope=conf.OPTIONAL.BACKEND_SCOPE)
def navigator(backend, firmware, golden_run):
def navigator(backend: BackendInterface, firmware: Firmware, golden_run: bool):
if firmware.is_nano:
return NanoNavigator(backend, firmware, golden_run)
else:
Expand All @@ -224,8 +226,9 @@ def navigator(backend, firmware, golden_run):


@pytest.fixture(scope="function")
def scenario_navigator(navigator, firmware, test_name, default_screenshot_path):
return NavigateWithScenario(navigator, firmware.device, test_name, default_screenshot_path)
def scenario_navigator(navigator: Navigator, firmware: Firmware, test_name: str,
default_screenshot_path: Path):
return NavigateWithScenario(navigator, firmware, test_name, default_screenshot_path)


@pytest.fixture(autouse=True)
Expand Down
9 changes: 9 additions & 0 deletions src/ragger/firmware/touch/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,12 @@ def firmware(self) -> Firmware:
@property
def positions(self):
return POSITIONS[str(self.__class__.__name__)][self.firmware]


class Center(Element):

def swipe_left(self):
self.client.finger_swipe(*self.positions, direction="left")

def swipe_right(self):
self.client.finger_swipe(*self.positions, direction="right")
4 changes: 4 additions & 0 deletions src/ragger/firmware/touch/positions.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ def __iter__(self):
FLEX_BUTTON_ABOVE_LOWER_MIDDLE = Position(240, 435)

POSITIONS = {
"Center": {
Firmware.STAX: STAX_CENTER,
Firmware.FLEX: FLEX_CENTER,
},
"ChoiceList": {
Firmware.STAX: {
# Up to 6 (5?) choice in a list
Expand Down
11 changes: 11 additions & 0 deletions src/ragger/firmware/touch/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from ragger.backend import BackendInterface
from ragger.firmware import Firmware

from .element import Center

from .layouts import CancelFooter, CenteredFooter, ChoiceList, ExitFooter, ExitHeader, \
FullKeyboardLetters, FullKeyboardSpecialCharacters1, FullKeyboardSpecialCharacters2, \
InfoFooter, InfoHeader, LeftHeader, LetterOnlyKeyboard, NavigationHeader, RightHeader, \
Expand All @@ -26,6 +28,7 @@
from .use_cases import UseCaseHome, UseCaseSettings, UseCaseSubSettings, UseCaseChoice, \
UseCaseStatus, UseCaseReview, UseCaseViewDetails, UseCaseAddressConfirmation

ELEMENT_PREFIX = "element_"
LAYOUT_PREFIX = "layout_"
USE_CASE_PREFIX = "use_case_"

Expand Down Expand Up @@ -78,6 +81,10 @@ class Screen(metaclass=MetaScreen):
"""

def __new__(cls, name: str, parents: Tuple, namespace: Dict):
elements = {
key.split(ELEMENT_PREFIX)[1]: namespace.pop(key)
for key in list(namespace.keys()) if key.startswith(ELEMENT_PREFIX)
}
layouts = {
key.split(LAYOUT_PREFIX)[1]: namespace.pop(key)
for key in list(namespace.keys()) if key.startswith(LAYOUT_PREFIX)
Expand All @@ -89,6 +96,8 @@ def __new__(cls, name: str, parents: Tuple, namespace: Dict):
original_init = namespace.pop("__init__", lambda *args, **kwargs: None)

def init(self, client: BackendInterface, firmware: Firmware, *args, **kwargs):
for attribute, cls in elements.items():
setattr(self, attribute, cls(client, firmware))
for attribute, cls in layouts.items():
setattr(self, attribute, cls(client, firmware))
for attribute, cls in use_cases.items():
Expand All @@ -111,6 +120,7 @@ def __init__(self, backend: BackendInterface, firmware: Firmware):
pass

# Type declaration to please mypy checks
center: Center
right_header: RightHeader
exit_header: ExitHeader
info_header: InfoHeader
Expand All @@ -137,6 +147,7 @@ def __init__(self, backend: BackendInterface, firmware: Firmware):
view_details: UseCaseViewDetails
address_confirmation: UseCaseAddressConfirmation

element_center = Center
# possible headers
layout_right_header = RightHeader
layout_exit_header = ExitHeader
Expand Down
4 changes: 4 additions & 0 deletions src/ragger/navigator/instruction.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ class NavInsID(BaseNavInsID):

# Navigation instructions for Stax devices
TOUCH = auto()
SWIPE = auto()
SWIPE_CENTER_TO_LEFT = auto()
SWIPE_CENTER_TO_RIGHT = auto()

# possible headers
RIGHT_HEADER_TAP = auto()
EXIT_HEADER_TAP = auto()
Expand Down
25 changes: 15 additions & 10 deletions src/ragger/navigator/navigation_scenario.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from pathlib import Path
from typing import Optional, List
from typing import Optional, Sequence
from enum import Enum, auto

from .navigator import NavInsID
from ragger.firmware import Firmware
from .navigator import InstructionType, Navigator, NavInsID


class UseCase(Enum):
Expand All @@ -12,26 +13,29 @@ class UseCase(Enum):

class NavigationScenarioData:
navigation: NavInsID
validation: List[NavInsID]
validation: Sequence[InstructionType]
pattern: str

def __init__(self, device: str, use_case: UseCase, approve: bool):
if device.startswith("nano"):
def __init__(self, device: Firmware, use_case: UseCase, approve: bool):
if device.is_nano:
self.navigation = NavInsID.RIGHT_CLICK
self.validation = [NavInsID.BOTH_CLICK]
self.pattern = "^Approve$" if approve else "^Reject$"

elif device.startswith("stax") or device.startswith("flex"):
if use_case == UseCase.ADDRESS_CONFIRMATION:
elif device in [Firmware.STAX, Firmware.FLEX]:
if device == Firmware.STAX:
self.navigation = NavInsID.USE_CASE_REVIEW_TAP
else:
self.navigation = NavInsID.SWIPE_CENTER_TO_LEFT

if use_case == UseCase.ADDRESS_CONFIRMATION:
if approve:
self.validation = [NavInsID.USE_CASE_ADDRESS_CONFIRMATION_CONFIRM]
else:
self.validation = [NavInsID.USE_CASE_ADDRESS_CONFIRMATION_CANCEL]
self.pattern = "^Confirm$"

elif use_case == UseCase.TX_REVIEW:
self.navigation = NavInsID.USE_CASE_REVIEW_TAP
if approve:
self.validation = [NavInsID.USE_CASE_REVIEW_CONFIRM]
else:
Expand All @@ -50,9 +54,10 @@ def __init__(self, device: str, use_case: UseCase, approve: bool):
raise NotImplementedError("Unknown device")


class NavigateWithScenario():
class NavigateWithScenario:

def __init__(self, navigator, device, test_name, screenshot_path):
def __init__(self, navigator: Navigator, device: Firmware, test_name: str,
screenshot_path: Path):
self.navigator = navigator
self.device = device
self.test_name = test_name
Expand Down
Loading

0 comments on commit eef2bea

Please sign in to comment.