Class-ify the viSer suite deployment methods

Clean-up any previous deployment before deploying the suite again.
Previous reports will be lost.

Issue: INFRA-236
Change-Id: I246af3c41140c9a936d0b5b561903cab34003be0
diff --git a/deploy.py b/deploy.py
index 57a9c7b..a24ca86 100755
--- a/deploy.py
+++ b/deploy.py
@@ -1,25 +1,19 @@
 #!/usr/bin/env python3
 
+import dataclasses
 import logging
 import pathlib
 import re
 import subprocess
 import sys
 import time
+from typing import Sequence
 
 import uiautomator
 
-VWS_CREDENTIALS = {"user": "fairphonetesting@gmail.com", "password": "aish3echi:uwaiSh"}
-
-
-PREBUILTS_PATH = "../../vendor/smartviser/viser/prebuilts/apk"
-
-PREBUILT_PROXY_APK_PATTERN = "com.lunarlabs.panda.proxy-latest-sdk{sdk}-{flavour}.apk"
-
-
 _LOG = logging.getLogger(__name__)
 
-PREBUILT_APKS = ["com.smartviser.demogame-latest.apk", "com.lunarlabs.panda-latest.apk"]
+VWS_CREDENTIALS = {"user": "fairphonetesting@gmail.com", "password": "aish3echi:uwaiSh"}
 
 ADB_DEVICES_PATTERN = re.compile(r"^([a-z0-9-]+)\s+device$", flags=re.M)
 
@@ -152,38 +146,6 @@
         disable_privacy_impact_checkbox.click()
 
 
-def get_proxy_apk(android_sdk, flavour):
-    if android_sdk >= 24:
-        return PREBUILT_PROXY_APK_PATTERN.format(sdk=24, flavour=flavour)
-    else:
-        return PREBUILT_PROXY_APK_PATTERN.format(sdk=19, flavour=flavour)
-
-
-# Prepare the DUT
-def prepare_dut(device, scenarios_dir, data_dir, prebuilts_dir):
-    flavour = "gms" if device.is_gms_device() else "sibon"
-    proxy_apk = get_proxy_apk(device.sdk, flavour)
-    prebuilts_dir = pathlib.Path(prebuilts_dir)
-
-    # Uninstall the smartviser apps
-    for app in PREBUILT_APKS + [proxy_apk]:
-        print("Uninstalling `{}`…".format(app))
-        device.uninstall(prebuilts_dir / app)
-
-    # Copy the scenarios
-    print("Pushing scenarios from `{}`…".format(scenarios_dir))
-    device.push(scenarios_dir, pathlib.Path("/sdcard/Viser"))
-
-    # Copy the scenarios data
-    print("Pushing scenarios data from `{}`…".format(data_dir))
-    device.push(data_dir, pathlib.Path("/sdcard/Viser/Data"))
-
-    # Install the smartviser apps (starting with the proxy app)
-    for app in [proxy_apk] + PREBUILT_APKS:
-        print("Installing `{}`…".format(app))
-        device.install(prebuilts_dir / app)
-
-
 # Grant the permissions through the UI
 def configure_perms(device):
     # Input the credentials
@@ -231,6 +193,8 @@
 
     serial: str
     """The device serial number (adb/fastboot)."""
+    os_flavour: str
+    """The Fairphone-specific OS flavour."""
     sdk: int
     """The Android SDK version number."""
     ui: uiautomator.Device
@@ -242,6 +206,8 @@
 
         # Cache the Android SDK version
         self.sdk = self.ui.info["sdkInt"]
+        # Cache the OS flavour
+        self.os_flavour = "gms" if self.is_gms_device() else "sibon"
 
     def adb(self, *args) -> subprocess.CompletedProcess:
         """Execute an adb command on this device.
@@ -322,6 +288,13 @@
 
         self.adb("uninstall", package)
 
+    def uninstall_package(self, package_name: str) -> None:
+        """Uninstall an app from its package name.
+
+        :raise DeviceCommandError: If the uninstall command failed.
+        """
+        self.adb("uninstall", package_name)
+
     def install(self, apk: pathlib.Path) -> None:
         """Install an app from an APK file.
 
@@ -367,8 +340,155 @@
         self.adb(*command)
 
 
+@dataclasses.dataclass(frozen=True)
+class AndroidApp:
+    """An installable Android app."""
+
+    package: str
+    """The app package name."""
+    apk: str
+    """The app package (APK) filename."""
+
+    def __str__(self) -> str:
+        return self.package
+
+
+@dataclasses.dataclass(frozen=True)
+class ViserProxyApp:
+    """A template for the viSer Proxy app."""
+
+    package: str
+    """The app package name."""
+    apk_filename_template: str
+    """The string template of the APK filename, in the `str.format()` style (`{}`).
+
+    The following place-holders can be used and will be replaced at runtime:
+    - `{sdk}`: The Android SDK number, e.g. `25` for Android 7.1.
+    - `{flavour}`: The Fairphone-specific Android build flavour, e.g. `gms`
+      (Fairphone OS) or `sibon` (Fairphone Open).
+    """
+
+    def resolve(self, *, sdk: int, flavour: str) -> AndroidApp:
+        """Resolve the app template into a Viser App."""
+        if sdk >= 24:
+            sdk = 24
+        else:
+            sdk = 19
+        return AndroidApp(
+            self.package, self.apk_filename_template.format(sdk=sdk, flavour=flavour)
+        )
+
+
+@dataclasses.dataclass(frozen=True)
+class ViserSuite:
+    """A SmartViser viSer app suite.
+
+    Example:
+
+    >>> device = DeviceUnderTest(...)
+    >>> suite = ViserSuite(...)
+    >>> suite.deploy(device)
+
+    """
+
+    VISER_PROXY_APP_TEMPLATE = ViserProxyApp(
+        "com.lunarlabs.panda.proxy",
+        "com.lunarlabs.panda.proxy-latest-sdk{sdk}-{flavour}.apk",
+    )
+    ANDROID_APPS = [
+        AndroidApp("com.smartviser.demogame", "com.smartviser.demogame-latest.apk"),
+        AndroidApp("com.lunarlabs.panda", "com.lunarlabs.panda-latest.apk"),
+    ]
+
+    prebuilts_path: pathlib.Path
+    scenarios_path: pathlib.Path
+    scenarios_data_path: pathlib.Path
+    target_path: pathlib.Path = pathlib.Path("/sdcard/Viser")
+
+    @property
+    def target_scenarios_path(self) -> pathlib.Path:
+        return self.target_path
+
+    @property
+    def target_scenarios_data_path(self) -> pathlib.Path:
+        return self.target_path / "Data"
+
+    def resolve_apps(self, device: DeviceUnderTest) -> Sequence[AndroidApp]:
+        """Resolve the apps based on the target device properties.
+
+        :param device: The device to target.
+        :returns: The sequence of apps suitable for the target device.
+            The sequence is ordered to satisfy the dependency graph
+            (i.e. required apps come first in the sequence).
+        """
+        return [
+            self.VISER_PROXY_APP_TEMPLATE.resolve(
+                sdk=device.sdk, flavour=device.os_flavour
+            )
+        ] + self.ANDROID_APPS
+
+    def deploy(self, device: DeviceUnderTest) -> None:
+        """Deploy the suite on a device.
+
+        Copy the test scenarios and their data, and install the
+        different apps composing the suite.
+
+        The previous configuration and data (i.e. reports) tied to the
+        app suite, if any, is deleted before hand.
+
+        :param device: The device to deploy the suite to.
+        """
+        self.cleanup_previous_deployment(device)
+        self.copy_scenarios(device)
+        self.install_suite(device)
+
+    def cleanup_previous_deployment(self, device: DeviceUnderTest) -> None:
+        """Clean-up a previous deployment of the suite on a device.
+
+        :param device: The device to clean-up.
+        """
+        # Uninstall the apps in the reverse order to cater for dependencies.
+        for app in reversed(self.resolve_apps(device)):
+            _LOG.info("Uninstall %s", app)
+            device.uninstall_package(app.package)
+
+        _LOG.info("Delete data from previous deployment")
+        device.remove(self.target_path, recurse=True)
+
+    def copy_scenarios(self, device: DeviceUnderTest) -> None:
+        """Copy the suite scenarios and their data on a device.
+
+        :param device: The device to copy the scenarios to.
+        """
+        _LOG.info(
+            "Copy scenarios: %s → %s", self.scenarios_path, self.target_scenarios_path
+        )
+        device.push(self.scenarios_path, self.target_scenarios_path)
+
+        _LOG.info(
+            "Copy scenarios data: %s → %s",
+            self.scenarios_data_path,
+            self.target_scenarios_data_path,
+        )
+        device.push(self.scenarios_path, self.target_scenarios_data_path)
+
+    def install_suite(self, device: DeviceUnderTest) -> None:
+        """Install the suite apps on a device.
+
+        :param device: The device to install the suite apps on.
+        """
+        for app in self.resolve_apps(device):
+            _LOG.info("Install %s", app)
+            device.install(self.prebuilts_path / app.apk)
+
+
 def deploy():
     serials = []
+    suite = ViserSuite(
+        pathlib.Path("../../vendor/smartviser/viser/prebuilts/apk"),
+        pathlib.Path("../scenarios"),
+        pathlib.Path("../scenarios-data"),
+    )
 
     if len(sys.argv) > 1:
         serials.append(sys.argv[1])
@@ -389,12 +509,7 @@
                 disable_privacy_impact_popup(device)
 
             # Push the scenarios, their data, and install the apps
-            prepare_dut(
-                device,
-                pathlib.Path("../scenarios"),
-                pathlib.Path("../scenarios-data"),
-                PREBUILTS_PATH,
-            )
+            suite.deploy(device)
 
             # Start the viser app
             device.adb(