# coding: utf-8 # import datetime import hashlib import logging import os import shutil import tarfile import adbutils import progress.bar import requests from logzero import logger, setup_logger from retry import retry from uiautomator2.version import (__apk_version__, __atx_agent_version__, __jar_version__, __version__) from uiautomator2.utils import natualsize appdir = os.path.join(os.path.expanduser("~"), '.uiautomator2') GITHUB_BASEURL = "https://github.com/openatx" class DownloadBar(progress.bar.PixelBar): message = "Downloading" suffix = '%(current_size)s/%(total_size)s' width = 10 @property def total_size(self): return natualsize(self.max) @property def current_size(self): return natualsize(self.index) def gen_cachepath(url: str) -> str: filename = os.path.basename(url) storepath = os.path.join( appdir, "cache", filename.replace(" ", "_") + "-" + hashlib.sha224(url.encode()).hexdigest()[:10], filename) return storepath def cache_download(url, filename=None, timeout=None, storepath=None, logger=logger): """ return downloaded filepath """ # check cache if not filename: filename = os.path.basename(url) if not storepath: storepath = gen_cachepath(url) storedir = os.path.dirname(storepath) if not os.path.isdir(storedir): os.makedirs(storedir) if os.path.exists(storepath) and os.path.getsize(storepath) > 0: logger.debug("Use cached assets: %s", storepath) return storepath logger.debug("Download %s", url) # download from url headers = { 'Accept': '*/*', 'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2', 'Connection': 'keep-alive', 'Origin': 'https://github.com', 'User-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36' } # yapf: disable r = requests.get(url, stream=True, headers=headers, timeout=None) r.raise_for_status() file_size = int(r.headers.get("Content-Length")) bar = DownloadBar(filename, max=file_size) with open(storepath + '.part', 'wb') as f: chunk_length = 16 * 1024 while 1: buf = r.raw.read(chunk_length) if not buf: break f.write(buf) bar.next(len(buf)) bar.finish() assert file_size == os.path.getsize(storepath + ".part") # may raise FileNotFoundError shutil.move(storepath + '.part', storepath) return storepath def mirror_download(url: str, filename=None, logger: logging.Logger = logger): """ Download from mirror, then fallback to origin url """ storepath = gen_cachepath(url) if not filename: filename = os.path.basename(url) github_host = "https://github.com" if url.startswith(github_host): mirror_url = "https://tool.appetizer.io" + url[len( github_host):] # mirror of github try: return cache_download(mirror_url, filename, timeout=60, storepath=storepath, logger=logger) except (requests.RequestException, FileNotFoundError, AssertionError) as e: logger.debug("download error from mirror(%s), use origin source", e) return cache_download(url, filename, storepath=storepath, logger=logger) def app_uiautomator_apk_urls(): ret = [] for name in ["app-uiautomator.apk", "app-uiautomator-test.apk"]: ret.append((name, "".join([ GITHUB_BASEURL, "/android-uiautomator-server/releases/download/", __apk_version__, "/", name ]))) return ret def parse_apk(path: str): """ Parse APK Returns: dict contains "package" and "main_activity" """ import apkutils2 apk = apkutils2.APK(path) package_name = apk.manifest.package_name main_activity = apk.manifest.main_activity return { "package": package_name, "main_activity": main_activity, } class Initer(): def __init__(self, device: adbutils.AdbDevice, loglevel=logging.INFO): d = self._device = device self.sdk = d.getprop('ro.build.version.sdk') self.abi = d.getprop('ro.product.cpu.abi') self.pre = d.getprop('ro.build.version.preview_sdk') self.arch = d.getprop('ro.arch') self.abis = (d.getprop('ro.product.cpu.abilist').strip() or self.abi).split(",") self.server_addr = None self.logger = setup_logger(level=loglevel) # self.logger.debug("Initial device %s", device) self.logger.info("uiautomator2 version: %s", __version__) @property def atx_agent_path(self): return "/data/local/tmp/atx-agent" def shell(self, *args, timeout=60): self.logger.debug("Shell: %s", args) return self._device.shell(args, timeout=60) @property def jar_urls(self): """ Returns: iter([name, url], [name, url]) """ for name in ['bundle.jar', 'uiautomator-stub.jar']: yield (name, "".join([ GITHUB_BASEURL, "/android-uiautomator-jsonrpcserver/releases/download/", __jar_version__, "/", name ])) @property def atx_agent_url(self): files = { 'armeabi-v7a': 'atx-agent_{v}_linux_armv7.tar.gz', 'arm64-v8a': 'atx-agent_{v}_linux_armv7.tar.gz', 'armeabi': 'atx-agent_{v}_linux_armv6.tar.gz', 'x86': 'atx-agent_{v}_linux_386.tar.gz', } name = None for abi in self.abis: name = files.get(abi) if name: break if not name: raise Exception( "arch(%s) need to be supported yet, please report an issue in github" % self.abis) return GITHUB_BASEURL + '/atx-agent/releases/download/%s/%s' % ( __atx_agent_version__, name.format(v=__atx_agent_version__)) @property def minicap_urls(self): """ binary from https://github.com/openatx/stf-binaries only got abi: armeabi-v7a and arm64-v8a """ base_url = GITHUB_BASEURL + \ "/stf-binaries/raw/0.2.2/node_modules/minicap-prebuilt-beta/prebuilt/" sdk = self.sdk yield base_url + self.abi + "/lib/android-" + sdk + "/minicap.so" yield base_url + self.abi + "/bin/minicap" @property def minitouch_url(self): return ''.join([ GITHUB_BASEURL + "/stf-binaries", "/raw/0.2.2/node_modules/minitouch-prebuilt-beta/prebuilt/", self.abi + "/bin/minitouch" ]) @retry(tries=2, logger=logger) def push_url(self, url, dest=None, mode=0o755, tgz=False, extract_name=None): # yapf: disable path = mirror_download(url, filename=os.path.basename(url), logger=self.logger) if tgz: tar = tarfile.open(path, 'r:gz') path = os.path.join(os.path.dirname(path), extract_name) tar.extract(extract_name, os.path.dirname(path)) # zlib.error may raise if not dest: dest = "/data/local/tmp/" + os.path.basename(path) self.logger.debug("Push to %s:0%o", dest, mode) self._device.sync.push(path, dest, mode=mode) return dest def is_apk_outdated(self): """ If apk signature mismatch, the uiautomator test will fail to start command: am instrument -w -r -e debug false \ -e class com.github.uiautomator.stub.Stub \ com.github.uiautomator.test/android.support.test.runner.AndroidJUnitRunner java.lang.SecurityException: Permission Denial: \ starting instrumentation ComponentInfo{com.github.uiautomator.test/android.support.test.runner.AndroidJUnitRunner} \ from pid=7877, uid=7877 not allowed \ because package com.github.uiautomator.test does not have a signature matching the target com.github.uiautomator """ apk_debug = self._device.package_info("com.github.uiautomator") apk_debug_test = self._device.package_info( "com.github.uiautomator.test") self.logger.debug("apk-debug package-info: %s", apk_debug) self.logger.debug("apk-debug-test package-info: %s", apk_debug_test) if not apk_debug or not apk_debug_test: return True if apk_debug['version_name'] != __apk_version__: self.logger.info( "package com.github.uiautomator version %s, latest %s", apk_debug['version_name'], __apk_version__) return True if apk_debug['signature'] != apk_debug_test['signature']: # On vivo-Y67 signature might not same, but signature matched. # So here need to check first_install_time again max_delta = datetime.timedelta(minutes=3) if abs(apk_debug['first_install_time'] - apk_debug_test['first_install_time']) > max_delta: self.logger.debug( "package com.github.uiautomator does not have a signature matching the target com.github.uiautomator" ) return True return False def is_atx_agent_outdated(self): """ Returns: bool """ agent_version = self._device.shell(self.atx_agent_path + " version").strip() if agent_version == "dev": self.logger.info("skip version check for atx-agent dev") return False # semver major.minor.patch try: real_ver = list(map(int, agent_version.split("."))) want_ver = list(map(int, __atx_agent_version__.split("."))) except ValueError: return True self.logger.debug("Real version: %s, Expect version: %s", real_ver, want_ver) if real_ver[:2] != want_ver[:2]: return True return real_ver[2] < want_ver[2] def check_install(self): """ Only check atx-agent and test apks (Do not check minicap and minitouch) Returns: True if everything is fine, else False """ d = self._device if d.sync.stat(self.atx_agent_path).size == 0: return False if self.is_atx_agent_outdated(): return False if self.is_apk_outdated(): return False return True def _install_uiautomator_apks(self): """ use uiautomator 2.0 to run uiautomator test """ self.shell("pm", "uninstall", "com.github.uiautomator") self.shell("pm", "uninstall", "com.github.uiautomator.test") for filename, url in app_uiautomator_apk_urls(): path = self.push_url(url, mode=0o644) package_name = "com.github.uiautomator.test" if "test.apk" in url else "com.github.uiautomator" if os.getenv("TMQ"): # used inside TMQ platform self.shell("CLASSPATH=/sdcard/tmq.jar", "exec", "app_process", "/system/bin", "com.android.commands.monkey.other.InstallCommand", "-r", "-v", "-p", package_name, path) else: self.shell("pm", "install", "-r", "-t", path) self.logger.info("- %s installed", filename) def _install_jars(self): """ use uiautomator 1.0 to run uiautomator test """ for (name, url) in self.jar_urls: self.push_url(url, "/data/local/tmp/" + name, mode=0o644) def _install_atx_agent(self, server_addr=None): self.logger.info("Install atx-agent %s", __atx_agent_version__) self.push_url(self.atx_agent_url, tgz=True, extract_name="atx-agent") args = [self.atx_agent_path, "server", "--nouia", "-d"] if server_addr: args.extend(['-t', server_addr]) self.shell(self.atx_agent_path, "server", "--stop") self.shell(*args) def install(self, server_addr=None): """ TODO: push minicap and minitouch from tgz file """ self.logger.info("Install minicap, minitouch") self.push_url(self.minitouch_url) if self.abi == "x86": self.logger.info( "abi:x86 seems to be android emulator, skip install minicap") elif int(self.sdk) >= 30: self.logger.info("Android R (sdk:30) has no minicap resource") else: for url in self.minicap_urls: self.push_url(url) # self._install_jars() # disable jars if self.is_apk_outdated(): self.logger.info( "Install com.github.uiautomator, com.github.uiautomator.test %s", __apk_version__) self._install_uiautomator_apks() else: self.logger.info("Already installed com.github.uiautomator apks") if self.is_atx_agent_outdated(): self._install_atx_agent(server_addr) self.start_atx_agent() self.logger.info("Check atx-agent version") self.check_atx_agent_version() print("Successfully init %s" % self._device) def start_atx_agent(self): self.shell(self.atx_agent_path, 'server', '--nouia', '-d') @retry( (requests.ConnectionError, requests.ReadTimeout, requests.HTTPError), delay=.5, tries=10) def check_atx_agent_version(self): port = self._device.forward_port(7912) self.logger.debug("Forward: local:tcp:%d -> remote:tcp:%d", port, 7912) version = requests.get("http://127.0.0.1:%d/version" % port).text.strip() self.logger.debug("atx-agent version %s", version) return version def uninstall(self): self._device.shell([self.atx_agent_path, "server", "--stop"]) self._device.shell(["rm", self.atx_agent_path]) self.logger.info("atx-agent stopped and removed") self._device.shell(["rm", "/data/local/tmp/minicap"]) self._device.shell(["rm", "/data/local/tmp/minicap.so"]) self._device.shell(["rm", "/data/local/tmp/minitouch"]) self.logger.info("minicap, minitouch removed") self._device.shell(["pm", "uninstall", "com.github.uiautomator"]) self._device.shell(["pm", "uninstall", "com.github.uiautomator.test"]) self.logger.info("com.github.uiautomator uninstalled, all done !!!") if __name__ == "__main__": import adbutils serial = None device = adbutils.adb.device(serial) init = Initer(device, loglevel=logging.DEBUG) print(init.check_install())