blob: bfb6ea3b1f20ff6ff0f54da399a3b159a55fdcdd [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
Borjan Tchakaloff3bb8b512020-02-19 15:49:53 +0100317 def launch(self, activity: str):
Borjan Tchakaloff7283b552020-02-07 16:40:47 +0100318 """Launch an app and eventually goes back to the home screen.
319
Borjan Tchakaloff7283b552020-02-07 16:40:47 +0100320 Example:
321
322 >>> device = DeviceUnderTest(...)
Borjan Tchakaloff3bb8b512020-02-19 15:49:53 +0100323 >>> with device.launch("com.android.settings/.Settings"):
Borjan Tchakaloff7283b552020-02-07 16:40:47 +0100324 ... device.ui(text="Bluetooth").exists
325 True
326 >>> device.ui(text="Bluetooth").exists
327 False
328
Borjan Tchakaloff3bb8b512020-02-19 15:49:53 +0100329 :param activity: The qualified activity name.
Borjan Tchakaloff7283b552020-02-07 16:40:47 +0100330 :raise DeviceCommandError: If the app manager command failed.
Borjan Tchakaloff3bb8b512020-02-19 15:49:53 +0100331 :raise InvalidIntentError: If the activity could not be
332 resolved.
Borjan Tchakaloff7283b552020-02-07 16:40:47 +0100333 """
Borjan Tchakaloff3bb8b512020-02-19 15:49:53 +0100334 command = ["shell", "am", "start", "-a", "android.intent.action.MAIN", activity]
Borjan Tchakaloff7283b552020-02-07 16:40:47 +0100335
336 try:
337 process = self.adb(*command)
Borjan Tchakaloff3bb8b512020-02-19 15:49:53 +0100338 # Ignore warnings as they should not impede the app launch. For instance,
339 # Android is verbose about apps not started *again*, because they are e.g.
340 # already active in the background or in the foreground, and that's fine.
341 if process.stderr and not process.stderr.startswith(
342 "Warning: Activity not started"
343 ):
Borjan Tchakaloff7283b552020-02-07 16:40:47 +0100344 raise InvalidIntentError(self.serial, " ".join(command), process.stderr)
345 yield
346 finally:
347 self.ui.press.home()
348
Borjan Tchakaloffa4bdae12017-11-21 14:58:12 +0100349
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100350@dataclasses.dataclass(frozen=True)
351class AndroidApp:
352 """An installable Android app."""
353
354 package: str
355 """The app package name."""
356 apk: str
357 """The app package (APK) filename."""
358
359 def __str__(self) -> str:
360 return self.package
361
362
363@dataclasses.dataclass(frozen=True)
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100364class Credentials:
365 """Credentials to access a service."""
366
367 username: str
368 password: str
369
370
371@dataclasses.dataclass(frozen=True)
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100372class ViserSuite:
373 """A SmartViser viSer app suite.
374
375 Example:
376
377 >>> device = DeviceUnderTest(...)
378 >>> suite = ViserSuite(...)
379 >>> suite.deploy(device)
380
381 """
382
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100383 ANDROID_APPS = [
384 AndroidApp("com.smartviser.demogame", "com.smartviser.demogame-latest.apk"),
385 AndroidApp("com.lunarlabs.panda", "com.lunarlabs.panda-latest.apk"),
386 ]
387
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100388 credentials: Credentials
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100389 prebuilts_path: pathlib.Path
390 scenarios_path: pathlib.Path
391 scenarios_data_path: pathlib.Path
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100392 settings_file: pathlib.Path
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100393 target_path: pathlib.Path = pathlib.Path("/sdcard/Viser")
394
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100395 def __str__(self) -> str:
396 return "SmartViser viSer app suite"
397
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100398 @property
399 def target_scenarios_path(self) -> pathlib.Path:
400 return self.target_path
401
402 @property
403 def target_scenarios_data_path(self) -> pathlib.Path:
404 return self.target_path / "Data"
405
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100406 @property
407 def target_settings_file(self) -> pathlib.Path:
408 return self.target_path / "Settings" / self.settings_file.name
409
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100410 def resolve_apps(self, device: DeviceUnderTest) -> Sequence[AndroidApp]:
411 """Resolve the apps based on the target device properties.
412
413 :param device: The device to target.
414 :returns: The sequence of apps suitable for the target device.
415 The sequence is ordered to satisfy the dependency graph
416 (i.e. required apps come first in the sequence).
417 """
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100418 return self.ANDROID_APPS
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100419
420 def deploy(self, device: DeviceUnderTest) -> None:
421 """Deploy the suite on a device.
422
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100423 Copy the test scenarios and their data, install the
424 different apps composing the suite, and configure the suite.
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100425
426 The previous configuration and data (i.e. reports) tied to the
427 app suite, if any, is deleted before hand.
428
429 :param device: The device to deploy the suite to.
430 """
431 self.cleanup_previous_deployment(device)
432 self.copy_scenarios(device)
433 self.install_suite(device)
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100434 self.configure_suite(device)
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100435
436 def cleanup_previous_deployment(self, device: DeviceUnderTest) -> None:
437 """Clean-up a previous deployment of the suite on a device.
438
439 :param device: The device to clean-up.
440 """
441 # Uninstall the apps in the reverse order to cater for dependencies.
442 for app in reversed(self.resolve_apps(device)):
443 _LOG.info("Uninstall %s", app)
444 device.uninstall_package(app.package)
445
446 _LOG.info("Delete data from previous deployment")
447 device.remove(self.target_path, recurse=True)
448
449 def copy_scenarios(self, device: DeviceUnderTest) -> None:
450 """Copy the suite scenarios and their data on a device.
451
452 :param device: The device to copy the scenarios to.
453 """
454 _LOG.info(
455 "Copy scenarios: %s → %s", self.scenarios_path, self.target_scenarios_path
456 )
457 device.push(self.scenarios_path, self.target_scenarios_path)
458
459 _LOG.info(
460 "Copy scenarios data: %s → %s",
461 self.scenarios_data_path,
462 self.target_scenarios_data_path,
463 )
Borjan Tchakaloff4ba9e032020-02-19 12:09:37 +0100464 device.push(self.scenarios_data_path, self.target_scenarios_data_path)
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100465
466 def install_suite(self, device: DeviceUnderTest) -> None:
467 """Install the suite apps on a device.
468
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100469 Input the credentials in the viSer app to authenticate the
470 device.
471
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100472 :param device: The device to install the suite apps on.
473 """
474 for app in self.resolve_apps(device):
475 _LOG.info("Install %s", app)
476 device.install(self.prebuilts_path / app.apk)
477
Borjan Tchakaloff3bb8b512020-02-19 15:49:53 +0100478 with device.launch("com.lunarlabs.panda/.ggb.Activity.MainActivity"):
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100479 _LOG.info("Initiate first run of viSer")
480
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100481 # Input the credentials
482 device.ui(resourceId="android:id/content").child(text="Username").child(
483 className="android.widget.EditText"
484 ).set_text(self.credentials.username)
485 device.ui(resourceId="android:id/content").child(text="Password").child(
486 className="android.widget.EditText"
487 ).set_text(self.credentials.password)
488
489 # Sign in
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100490 signin_label = "SIGN IN" if device.sdk >= ANDROID_7_SDK else "Sign in"
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100491 device.ui(resourceId="android:id/content").child(
492 text=signin_label, className="android.widget.Button"
493 ).click()
494
Borjan Tchakaloff3b177a42020-02-19 11:05:38 +0100495 # Wait for log-in to complete
496 progress_bar = device.ui(resourceId="android:id/content").child(
497 className="android.widget.ProgressBar"
498 )
499 progress_bar.wait.gone(timeout=10000)
500
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100501 # Is the app licensed?
502 license_failed_info = device.ui(resourceId="android:id/content").child(
503 textContains="Licence verification failed"
504 )
505 if license_failed_info.exists:
506 raise UnlicensedDeviceError(device, self)
507
508 self._maybe_accept_viser_dialer(device)
509
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100510 def configure_suite(self, device: DeviceUnderTest) -> None:
511 """Configure the suite on a device.
512
513 :param device: The device to configure the suite on.
514 """
515 _LOG.info(
516 "Copy settings: %s → %s", self.settings_file, self.target_settings_file
517 )
518 device.push(self.settings_file, self.target_settings_file)
519
Borjan Tchakaloff3bb8b512020-02-19 15:49:53 +0100520 with device.launch("com.lunarlabs.panda/.ggb.Activity.MainActivity"):
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100521 device.ui(description="Open navigation drawer").click()
522 device.ui(
523 text="Settings", className="android.widget.CheckedTextView"
524 ).click()
525 device.ui(className="android.support.v7.widget.RecyclerView").child_by_text(
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100526 "Settings management", className="android.widget.LinearLayout"
527 ).click()
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100528 device.ui(className="android.support.v7.widget.RecyclerView").child_by_text(
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100529 "Load settings", className="android.widget.LinearLayout"
530 ).click()
531 device.ui(resourceId="android:id/list").child_by_text(
532 self.settings_file.name, className="android.widget.TextView"
533 ).click()
534 device.ui(
535 textMatches="(?i)Select", className="android.widget.Button"
536 ).click()
537
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100538 self._maybe_accept_viser_dialer(device)
539
540 def _maybe_accept_viser_dialer(self, device: DeviceUnderTest) -> None:
541 """Accept the built-in viSer dialer as the default dialer, if necessary.
542
543 This method tries to accept the built-in viSer dialer from a
544 currently displayed modal. Two types of modals are handled, the
545 modal spawned by the viSer app itself, and the modal spawned by
546 the system UI.
547
548 This method is idempotent and will pass even if the modals are
549 missing.
550
551 :param device: The device to accept the modal on.
552 """
553 dialer_set = False
554
555 # Optional modal to set the the built-in dialer as default
556 set_dialer_modal = device.ui(resourceId="android:id/content").child(
557 textContains="Do you want to use viSer dialer as default ?"
558 )
559 if set_dialer_modal.exists:
560 device.ui(resourceId="android:id/content").child(
561 textMatches="(?i)Yes", className="android.widget.Button"
562 ).click()
563 dialer_set = True
564
565 # Optional system modal to really set the built-in dialer as default
566 set_dialer_system_modal = device.ui(resourceId="android:id/content").child(
567 text="Make viSer your default Phone app?"
568 )
569 if set_dialer_system_modal.exists:
570 device.ui(resourceId="android:id/content").child(
571 textMatches="(?i)Set default", className="android.widget.Button"
572 ).click()
573 dialer_set = True
574
575 # Optional system modal to change the default dialer
576 change_dialer_system_modal = device.ui(resourceId="android:id/content").child(
577 text="Change default Dialer app?"
578 )
579 if change_dialer_system_modal.exists:
580 device.ui(resourceId="android:id/content").child(
581 textMatches="(?i)OK", className="android.widget.Button"
582 ).click()
583 dialer_set = True
584
585 if dialer_set:
586 _LOG.info("Set the built-in viSer dialer as default dialer")
587
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100588
Franz-Xaver Geigerd1079e22018-02-19 14:34:15 +0100589def deploy():
590 serials = []
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100591 suite = ViserSuite(
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100592 Credentials("fairphonetesting@gmail.com", "aish3echi:uwaiSh"),
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100593 pathlib.Path("../../vendor/smartviser/viser/prebuilts/apk"),
594 pathlib.Path("../scenarios"),
595 pathlib.Path("../scenarios-data"),
Borjan Tchakaloff275978c2020-02-07 14:01:36 +0100596 pathlib.Path("settings/fairphone-bot_settings"),
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100597 )
Borjan Tchakaloffa4bdae12017-11-21 14:58:12 +0100598
Franz-Xaver Geigerd1079e22018-02-19 14:34:15 +0100599 if len(sys.argv) > 1:
600 serials.append(sys.argv[1])
601 else:
Franz-Xaver Geigerb0f55422018-04-17 10:33:37 +0200602 serials = list_devices()
Borjan Tchakaloffa4bdae12017-11-21 14:58:12 +0100603
Franz-Xaver Geigerd1079e22018-02-19 14:34:15 +0100604 for serial in serials:
Borjan Tchakaloff31c37e02020-02-07 14:03:27 +0100605 _LOG.info("Deploy to device %s", serial)
Borjan Tchakaloffa4bdae12017-11-21 14:58:12 +0100606
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100607 device = DeviceUnderTest(serial)
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200608 try:
609 # Make sure the screen stays on - we're going to use UI automation
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100610 device.force_awake()
611 device.unlock()
Borjan Tchakaloff64ec42b2018-02-07 11:33:15 -0800612
Karsten Tauscheb36529f2019-03-02 14:13:29 +0100613 # Disable Privacy Impact popup on Android 5.
Borjan Tchakaloff677090b2020-02-19 09:48:07 +0100614 if device.sdk < ANDROID_6_SDK:
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100615 disable_privacy_impact_popup(device)
Karsten Tauscheb36529f2019-03-02 14:13:29 +0100616
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200617 # Push the scenarios, their data, and install the apps
Borjan Tchakaloff9c59fb82020-02-07 11:49:41 +0100618 suite.deploy(device)
Borjan Tchakaloffde9fa0f2018-04-12 12:05:48 +0200619 finally:
620 try:
621 # Leave the device alone now
Borjan Tchakaloff1e841d82020-01-24 16:25:50 +0100622 device.force_awake(always=False)
Borjan Tchakaloff31c37e02020-02-07 14:03:27 +0100623 except DeviceCommandError:
624 _LOG.warning("Failed to tear down device", exc_info=True)
Borjan Tchakaloff64ec42b2018-02-07 11:33:15 -0800625
Franz-Xaver Geigerd1079e22018-02-19 14:34:15 +0100626
Borjan Tchakaloff7d4f6152020-01-24 15:41:00 +0100627if __name__ == "__main__":
Borjan Tchakaloff31c37e02020-02-07 14:03:27 +0100628 logging.basicConfig(
629 format="%(asctime)-15s:%(levelname)s:%(message)s", level=logging.INFO
630 )
Franz-Xaver Geigerd1079e22018-02-19 14:34:15 +0100631 deploy()