# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
""" Drives an android device.
"""
import os
import posixpath
import tempfile
import contextlib
import time
import logging
import attr
from arsenic.services
import Geckodriver, free_port, subprocess_based_service
from mozdevice
import ADBDeviceFactory, ADBError
from condprof.util
import write_yml_file, logger, DEFAULT_PREFS, BaseEnv
# XXX most of this code should migrate into mozdevice - see Bug 1574849
class AndroidDevice:
def __init__(
self,
app_name,
marionette_port=2828,
verbose=
False,
remote_test_root=
"/sdcard/test_root/",
):
self.app_name = app_name
# XXX make that an option
if "fenix" in app_name:
self.activity =
"org.mozilla.fenix.IntentReceiverActivity"
else:
self.activity =
"org.mozilla.geckoview_example.GeckoViewActivity"
self.verbose = verbose
self.device =
None
self.marionette_port = marionette_port
self.profile =
None
self.remote_profile =
None
self.log_file =
None
self.remote_test_root = remote_test_root
self._adb_fh =
None
def _set_adb_logger(self, log_file):
self.log_file = log_file
if self.log_file
is None:
return
logger.info(
"Setting ADB log file to %s" % self.log_file)
adb_logger = logging.getLogger(
"adb")
adb_logger.setLevel(logging.DEBUG)
self._adb_fh = logging.FileHandler(self.log_file)
self._adb_fh.setLevel(logging.DEBUG)
adb_logger.addHandler(self._adb_fh)
def _unset_adb_logger(self):
if self._adb_fh
is None:
return
logging.getLogger(
"adb").removeHandler(self._adb_fh)
self._adb_fh =
None
def clear_logcat(self, timeout=
None, buffers=[]):
if not self.device:
return
self.device.clear_logcat(timeout, buffers)
def get_logcat(self):
if not self.device:
return None
# we don't want to have ADBCommand dump the command
# in the debug stream so we reduce its verbosity here
# temporarely
old_verbose = self.device._verbose
self.device._verbose =
False
try:
return self.device.get_logcat()
finally:
self.device._verbose = old_verbose
def prepare(self, profile, logfile):
self._set_adb_logger(logfile)
try:
# See android_emulator_pgo.py run_tests for more
# details on why test_root must be /sdcard/test_root
# for android pgo due to Android 4.3.
self.device = ADBDeviceFactory(
verbose=self.verbose, logger_name=
"adb", test_root=self.remote_test_root
)
except Exception:
logger.error(
"Cannot initialize device")
raise
device = self.device
self.profile = profile
# checking that the app is installed
if not device.is_app_installed(self.app_name):
raise Exception(
"%s is not installed" % self.app_name)
# debug flag
logger.info(
"Setting %s as the debug app on the phone" % self.app_name)
device.shell(
"am set-debug-app --persistent %s" % self.app_name,
stdout_callback=logger.info,
)
# creating the profile on the device
logger.info(
"Creating the profile on the device")
remote_profile = posixpath.join(self.remote_test_root,
"profile")
logger.info(
"The profile on the phone will be at %s" % remote_profile)
device.rm(remote_profile, force=
True, recursive=
True)
logger.info(
"Pushing %s on the phone" % self.profile)
device.push(profile, remote_profile)
device.chmod(remote_profile, recursive=
True)
self.profile = profile
self.remote_profile = remote_profile
# creating the yml file
yml_data = {
"args": [
"-marionette",
"-profile", self.remote_profile],
"prefs": DEFAULT_PREFS,
"env": {
"LOG_VERBOSE": 1,
"R_LOG_LEVEL": 6,
"MOZ_LOG":
""},
}
yml_name =
"%s-geckoview-config.yaml" % self.app_name
yml_on_host = posixpath.join(tempfile.mkdtemp(), yml_name)
write_yml_file(yml_on_host, yml_data)
tmp_on_device = posixpath.join(
"/data",
"local",
"tmp")
if not device.exists(tmp_on_device):
raise IOError(
"%s does not exists on the device" % tmp_on_device)
yml_on_device = posixpath.join(tmp_on_device, yml_name)
try:
device.rm(yml_on_device, force=
True, recursive=
True)
device.push(yml_on_host, yml_on_device)
device.chmod(yml_on_device, recursive=
True)
except Exception:
logger.info(
"could not create the yaml file on device. Permission issue?")
raise
# command line 'extra' args not used with geckoview apps; instead we use
# an on-device config.yml file
intent =
"android.intent.action.VIEW"
device.stop_application(self.app_name)
device.launch_application(
self.app_name, self.activity, intent, extras=
None, url=
"about:blank"
)
if not device.process_exist(self.app_name):
raise Exception(
"Could not start %s" % self.app_name)
logger.info(
"Creating socket forwarding on port %d" % self.marionette_port)
device.forward(
local=
"tcp:%d" % self.marionette_port,
remote=
"tcp:%d" % self.marionette_port,
)
# we don't have a clean way for now to check that GV or Fenix
# is ready to handle our tests. So here we just wait 30s
logger.info(
"Sleeping for 30s")
time.sleep(30)
def stop_browser(self):
logger.info(
"Stopping %s" % self.app_name)
try:
self.device.stop_application(self.app_name)
except ADBError:
logger.info(
"Could not stop the application using force-stop")
time.sleep(5)
if self.device.process_exist(self.app_name):
logger.info(
"%s still running, trying SIGKILL" % self.app_name)
num_tries = 0
while self.device.process_exist(self.app_name)
and num_tries < 5:
try:
self.device.pkill(self.app_name)
except ADBError:
pass
num_tries += 1
time.sleep(1)
logger.info(
"%s stopped" % self.app_name)
def collect_profile(self):
logger.info(
"Collecting profile from %s" % self.remote_profile)
self.device.pull(self.remote_profile, self.profile)
def close(self):
self._unset_adb_logger()
if self.device
is None:
return
try:
self.device.remove_forwards(
"tcp:%d" % self.marionette_port)
except ADBError:
logger.warning(
"Could not remove forward port")
# XXX redundant, remove
@contextlib.contextmanager
def device(
app_name, marionette_port=2828, verbose=
True, remote_test_root=
"/sdcard/test_root/"
):
device_ = AndroidDevice(
app_name, marionette_port, verbose, remote_test_root=remote_test_root
)
try:
yield device_
finally:
device_.close()
@attr.s
class AndroidGeckodriver(Geckodriver):
async
def start(self):
port = free_port()
await self._check_version()
logger.info(
"Running Webdriver on port %d" % port)
logger.info(
"Running Marionette on port 2828")
pargs = [
self.binary,
"--log",
"trace",
"--port",
str(port),
"--marionette-port",
"2828",
]
logger.info(
"Connecting on Android device")
pargs.append(
"--connect-existing")
return await subprocess_based_service(
pargs, f
"http://localhost:{port}", self.log_file
)
class AndroidEnv(BaseEnv):
@contextlib.contextmanager
def get_device(self, *args, **kw):
with device(self.firefox, *args, **kw)
as d:
self.device = d
yield self.device
def get_target_platform(self):
app = self.firefox.split(
"org.mozilla.")[-1]
if self.device_name
is None:
return app
return "%s-%s" % (self.device_name, app)
def dump_logs(self):
logger.info(
"Dumping Android logs")
try:
logcat = self.device.get_logcat()
if logcat:
# local path, not using posixpath
logfile = os.path.join(self.archive,
"logcat.log")
logger.info(
"Writing logcat at %s" % logfile)
with open(logfile,
"wb")
as f:
for line
in logcat:
f.write(line.encode(
"utf8", errors=
"replace") + b
"\n")
else:
logger.info(
"logcat came back empty")
except Exception:
logger.error(
"Could not extract the logcat", exc_info=
True)
@contextlib.contextmanager
def get_browser(self):
yield
def get_browser_args(self, headless, prefs=
None):
# XXX merge with DesktopEnv.get_browser_args
options = [
"--allow-downgrade"]
if headless:
options.append(
"-headless")
if prefs
is None:
prefs = {}
return {
"moz:firefoxOptions": {
"args": options,
"prefs": prefs}}
def prepare(self, logfile):
self.device.prepare(self.profile, logfile)
def get_browser_version(self):
return self.target_platform +
"-XXXneedtograbversion"
def get_geckodriver(self, log_file):
return AndroidGeckodriver(binary=self.geckodriver, log_file=log_file)
def check_session(self, session):
async
def fake_close(*args):
pass
session.close = fake_close
def collect_profile(self):
self.device.collect_profile()
def stop_browser(self):
self.device.stop_browser()