| #!/usr/bin/env python3 |
| |
| import os |
| import pathlib |
| import re |
| import subprocess |
| import sys |
| import time |
| |
| from uiautomator import Device |
| |
| 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" |
| |
| PREBUILT_APKS = ["com.smartviser.demogame-latest.apk", "com.lunarlabs.panda-latest.apk"] |
| |
| FAIRPHONE_WIFI_NETWORKS = { |
| "Fairphone Guest": {"security": "WPA/WPA2 PSK", "password": "fairwifi"}, |
| "Fairphone DEV (2.4 GHz)": {"security": "WPA/WPA2 PSK", "password": "fdev@adm"}, |
| "Fairphone DEV (5 GHz)": {"security": "WPA/WPA2 PSK", "password": "fdev@adm"}, |
| } |
| |
| ADB_DEVICES_PATTERN = re.compile(r"^([a-z0-9-]+)\s+device$", flags=re.M) |
| |
| |
| class HostCommandError(BaseException): |
| """An error happened while issuing a command on the host.""" |
| |
| def __init__(self, command, error_message): |
| self.command = command |
| self.error_message = error_message |
| message = "Command `{}` failed: {}".format(command, error_message) |
| super(HostCommandError, self).__init__(message) |
| |
| |
| class DeviceCommandError(BaseException): |
| """An error happened while sending a command to a device.""" |
| |
| def __init__(self, serial, command, error_message): |
| self.serial = serial |
| self.command = command |
| self.error_message = error_message |
| message = "Command `{}` failed on {}: {}".format(command, serial, error_message) |
| super(DeviceCommandError, self).__init__(message) |
| |
| |
| def adb(*args, serial=None, raise_on_error=True): |
| """Run ADB command attached to serial. |
| |
| Example: |
| >>> process = adb('shell', 'getprop', 'ro.build.fingerprint', serial='cc60c021') |
| >>> process.returncode |
| 0 |
| >>> process.stdout.strip() |
| 'Fairphone/FP2/FP2:6.0.1/FP2-gms-18.02.0/FP2-gms-18.02.0:user/release-keys' |
| |
| :param *args: |
| List of options to ADB (including command). |
| :param str serial: |
| Identifier for ADB connection to device. |
| :param raise_on_error bool: |
| Whether to raise a DeviceCommandError exception if the return code is |
| less than 0. |
| :returns subprocess.CompletedProcess: |
| Completed process. |
| :raises DeviceCommandError: |
| If the command failed. |
| """ |
| |
| # Make sure the adb server is started to avoid the infamous "out of date" |
| # message that pollutes stdout. |
| ret = subprocess.run( |
| ["adb", "start-server"], |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| universal_newlines=True, |
| ) |
| if ret.returncode < 0: |
| if raise_on_error: |
| raise DeviceCommandError(serial if serial else "??", str(args), ret.stderr) |
| else: |
| return None |
| |
| command = ["adb"] |
| if serial: |
| command += ["-s", serial] |
| if args: |
| command += list(args) |
| ret = subprocess.run( |
| command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True |
| ) |
| |
| if raise_on_error and ret.returncode < 0: |
| raise DeviceCommandError(serial if serial else "??", str(args), ret.stderr) |
| |
| return ret |
| |
| |
| def list_devices(): |
| """List serial numbers of devices attached to adb. |
| |
| Raises: |
| DeviceCommandError: If the underlying adb command failed. |
| """ |
| process = adb("devices") |
| return ADB_DEVICES_PATTERN.findall(process.stdout) |
| |
| |
| def aapt(*args, raise_on_error=True): |
| """Run an AAPT command. |
| |
| :param *args: |
| The AAPT command with its options. |
| :param raise_on_error bool: |
| Whether to raise a DeviceCommandError exception if the return code is |
| less than 0. |
| :returns subprocess.CompletedProcess: |
| Completed process. |
| :raises HostCommandError: |
| If the command failed. |
| """ |
| command = ["aapt"] |
| if args: |
| command += list(args) |
| ret = subprocess.run( |
| command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True |
| ) |
| |
| if raise_on_error and ret.returncode < 0: |
| raise HostCommandError(str(args), ret.stderr) |
| |
| return ret |
| |
| |
| def force_awake(serial, always=True): |
| """Force the device to stay awake. |
| |
| Raises: |
| DeviceCommandError: If the underlying adb command failed. |
| """ |
| adb( |
| "shell", |
| "svc power stayon {}".format("true" if always else "false"), |
| serial=serial, |
| ) |
| |
| |
| def unlock(dut): |
| """Wake-up the device and unlock it. |
| |
| Raises: |
| DeviceCommandError: If the underlying adb commands failed. |
| """ |
| if not dut.info["screenOn"]: |
| adb("shell", "input keyevent KEYCODE_POWER", serial=dut.serial) |
| time.sleep(1) |
| # The KEYCODE_MENU input is enough to unlock a "swipe up to unlock" |
| # lockscreen on Android 6, but unfortunately not Android 7. So we use a |
| # swipe up (that depends on the screen resolution) instead. |
| adb("shell", "input touchscreen swipe 930 880 930 380", serial=dut.serial) |
| time.sleep(1) |
| adb("shell", "input keyevent KEYCODE_HOME", serial=dut.serial) |
| |
| |
| def getprop(serial, key): |
| """Get system property of device. |
| |
| Example: |
| >>> getprop('167eb6e8', 'ro.build.id') |
| 'FP2-gms-18.02.0' |
| |
| :param str serial: |
| Identifier for ADB connection to device. |
| :param str key: |
| Key of property to get. |
| :returns str: |
| Value of system property. |
| :raise DeviceCommandError: If the underlying adb command failed. |
| """ |
| process = adb("shell", "getprop", key, serial=serial) |
| return process.stdout.strip() |
| |
| |
| def is_gms_device(serial): |
| """Test if device runs GMS or sibon. |
| |
| Example: |
| >>> is_gms_device('167eb6e8') |
| True |
| |
| :param str serial: |
| Identifier for ADB connection to device. |
| :returns bool: |
| True if device runs GMS, false otherwise. |
| :raise DeviceCommandError: If the underlying adb command failed. |
| """ |
| return getprop(serial, "ro.build.id").startswith("FP2-gms-") or getprop( |
| serial, "ro.build.version.incremental" |
| ).startswith("gms-") |
| |
| |
| def uninstall_apk(serial, filename, prebuilts_dir): |
| """Uninstall apk from prebuilts_dir on device. |
| |
| Raises: |
| ValueError: If the package name could not be read from the apk. |
| DeviceCommandError: If the uninstall command failed. |
| """ |
| ret = aapt("dump", "badging", "{}/{}".format(prebuilts_dir, filename)) |
| package = None |
| for line in ret.stdout.splitlines(): |
| if line.startswith("package"): |
| for token in line.split(" "): |
| if token.startswith("name="): |
| # Extract the package name out of the token |
| # (name='some.package.name') |
| package = token[6:-1] |
| break |
| if not package: |
| raise ValueError("Could not find package of app `{}`".format(filename)) |
| |
| adb("uninstall", package, serial=serial) |
| |
| |
| def install_apk(dut, filename, prebuilts_dir): |
| """Install apk from prebuilts_dir on device. |
| |
| Raises: |
| DeviceCommandError: If the install command failed. |
| """ |
| path = os.path.join(prebuilts_dir, filename) |
| command = ["install", "-r"] |
| if dut.sdk >= 23: |
| # From Marshmallow onwards, adb has a flag to grant default permissions |
| command.append("-g") |
| adb(*command, path, serial=dut.serial) |
| |
| |
| def configure_wifi_networks(dut, networks): |
| """Configure Wi-Fi networks. |
| |
| The `networks` parameters is a list of networks to configure hashed by |
| their SSID. Each network value should have the following format: |
| - security (str): The security value as can be found in the Wi-Fi |
| settings dialog. Common values are 'None' and 'WPA/WPA2 PSK'. |
| - password (str, optional): The network password if the security is |
| 'WPA/WPA2 PSK'. |
| |
| Parameters: |
| dut (Device): The device object. |
| networks (dict(dict(str))): The list of networks to configure. |
| Raises: |
| DeviceCommandError: If the UI automation fails. |
| """ |
| # Open the Wi-Fi settings |
| adb( |
| "shell", |
| "am start -a android.settings.WIFI_SETTINGS --activity-clear-task", |
| serial=dut.serial, |
| ) |
| |
| # Make sure Wi-Fi is enabled |
| wifi_enabler = dut(text="OFF", resourceId="com.android.settings:id/switch_widget") |
| if wifi_enabler.exists: |
| wifi_enabler.click() |
| |
| # Check for registered networks |
| registered_networks = set() |
| dut(description="More options").click.wait() |
| time.sleep(1) |
| saved_networks = dut(text="Saved networks") |
| if saved_networks.exists: |
| saved_networks.click.wait() |
| for ssid in networks.keys(): |
| if dut(text=ssid).exists: |
| registered_networks.add(ssid) |
| dut.press.back() |
| |
| missing_networks = networks.keys() - registered_networks |
| |
| for ssid in registered_networks: |
| print("Ignoring `{}` Wi-Fi network, already configured.".format(ssid)) |
| |
| for ssid in missing_networks: |
| print("Configuring `{}` Wi-Fi network…".format(ssid)) |
| |
| dut(description="More options").click.wait() |
| dut(text="Add network").click.wait() |
| dut(resourceId="com.android.settings:id/ssid").set_text(ssid) |
| dut(resourceId="com.android.settings:id/security").click() |
| dut(text=networks[ssid]["security"]).click() |
| password_field = dut(resourceId="com.android.settings:id/password") |
| time.sleep(1) |
| if "password" in networks[ssid] and networks[ssid]["password"]: |
| if not password_field.exists: |
| dut(text="Cancel").click() |
| raise DeviceCommandError(dut, "UI: add Wi-Fi", "missing password field") |
| password_field.set_text(networks[ssid]["password"]) |
| elif password_field.exists: |
| dut(text="Cancel").click() |
| raise DeviceCommandError(dut, "UI: add Wi-Fi", "missing password data") |
| save_button = dut(text="Save") |
| if not save_button.click(): |
| dut(text="Cancel").click() |
| raise DeviceCommandError(dut, "UI: add Wi-Fi", "could not save network") |
| |
| # Force the Wi-Fi on and off to pick the best network available |
| dut(text="ON", resourceId="com.android.settings:id/switch_widget").click() |
| dut(text="OFF", resourceId="com.android.settings:id/switch_widget").click() |
| |
| # Leave the settings |
| dut.press.back() |
| |
| |
| def disable_privacy_impact_popup(dut): |
| """Disable Privacy Impact popup on Android 5. |
| |
| This simplifies UI automation. Disabling the feature globally is more robust |
| than clicking through the the Privacy Impact screen per app. |
| """ |
| print("Disabling the Privacy Impact screen…") |
| adb( |
| "shell", |
| ( |
| "am start -a android.intent.action.MAIN " |
| "com.fairphone.privacyimpact/.PrivacyImpactPreferenceActivity" |
| ), |
| serial=dut.serial, |
| ) |
| disable_privacy_impact_checkbox = dut(className="android.widget.CheckBox") |
| if not disable_privacy_impact_checkbox.checked: |
| 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(dut, scenarios_dir, data_dir, prebuilts_dir): |
| flavour = "gms" if is_gms_device(dut.serial) else "sibon" |
| proxy_apk = get_proxy_apk(dut.sdk, flavour) |
| |
| # Uninstall the smartviser apps |
| for app in PREBUILT_APKS + [proxy_apk]: |
| print("Uninstalling `{}`…".format(app)) |
| uninstall_apk(dut.serial, app, prebuilts_dir) |
| |
| # Copy the scenarios |
| print("Pushing scenarios from `{}`…".format(scenarios_dir)) |
| adb("push", scenarios_dir, "/sdcard/viser", serial=dut.serial) |
| |
| # Copy the scenarios data |
| print("Pushing scenarios data from `{}`…".format(data_dir)) |
| adb("push", data_dir, "/sdcard/viser/data", serial=dut.serial) |
| |
| # Install the smartviser apps (starting with the proxy app) |
| for app in [proxy_apk] + PREBUILT_APKS: |
| print("Installing `{}`…".format(app)) |
| install_apk(dut, app, prebuilts_dir) |
| |
| |
| # Grant the permissions through the UI |
| def configure_perms(dut): |
| # Input the credentials |
| dut(resourceId="android:id/content").child(text="Username").child( |
| className="android.widget.EditText" |
| ).set_text(VWS_CREDENTIALS["user"]) |
| dut(resourceId="android:id/content").child(text="Password").child( |
| className="android.widget.EditText" |
| ).set_text(VWS_CREDENTIALS["password"]) |
| |
| # Sign in |
| signin_label = "SIGN IN" if dut.sdk >= 24 else "Sign in" |
| dut(resourceId="android:id/content").child( |
| text=signin_label, className="android.widget.Button" |
| ).click() |
| |
| |
| def configure_sms(dut): |
| # TODO wait for the connection to be established and time-out |
| prompt = dut(resourceId="android:id/content").child( |
| text="Viser must be your SMS app to send messages" |
| ) |
| while not prompt.exists: |
| time.sleep(1) |
| |
| # Make viser the default SMS app |
| dut(resourceId="android:id/content").child_by_text( |
| "Viser must be your SMS app to send messages", |
| className="android.widget.LinearLayout", |
| ).child(text="OK", className="android.widget.Button").click() |
| |
| dut(resourceId="android:id/content").child_by_text( |
| "Change SMS app?", className="android.widget.LinearLayout" |
| ).child(text="Yes", className="android.widget.Button").click() |
| |
| |
| def configure_settings(dut): |
| # Set the e-mail account |
| dut(text="Settings", className="android.widget.TextView").click() |
| dut(resourceId="android:id/list").child_by_text( |
| "User settings", className="android.widget.LinearLayout" |
| ).click() |
| dut(resourceId="android:id/list").child_by_text( |
| "Email account", className="android.widget.LinearLayout" |
| ).click() |
| prompt = dut(resourceId="android:id/content").child_by_text( |
| "Email account", className="android.widget.LinearLayout" |
| ) |
| prompt.child(resourceId="android:id/edit").set_text("fairphone.viser@gmail.com") |
| prompt.child(text="OK", className="android.widget.Button").click() |
| dut(resourceId="android:id/list").child_by_text( |
| "Email password", className="android.widget.LinearLayout" |
| ).click() |
| dut(text="Password :").child(className="android.widget.EditText").set_text( |
| "fairphoneviser2017" |
| ) |
| dut(description="OK", className="android.widget.TextView").click() |
| dut.press.back() |
| dut.press.back() |
| |
| |
| def deploy(): |
| serials = [] |
| |
| if len(sys.argv) > 1: |
| serials.append(sys.argv[1]) |
| else: |
| serials = list_devices() |
| |
| for serial in serials: |
| print("Configuring device {}…".format(serial)) |
| |
| dut = Device(serial) |
| # Work around the not-so-easy Device class |
| dut.serial = serial |
| # Cache the Android SDK version (dut.info fetches system properties) |
| dut.sdk = dut.info["sdkInt"] |
| |
| try: |
| # Make sure the screen stays on - we're going to use UI automation |
| force_awake(serial) |
| unlock(dut) |
| |
| # Configure common Fairphone Wi-Fi networks |
| if dut.sdk < 24: |
| configure_wifi_networks(dut, FAIRPHONE_WIFI_NETWORKS) |
| else: |
| print( |
| "Uh oh, the device is running Android SDK {} on which we " |
| "do not deploy Wi-Fi networks yet.".format(dut.sdk) |
| ) |
| |
| # Disable Privacy Impact popup on Android 5. |
| if dut.sdk <= 22: |
| disable_privacy_impact_popup(dut) |
| |
| # Push the scenarios, their data, and install the apps |
| prepare_dut(dut, "../scenarios", "../scenarios-data", PREBUILTS_PATH) |
| |
| # Start the viser app |
| adb( |
| "shell", |
| "monkey", |
| "-p", |
| "com.lunarlabs.panda", |
| "-c", |
| "android.intent.category.LAUNCHER", |
| "1", |
| serial=serial, |
| ) |
| |
| configure_perms(dut) |
| |
| # TODO DO NOT DO THE FOLLOWING IF NO SIM CARD IS IN THE DUT |
| # time.sleep(10) |
| # configure_sms(dut) |
| |
| configure_settings(dut) |
| except (HostCommandError, DeviceCommandError) as e: |
| print("ERROR {}".format(e), file=sys.stderr) |
| finally: |
| try: |
| # Leave the device alone now |
| force_awake(serial, always=False) |
| except DeviceCommandError as e: |
| print("WARNING {}".format(e), file=sys.stderr) |
| |
| |
| if __name__ == "__main__": |
| deploy() |