blob: b9b3ed03c271bdc5ee499a4a3a1b35ba4134b1cb [file] [log] [blame]
Borjan Tchakaloffa4bdae12017-11-21 14:58:12 +01001#!/usr/bin/env python3
2
Borjan Tchakaloff677090b2020-02-19 09:48:07 +01003import abc
Borjan Tchakaloff7283b552020-02-07 16:40:47 +01004import contextlib
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +01005import dataclasses
Borjan Tchakaloff31c37e02020-02-07 14:03:27 +01006import logging
Borjan Tchakaloffa4bdae12017-11-21 14:58:12 +01007import pathlib
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +01008import re
9import subprocess
10import sys
11import time
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +010012from typing import Sequence
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +010013
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +010014import uiautomator
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +010015
Borjan Tchakaloff31c37e02020-02-07 14:03:27 +010016_LOG = logging.getLogger(__name__)
17
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +010018ADB_DEVICES_PATTERN = re.compile(r"^([a-z0-9-]+)\s+device$", flags=re.M)
Franz-Xaver Geigerb0f55422018-04-17 10:33:37 +020019
Borjan Tchakaloff677090b2020-02-19 09:48:07 +010020ANDROID_6_SDK = 23
21ANDROID_7_SDK = 24
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +010022
Borjan Tchakaloff677090b2020-02-19 09:48:07 +010023
24class BaseError(Exception, abc.ABC):
25 """An abstract error."""
26
27
28class HostCommandError(BaseError):
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +020029 """An error happened while issuing a command on the host."""
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +010030
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +020031 def __init__(self, command, error_message):
32 self.command = command
33 self.error_message = error_message
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +010034 message = "Command `{}` failed: {}".format(command, error_message)
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +020035 super(HostCommandError, self).__init__(message)
36
37
Borjan Tchakaloff677090b2020-02-19 09:48:07 +010038class DeviceCommandError(BaseError):
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +020039 """An error happened while sending a command to a device."""
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +010040
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +020041 def __init__(self, serial, command, error_message):
42 self.serial = serial
43 self.command = command
44 self.error_message = error_message
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +010045 message = "Command `{}` failed on {}: {}".format(command, serial, error_message)
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +020046 super(DeviceCommandError, self).__init__(message)
47
48
Borjan Tchakaloff7283b552020-02-07 16:40:47 +010049class InvalidIntentError(DeviceCommandError):
50 """An intent was rejected by a device."""
51
52
Borjan Tchakaloff677090b2020-02-19 09:48:07 +010053class UnlicensedDeviceError(BaseError):
54 """A device is missing a license."""
55
56 def __init__(self, device, provider):
57 self.device = device
58 super().__init__(f"{device} is missing a license for {provider}")
59
60
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +010061def adb(*args, serial=None, raise_on_error=True):
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +010062 """Run ADB command attached to serial.
63
64 Example:
65 >>> process = adb('shell', 'getprop', 'ro.build.fingerprint', serial='cc60c021')
66 >>> process.returncode
67 0
68 >>> process.stdout.strip()
69 'Fairphone/FP2/FP2:6.0.1/FP2-gms-18.02.0/FP2-gms-18.02.0:user/release-keys'
70
71 :param *args:
72 List of options to ADB (including command).
73 :param str serial:
74 Identifier for ADB connection to device.
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +020075 :param raise_on_error bool:
76 Whether to raise a DeviceCommandError exception if the return code is
77 less than 0.
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +010078 :returns subprocess.CompletedProcess:
79 Completed process.
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +020080 :raises DeviceCommandError:
81 If the command failed.
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +010082 """
83
84 # Make sure the adb server is started to avoid the infamous "out of date"
85 # message that pollutes stdout.
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +020086 ret = subprocess.run(
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +010087 ["adb", "start-server"],
88 stdout=subprocess.PIPE,
89 stderr=subprocess.PIPE,
90 universal_newlines=True,
91 )
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +020092 if ret.returncode < 0:
93 if raise_on_error:
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +010094 raise DeviceCommandError(serial if serial else "??", str(args), ret.stderr)
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +020095 else:
96 return None
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +010097
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +010098 command = ["adb"]
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +010099 if serial:
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +0100100 command += ["-s", serial]
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +0100101 if args:
102 command += list(args)
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200103 ret = subprocess.run(
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +0100104 command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True
105 )
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +0100106
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200107 if raise_on_error and ret.returncode < 0:
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +0100108 raise DeviceCommandError(serial if serial else "??", str(args), ret.stderr)
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +0100109
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200110 return ret
111
112
Franz-Xaver Geigerb0f55422018-04-17 10:33:37 +0200113def list_devices():
114 """List serial numbers of devices attached to adb.
115
116 Raises:
117 DeviceCommandError: If the underlying adb command failed.
118 """
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +0100119 process = adb("devices")
Franz-Xaver Geigerb0f55422018-04-17 10:33:37 +0200120 return ADB_DEVICES_PATTERN.findall(process.stdout)
121
122
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +0100123def aapt(*args, raise_on_error=True):
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +0100124 """Run an AAPT command.
125
126 :param *args:
127 The AAPT command with its options.
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200128 :param raise_on_error bool:
129 Whether to raise a DeviceCommandError exception if the return code is
130 less than 0.
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +0100131 :returns subprocess.CompletedProcess:
132 Completed process.
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200133 :raises HostCommandError:
134 If the command failed.
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +0100135 """
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +0100136 command = ["aapt"]
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +0100137 if args:
138 command += list(args)
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200139 ret = subprocess.run(
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +0100140 command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True
141 )
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +0100142
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200143 if raise_on_error and ret.returncode < 0:
144 raise HostCommandError(str(args), ret.stderr)
145
146 return ret
147
Franz-Xaver Geigerb28348e2018-02-19 14:41:40 +0100148
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100149def disable_privacy_impact_popup(device):
Karsten Tauscheb36529f2019-03-02 14:13:29 +0100150 """Disable Privacy Impact popup on Android 5.
151
152 This simplifies UI automation. Disabling the feature globally is more robust
153 than clicking through the the Privacy Impact screen per app.
154 """
Borjan Tchakaloff7283b552020-02-07 16:40:47 +0100155 _LOG.info("Disable the Privacy Impact screen")
156 with device.launch("com.fairphone.privacyimpact/.PrivacyImpactPreferenceActivity"):
157 disable_privacy_impact_checkbox = device.ui(className="android.widget.CheckBox")
158 if not disable_privacy_impact_checkbox.checked:
159 disable_privacy_impact_checkbox.click()
Karsten Tauscheb36529f2019-03-02 14:13:29 +0100160
161
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100162class DeviceUnderTest:
163 """An Android device under test."""
164
165 serial: str
166 """The device serial number (adb/fastboot)."""
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100167 os_flavour: str
168 """The Fairphone-specific OS flavour."""
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100169 sdk: int
170 """The Android SDK version number."""
171 ui: uiautomator.Device
172 """The UI Automator handle piloting the device."""
173
174 def __init__(self, serial: str):
175 self.serial = serial
176 self.ui = uiautomator.Device(serial)
177
178 # Cache the Android SDK version
179 self.sdk = self.ui.info["sdkInt"]
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100180 # Cache the OS flavour
181 self.os_flavour = "gms" if self.is_gms_device() else "sibon"
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100182
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100183 def __str__(self) -> str:
184 return f"{DeviceUnderTest.__name__}({self.serial})"
185
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100186 def adb(self, *args) -> subprocess.CompletedProcess:
187 """Execute an adb command on this device.
188
189 :returns: The completed process.
190 :raise DeviceCommandError: If the underlying adb command failed.
191 """
192 return adb(*args, serial=self.serial)
193
194 def force_awake(self, always=True) -> None:
195 """Force the device to stay awake.
196
197 :raise DeviceCommandError: If the underlying adb command failed.
198 """
199 self.adb("shell", "svc power stayon {}".format("true" if always else "false"))
200
201 def unlock(self) -> None:
202 """Wake-up the device and unlock it.
203
204 :raise DeviceCommandError: If the underlying adb commands failed.
205 """
206 if not self.ui.info["screenOn"]:
207 self.adb("shell", "input keyevent KEYCODE_POWER")
208 time.sleep(1)
209 # The KEYCODE_MENU input is enough to unlock a "swipe up to unlock"
210 # lockscreen on Android 6, but unfortunately not Android 7. So we use a
211 # swipe up (that depends on the screen resolution) instead.
212 self.adb("shell", "input touchscreen swipe 930 880 930 380")
213 time.sleep(1)
214 self.adb("shell", "input keyevent KEYCODE_HOME")
215
216 def getprop(self, key: str) -> str:
217 """Get a system property.
218
219 Example:
220 >>> self.getprop('ro.build.id')
221 'FP2-gms-18.02.0'
222
223 :param key: Key of property to get.
224 :returns: Value of system property.
225 :raise DeviceCommandError: If the underlying adb command failed.
226 """
227 process = self.adb("shell", "getprop", key)
228 return process.stdout.strip()
229
230 def is_gms_device(self) -> bool:
231 """Whether the device runs GMS or sibon.
232
233 Example:
234 >>> self.is_gms_device()
235 True
236
237 :returns: True if device runs GMS, false otherwise.
238 :raise DeviceCommandError: If the underlying adb command failed.
239 """
240 return self.getprop("ro.build.id").startswith("FP2-gms-") or self.getprop(
241 "ro.build.version.incremental"
242 ).startswith("gms-")
243
244 def uninstall(self, apk: pathlib.Path) -> None:
245 """Uninstall an app from an APK file.
246
247 :raise ValueError: If the package name could not be read from the apk.
248 :raise DeviceCommandError: If the uninstall command failed.
249 """
250 ret = aapt("dump", "badging", str(apk))
251 package = None
252 for line in ret.stdout.splitlines():
253 if line.startswith("package"):
254 for token in line.split(" "):
255 if token.startswith("name="):
256 # Extract the package name out of the token
257 # (name='some.package.name')
258 package = token[6:-1]
259 break
260 if not package:
261 raise ValueError("Could not find package of app `{}`".format(apk.name))
262
263 self.adb("uninstall", package)
264
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100265 def uninstall_package(self, package_name: str) -> None:
266 """Uninstall an app from its package name.
267
268 :raise DeviceCommandError: If the uninstall command failed.
269 """
270 self.adb("uninstall", package_name)
271
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100272 def install(self, apk: pathlib.Path) -> None:
273 """Install an app from an APK file.
274
275 :raise DeviceCommandError: If the install command failed.
276 """
277 command = ["install", "-r"]
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100278 if self.sdk >= ANDROID_6_SDK:
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100279 # From Marshmallow onwards, adb has a flag to grant default permissions
280 command.append("-g")
281
282 self.adb(*command, str(apk))
Borjan Tchakaloffa4bdae12017-11-21 14:58:12 +0100283
Borjan Tchakaloffaafa2262020-02-07 11:53:07 +0100284 def push(self, source: pathlib.Path, target: pathlib.Path) -> None:
285 """Push a file or directory to the device.
286
287 The target directory (if the source is a directory), or its
288 parent (if the source if a file) will be created on the device,
289 always.
290
291 :raise DeviceCommandError: If the push command failed.
292 """
293 if source.is_dir():
294 # `adb push` skips empty directories so we have to manually create the
295 # target directory itself.
296 self.adb("shell", "mkdir", "-p", str(target))
297 # `adb push` expects the target to be the host directory not the target
298 # directory itself. So we'll just copy the source directory content instead.
299 sources = [str(child) for child in source.iterdir()]
300 self.adb("push", *sources, str(target))
301 else:
302 self.adb("shell", "mkdir", "-p", str(target.parent))
303 self.adb("push", str(source), str(target))
304
305 def remove(self, target: pathlib.Path, *, recurse: bool = False) -> None:
306 """Remove a file or directory from the device.
307
308 :raise DeviceCommandError: If the remove command failed.
309 """
310 command = ["shell", "rm", "-f"]
311 if recurse:
312 command.append("-r")
313 command.append(str(target))
314 self.adb(*command)
315
Borjan Tchakaloff7283b552020-02-07 16:40:47 +0100316 @contextlib.contextmanager
317 def launch(self, package_or_activity: str):
318 """Launch an app and eventually goes back to the home screen.
319
320 There are two ways to start an application:
321
322 1. Start the main activity (advertised in the launcher), only
323 the package name is needed (e.g. `com.android.settings`);
324 2. Start a specific activity, the qualified activity name is
325 needed (e.g. `com.android.settings/.Settings`).
326
327 Example:
328
329 >>> device = DeviceUnderTest(...)
330 >>> with device.launch("com.android.settings"):
331 ... device.ui(text="Bluetooth").exists
332 True
333 >>> device.ui(text="Bluetooth").exists
334 False
335
336 :param package_or_activity: The package name or the qualified
337 activity name.
338 :raise DeviceCommandError: If the app manager command failed.
339 :raise InvalidIntentError: If the package or activity could not
340 be resolved.
341 """
342 if "/" in package_or_activity:
343 [package, activity] = package_or_activity.split("/", maxsplit=1)
344 else:
345 package, activity = package_or_activity, ""
346
347 if activity:
348 command = [
349 "shell",
350 "am",
351 "start",
352 "-a",
353 "android.intent.action.MAIN",
354 package_or_activity,
355 ]
356 else:
357 # Use the monkey to avoid specifying the exact activity name.
358 command = [
359 "shell",
360 "monkey",
361 "-p",
362 package,
363 "-c",
364 "android.intent.category.LAUNCHER",
365 "1",
366 ]
367
368 try:
369 process = self.adb(*command)
370 if process.stderr:
371 raise InvalidIntentError(self.serial, " ".join(command), process.stderr)
372 yield
373 finally:
374 self.ui.press.home()
375
Borjan Tchakaloffa4bdae12017-11-21 14:58:12 +0100376
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100377@dataclasses.dataclass(frozen=True)
378class AndroidApp:
379 """An installable Android app."""
380
381 package: str
382 """The app package name."""
383 apk: str
384 """The app package (APK) filename."""
385
386 def __str__(self) -> str:
387 return self.package
388
389
390@dataclasses.dataclass(frozen=True)
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100391class Credentials:
392 """Credentials to access a service."""
393
394 username: str
395 password: str
396
397
398@dataclasses.dataclass(frozen=True)
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100399class ViserSuite:
400 """A SmartViser viSer app suite.
401
402 Example:
403
404 >>> device = DeviceUnderTest(...)
405 >>> suite = ViserSuite(...)
406 >>> suite.deploy(device)
407
408 """
409
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100410 ANDROID_APPS = [
411 AndroidApp("com.smartviser.demogame", "com.smartviser.demogame-latest.apk"),
412 AndroidApp("com.lunarlabs.panda", "com.lunarlabs.panda-latest.apk"),
413 ]
414
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100415 credentials: Credentials
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100416 prebuilts_path: pathlib.Path
417 scenarios_path: pathlib.Path
418 scenarios_data_path: pathlib.Path
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100419 settings_file: pathlib.Path
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100420 target_path: pathlib.Path = pathlib.Path("/sdcard/Viser")
421
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100422 def __str__(self) -> str:
423 return "SmartViser viSer app suite"
424
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100425 @property
426 def target_scenarios_path(self) -> pathlib.Path:
427 return self.target_path
428
429 @property
430 def target_scenarios_data_path(self) -> pathlib.Path:
431 return self.target_path / "Data"
432
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100433 @property
434 def target_settings_file(self) -> pathlib.Path:
435 return self.target_path / "Settings" / self.settings_file.name
436
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100437 def resolve_apps(self, device: DeviceUnderTest) -> Sequence[AndroidApp]:
438 """Resolve the apps based on the target device properties.
439
440 :param device: The device to target.
441 :returns: The sequence of apps suitable for the target device.
442 The sequence is ordered to satisfy the dependency graph
443 (i.e. required apps come first in the sequence).
444 """
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100445 return self.ANDROID_APPS
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100446
447 def deploy(self, device: DeviceUnderTest) -> None:
448 """Deploy the suite on a device.
449
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100450 Copy the test scenarios and their data, install the
451 different apps composing the suite, and configure the suite.
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100452
453 The previous configuration and data (i.e. reports) tied to the
454 app suite, if any, is deleted before hand.
455
456 :param device: The device to deploy the suite to.
457 """
458 self.cleanup_previous_deployment(device)
459 self.copy_scenarios(device)
460 self.install_suite(device)
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100461 self.configure_suite(device)
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100462
463 def cleanup_previous_deployment(self, device: DeviceUnderTest) -> None:
464 """Clean-up a previous deployment of the suite on a device.
465
466 :param device: The device to clean-up.
467 """
468 # Uninstall the apps in the reverse order to cater for dependencies.
469 for app in reversed(self.resolve_apps(device)):
470 _LOG.info("Uninstall %s", app)
471 device.uninstall_package(app.package)
472
473 _LOG.info("Delete data from previous deployment")
474 device.remove(self.target_path, recurse=True)
475
476 def copy_scenarios(self, device: DeviceUnderTest) -> None:
477 """Copy the suite scenarios and their data on a device.
478
479 :param device: The device to copy the scenarios to.
480 """
481 _LOG.info(
482 "Copy scenarios: %s → %s", self.scenarios_path, self.target_scenarios_path
483 )
484 device.push(self.scenarios_path, self.target_scenarios_path)
485
486 _LOG.info(
487 "Copy scenarios data: %s → %s",
488 self.scenarios_data_path,
489 self.target_scenarios_data_path,
490 )
Borjan Tchakaloff4ba9e032020-02-19 12:09:37 +0100491 device.push(self.scenarios_data_path, self.target_scenarios_data_path)
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100492
493 def install_suite(self, device: DeviceUnderTest) -> None:
494 """Install the suite apps on a device.
495
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100496 Input the credentials in the viSer app to authenticate the
497 device.
498
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100499 :param device: The device to install the suite apps on.
500 """
501 for app in self.resolve_apps(device):
502 _LOG.info("Install %s", app)
503 device.install(self.prebuilts_path / app.apk)
504
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100505 with device.launch("com.lunarlabs.panda"):
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100506 _LOG.info("Initiate first run of viSer")
507
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100508 # Input the credentials
509 device.ui(resourceId="android:id/content").child(text="Username").child(
510 className="android.widget.EditText"
511 ).set_text(self.credentials.username)
512 device.ui(resourceId="android:id/content").child(text="Password").child(
513 className="android.widget.EditText"
514 ).set_text(self.credentials.password)
515
516 # Sign in
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100517 signin_label = "SIGN IN" if device.sdk >= ANDROID_7_SDK else "Sign in"
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100518 device.ui(resourceId="android:id/content").child(
519 text=signin_label, className="android.widget.Button"
520 ).click()
521
Borjan Tchakaloff3b177a42020-02-19 11:05:38 +0100522 # Wait for log-in to complete
523 progress_bar = device.ui(resourceId="android:id/content").child(
524 className="android.widget.ProgressBar"
525 )
526 progress_bar.wait.gone(timeout=10000)
527
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100528 # Is the app licensed?
529 license_failed_info = device.ui(resourceId="android:id/content").child(
530 textContains="Licence verification failed"
531 )
532 if license_failed_info.exists:
533 raise UnlicensedDeviceError(device, self)
534
535 self._maybe_accept_viser_dialer(device)
536
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100537 def configure_suite(self, device: DeviceUnderTest) -> None:
538 """Configure the suite on a device.
539
540 :param device: The device to configure the suite on.
541 """
542 _LOG.info(
543 "Copy settings: %s → %s", self.settings_file, self.target_settings_file
544 )
545 device.push(self.settings_file, self.target_settings_file)
546
547 with device.launch("com.lunarlabs.panda"):
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100548 device.ui(description="Open navigation drawer").click()
549 device.ui(
550 text="Settings", className="android.widget.CheckedTextView"
551 ).click()
552 device.ui(className="android.support.v7.widget.RecyclerView").child_by_text(
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100553 "Settings management", className="android.widget.LinearLayout"
554 ).click()
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100555 device.ui(className="android.support.v7.widget.RecyclerView").child_by_text(
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100556 "Load settings", className="android.widget.LinearLayout"
557 ).click()
558 device.ui(resourceId="android:id/list").child_by_text(
559 self.settings_file.name, className="android.widget.TextView"
560 ).click()
561 device.ui(
562 textMatches="(?i)Select", className="android.widget.Button"
563 ).click()
564
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100565 self._maybe_accept_viser_dialer(device)
566
567 def _maybe_accept_viser_dialer(self, device: DeviceUnderTest) -> None:
568 """Accept the built-in viSer dialer as the default dialer, if necessary.
569
570 This method tries to accept the built-in viSer dialer from a
571 currently displayed modal. Two types of modals are handled, the
572 modal spawned by the viSer app itself, and the modal spawned by
573 the system UI.
574
575 This method is idempotent and will pass even if the modals are
576 missing.
577
578 :param device: The device to accept the modal on.
579 """
580 dialer_set = False
581
582 # Optional modal to set the the built-in dialer as default
583 set_dialer_modal = device.ui(resourceId="android:id/content").child(
584 textContains="Do you want to use viSer dialer as default ?"
585 )
586 if set_dialer_modal.exists:
587 device.ui(resourceId="android:id/content").child(
588 textMatches="(?i)Yes", className="android.widget.Button"
589 ).click()
590 dialer_set = True
591
592 # Optional system modal to really set the built-in dialer as default
593 set_dialer_system_modal = device.ui(resourceId="android:id/content").child(
594 text="Make viSer your default Phone app?"
595 )
596 if set_dialer_system_modal.exists:
597 device.ui(resourceId="android:id/content").child(
598 textMatches="(?i)Set default", className="android.widget.Button"
599 ).click()
600 dialer_set = True
601
602 # Optional system modal to change the default dialer
603 change_dialer_system_modal = device.ui(resourceId="android:id/content").child(
604 text="Change default Dialer app?"
605 )
606 if change_dialer_system_modal.exists:
607 device.ui(resourceId="android:id/content").child(
608 textMatches="(?i)OK", className="android.widget.Button"
609 ).click()
610 dialer_set = True
611
612 if dialer_set:
613 _LOG.info("Set the built-in viSer dialer as default dialer")
614
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100615
Franz-Xaver Geigerd1079e22018-02-19 14:34:15 +0100616def deploy():
617 serials = []
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100618 suite = ViserSuite(
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100619 Credentials("fairphonetesting@gmail.com", "aish3echi:uwaiSh"),
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100620 pathlib.Path("../../vendor/smartviser/viser/prebuilts/apk"),
621 pathlib.Path("../scenarios"),
622 pathlib.Path("../scenarios-data"),
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100623 pathlib.Path("settings/fairphone-bot_settings"),
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100624 )
Borjan Tchakaloffa4bdae12017-11-21 14:58:12 +0100625
Franz-Xaver Geigerd1079e22018-02-19 14:34:15 +0100626 if len(sys.argv) > 1:
627 serials.append(sys.argv[1])
628 else:
Franz-Xaver Geigerb0f55422018-04-17 10:33:37 +0200629 serials = list_devices()
Borjan Tchakaloffa4bdae12017-11-21 14:58:12 +0100630
Franz-Xaver Geigerd1079e22018-02-19 14:34:15 +0100631 for serial in serials:
Borjan Tchakaloff31c37e02020-02-07 14:03:27 +0100632 _LOG.info("Deploy to device %s", serial)
Borjan Tchakaloffa4bdae12017-11-21 14:58:12 +0100633
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100634 device = DeviceUnderTest(serial)
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200635 try:
636 # Make sure the screen stays on - we're going to use UI automation
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100637 device.force_awake()
638 device.unlock()
Borjan Tchakaloff64ec42b2018-02-07 11:33:15 -0800639
Karsten Tauscheb36529f2019-03-02 14:13:29 +0100640 # Disable Privacy Impact popup on Android 5.
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100641 if device.sdk < ANDROID_6_SDK:
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100642 disable_privacy_impact_popup(device)
Karsten Tauscheb36529f2019-03-02 14:13:29 +0100643
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200644 # Push the scenarios, their data, and install the apps
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100645 suite.deploy(device)
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100646 except (BaseError, uiautomator.JsonRPCError):
Borjan Tchakaloff31c37e02020-02-07 14:03:27 +0100647 _LOG.error("Failed to execute deployment", exc_info=True)
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200648 finally:
649 try:
650 # Leave the device alone now
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100651 device.force_awake(always=False)
Borjan Tchakaloff31c37e02020-02-07 14:03:27 +0100652 except DeviceCommandError:
653 _LOG.warning("Failed to tear down device", exc_info=True)
Borjan Tchakaloff64ec42b2018-02-07 11:33:15 -0800654
Franz-Xaver Geigerd1079e22018-02-19 14:34:15 +0100655
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +0100656if __name__ == "__main__":
Borjan Tchakaloff31c37e02020-02-07 14:03:27 +0100657 logging.basicConfig(
658 format="%(asctime)-15s:%(levelname)s:%(message)s", level=logging.INFO
659 )
Franz-Xaver Geigerd1079e22018-02-19 14:34:15 +0100660 deploy()