init.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. # coding: utf-8
  2. #
  3. import datetime
  4. import hashlib
  5. import logging
  6. import os
  7. from pathlib import Path
  8. import shutil
  9. import tarfile
  10. import adbutils
  11. import progress.bar
  12. import requests
  13. from retry import retry
  14. from uiautomator2.utils import natualsize
  15. from uiautomator2.version import __apk_version__, __atx_agent_version__, __jar_version__, __version__
  16. appdir = os.path.join(os.path.expanduser("~"), '.uiautomator2')
  17. GITHUB_BASEURL = "https://github.com/openatx"
  18. logger = logging.getLogger(__name__)
  19. assets_dir = Path(__file__).absolute().parent.joinpath("assets")
  20. class DownloadBar(progress.bar.PixelBar):
  21. message = "Downloading"
  22. suffix = '%(current_size)s/%(total_size)s'
  23. width = 10
  24. @property
  25. def total_size(self):
  26. return natualsize(self.max)
  27. @property
  28. def current_size(self):
  29. return natualsize(self.index)
  30. def gen_cachepath(url: str) -> str:
  31. filename = os.path.basename(url)
  32. storepath = os.path.join(
  33. appdir, "cache",
  34. filename.replace(" ", "_") + "-" +
  35. hashlib.sha224(url.encode()).hexdigest()[:10], filename)
  36. return storepath
  37. def cache_download(url, filename=None, timeout=None, storepath=None, logger=logger):
  38. """ return downloaded filepath """
  39. # check cache
  40. if not filename:
  41. filename = os.path.basename(url)
  42. if not storepath:
  43. storepath = gen_cachepath(url)
  44. storedir = os.path.dirname(storepath)
  45. if not os.path.isdir(storedir):
  46. os.makedirs(storedir)
  47. if os.path.exists(storepath) and os.path.getsize(storepath) > 0:
  48. logger.debug("Use cached assets: %s", storepath)
  49. return storepath
  50. logger.debug("Download %s", url)
  51. # download from url
  52. headers = {
  53. 'Accept': '*/*',
  54. 'Accept-Encoding': 'gzip, deflate, br',
  55. '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',
  56. 'Connection': 'keep-alive',
  57. 'Origin': 'https://github.com',
  58. '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'
  59. } # yapf: disable
  60. r = requests.get(url, stream=True, headers=headers, timeout=None)
  61. r.raise_for_status()
  62. file_size = int(r.headers.get("Content-Length"))
  63. bar = DownloadBar(filename, max=file_size)
  64. with open(storepath + '.part', 'wb') as f:
  65. chunk_length = 16 * 1024
  66. while 1:
  67. buf = r.raw.read(chunk_length)
  68. if not buf:
  69. break
  70. f.write(buf)
  71. bar.next(len(buf))
  72. bar.finish()
  73. assert file_size == os.path.getsize(storepath +
  74. ".part") # may raise FileNotFoundError
  75. shutil.move(storepath + '.part', storepath)
  76. return storepath
  77. def mirror_download(url: str, filename=None):
  78. """
  79. Download from mirror, then fallback to origin url
  80. """
  81. storepath = gen_cachepath(url)
  82. if not filename:
  83. filename = os.path.basename(url)
  84. github_host = "https://github.com"
  85. if url.startswith(github_host):
  86. mirror_url = "https://tool.appetizer.io" + url[len(
  87. github_host):] # mirror of github
  88. try:
  89. return cache_download(mirror_url,
  90. filename,
  91. timeout=60,
  92. storepath=storepath,
  93. logger=logger)
  94. except (requests.RequestException, FileNotFoundError,
  95. AssertionError) as e:
  96. logger.debug("download error from mirror(%s), use origin source", e)
  97. return cache_download(url, filename, storepath=storepath, logger=logger)
  98. def app_uiautomator_apk_urls():
  99. ret = []
  100. for name in ["app-uiautomator.apk", "app-uiautomator-test.apk"]:
  101. ret.append((name, "".join([
  102. GITHUB_BASEURL, "/android-uiautomator-server/releases/download/",
  103. __apk_version__, "/", name
  104. ])))
  105. return ret
  106. def parse_apk(path: str):
  107. """
  108. Parse APK
  109. Returns:
  110. dict contains "package" and "main_activity"
  111. """
  112. import apkutils2
  113. apk = apkutils2.APK(path)
  114. package_name = apk.manifest.package_name
  115. main_activity = apk.manifest.main_activity
  116. return {
  117. "package": package_name,
  118. "main_activity": main_activity,
  119. }
  120. class Initer():
  121. def __init__(self, device: adbutils.AdbDevice, loglevel=logging.DEBUG):
  122. d = self._device = device
  123. self.sdk = d.getprop('ro.build.version.sdk')
  124. self.abi = d.getprop('ro.product.cpu.abi')
  125. self.pre = d.getprop('ro.build.version.preview_sdk')
  126. self.arch = d.getprop('ro.arch')
  127. self.abis = (d.getprop('ro.product.cpu.abilist').strip()
  128. or self.abi).split(",")
  129. self.__atx_listen_addr = "127.0.0.1:7912"
  130. logger.info("uiautomator2 version: %s", __version__)
  131. def set_atx_agent_addr(self, addr: str):
  132. assert ":" in addr
  133. self.__atx_listen_addr = addr
  134. @property
  135. def atx_agent_path(self):
  136. return "/data/local/tmp/atx-agent"
  137. def shell(self, *args, timeout=60):
  138. logger.debug("Shell: %s", args)
  139. return self._device.shell(args, timeout=60)
  140. @property
  141. def jar_urls(self):
  142. """
  143. Returns:
  144. iter([name, url], [name, url])
  145. """
  146. for name in ['bundle.jar', 'uiautomator-stub.jar']:
  147. yield (name, "".join([
  148. GITHUB_BASEURL,
  149. "/android-uiautomator-jsonrpcserver/releases/download/",
  150. __jar_version__, "/", name
  151. ]))
  152. @property
  153. def atx_agent_url(self):
  154. files = {
  155. 'armeabi-v7a': 'atx-agent_{v}_linux_armv7.tar.gz',
  156. 'arm64-v8a': 'atx-agent_{v}_linux_arm64.tar.gz',
  157. 'armeabi': 'atx-agent_{v}_linux_armv6.tar.gz',
  158. 'x86': 'atx-agent_{v}_linux_386.tar.gz',
  159. 'x86_64': 'atx-agent_{v}_linux_386.tar.gz',
  160. }
  161. name = None
  162. for abi in self.abis:
  163. name = files.get(abi)
  164. if name:
  165. break
  166. if not name:
  167. raise Exception(
  168. "arch(%s) need to be supported yet, please report an issue in github"
  169. % self.abis)
  170. return GITHUB_BASEURL + '/atx-agent/releases/download/%s/%s' % (
  171. __atx_agent_version__, name.format(v=__atx_agent_version__))
  172. @property
  173. def minicap_urls(self):
  174. """
  175. binary from https://github.com/openatx/stf-binaries
  176. only got abi: armeabi-v7a and arm64-v8a
  177. """
  178. base_url = GITHUB_BASEURL + \
  179. "/stf-binaries/raw/0.3.0/node_modules/@devicefarmer/minicap-prebuilt/prebuilt/"
  180. sdk = self.sdk
  181. yield base_url + self.abi + "/lib/android-" + sdk + "/minicap.so"
  182. yield base_url + self.abi + "/bin/minicap"
  183. @property
  184. def minitouch_url(self):
  185. return ''.join([
  186. GITHUB_BASEURL + "/stf-binaries",
  187. "/raw/0.3.0/node_modules/@devicefarmer/minitouch-prebuilt/prebuilt/",
  188. self.abi + "/bin/minitouch"
  189. ])
  190. @retry(tries=2, logger=logger)
  191. def push_url(self, url, dest=None, mode=0o755, tgz=False, extract_name=None): # yapf: disable
  192. path = mirror_download(url, filename=os.path.basename(url))
  193. if tgz:
  194. tar = tarfile.open(path, 'r:gz')
  195. path = os.path.join(os.path.dirname(path), extract_name)
  196. tar.extract(extract_name,
  197. os.path.dirname(path)) # zlib.error may raise
  198. if not dest:
  199. dest = "/data/local/tmp/" + os.path.basename(path)
  200. logger.debug("Push to %s:0%o", dest, mode)
  201. self._device.sync.push(path, dest, mode=mode)
  202. return dest
  203. def is_apk_outdated(self):
  204. """
  205. If apk signature mismatch, the uiautomator test will fail to start
  206. command: am instrument -w -r -e debug false \
  207. -e class com.github.uiautomator.stub.Stub \
  208. com.github.uiautomator.test/android.support.test.runner.AndroidJUnitRunner
  209. java.lang.SecurityException: Permission Denial: \
  210. starting instrumentation ComponentInfo{com.github.uiautomator.test/android.support.test.runner.AndroidJUnitRunner} \
  211. from pid=7877, uid=7877 not allowed \
  212. because package com.github.uiautomator.test does not have a signature matching the target com.github.uiautomator
  213. """
  214. apk_debug = self._device.package_info("com.github.uiautomator")
  215. apk_debug_test = self._device.package_info(
  216. "com.github.uiautomator.test")
  217. logger.debug("apk-debug package-info: %s", apk_debug)
  218. logger.debug("apk-debug-test package-info: %s", apk_debug_test)
  219. if not apk_debug or not apk_debug_test:
  220. return True
  221. if apk_debug['version_name'] != __apk_version__:
  222. logger.info(
  223. "package com.github.uiautomator version %s, latest %s",
  224. apk_debug['version_name'], __apk_version__)
  225. return True
  226. if apk_debug['signature'] != apk_debug_test['signature']:
  227. # On vivo-Y67 signature might not same, but signature matched.
  228. # So here need to check first_install_time again
  229. max_delta = datetime.timedelta(minutes=3)
  230. if abs(apk_debug['first_install_time'] -
  231. apk_debug_test['first_install_time']) > max_delta:
  232. logger.debug(
  233. "package com.github.uiautomator does not have a signature matching the target com.github.uiautomator"
  234. )
  235. return True
  236. return False
  237. def is_atx_agent_outdated(self):
  238. """
  239. Returns:
  240. bool
  241. """
  242. agent_version = self._device.shell([self.atx_agent_path, "version"]).strip()
  243. if agent_version == "dev":
  244. logger.info("skip version check for atx-agent dev")
  245. return False
  246. # semver major.minor.patch
  247. try:
  248. real_ver = list(map(int, agent_version.split(".")))
  249. want_ver = list(map(int, __atx_agent_version__.split(".")))
  250. except ValueError:
  251. return True
  252. logger.debug("Real version: %s, Expect version: %s", real_ver,
  253. want_ver)
  254. if real_ver[:2] != want_ver[:2]:
  255. return True
  256. return real_ver[2] < want_ver[2]
  257. def check_install(self):
  258. """
  259. Only check atx-agent and test apks (Do not check minicap and minitouch)
  260. Returns:
  261. True if everything is fine, else False
  262. """
  263. d = self._device
  264. if d.sync.stat(self.atx_agent_path).size == 0:
  265. return False
  266. if self.is_atx_agent_outdated():
  267. return False
  268. if self.is_apk_outdated():
  269. return False
  270. return True
  271. def _install_uiautomator_apks(self):
  272. """ use uiautomator 2.0 to run uiautomator test
  273. 通常在连接USB数据线的情况下调用
  274. """
  275. self.shell("pm", "uninstall", "com.github.uiautomator")
  276. self.shell("pm", "uninstall", "com.github.uiautomator.test")
  277. for filename, url in app_uiautomator_apk_urls():
  278. path = self.push_url(url, mode=0o644)
  279. self.shell("pm", "install", "-r", "-t", path)
  280. logger.info("- %s installed", filename)
  281. def _install_jars(self):
  282. """ use uiautomator 1.0 to run uiautomator test """
  283. for (name, url) in self.jar_urls:
  284. self.push_url(url, "/data/local/tmp/" + name, mode=0o644)
  285. def _install_atx_agent(self):
  286. logger.info("Install atx-agent %s", __atx_agent_version__)
  287. if 'armeabi' in self.abis:
  288. local_atx_agent_path = assets_dir.joinpath("atx-agent")
  289. if local_atx_agent_path.exists():
  290. logger.info("Use local atx-agent[armeabi]: %s", local_atx_agent_path)
  291. dest = '/data/local/tmp/atx-agent'
  292. self._device.sync.push(local_atx_agent_path, dest, mode=0o755)
  293. return
  294. self.push_url(self.atx_agent_url, tgz=True, extract_name="atx-agent")
  295. def setup_atx_agent(self):
  296. # stop atx-agent first
  297. self.shell(self.atx_agent_path, "server", "--stop")
  298. if self.is_atx_agent_outdated():
  299. self._install_atx_agent()
  300. self.shell(self.atx_agent_path, 'server', '--nouia', '-d', "--addr", self.__atx_listen_addr)
  301. logger.info("Check atx-agent version")
  302. self.check_atx_agent_version()
  303. @retry(
  304. (requests.ConnectionError, requests.ReadTimeout, requests.HTTPError),
  305. delay=.5,
  306. tries=10)
  307. def check_atx_agent_version(self):
  308. port = self._device.forward_port(7912)
  309. logger.debug("Forward: local:tcp:%d -> remote:tcp:%d", port, 7912)
  310. version = requests.get("http://%s:%d/version" %
  311. (self._device._client.host, port)).text.strip()
  312. logger.debug("atx-agent version %s", version)
  313. wlan_ip = requests.get("http://%s:%d/wlan/ip" %
  314. (self._device._client.host, port)).text.strip()
  315. logger.debug("device wlan ip: %s", wlan_ip)
  316. return version
  317. def install(self):
  318. """
  319. TODO: push minicap and minitouch from tgz file
  320. """
  321. logger.info("Install minicap, minitouch")
  322. self.push_url(self.minitouch_url)
  323. if self.abi == "x86":
  324. logger.info(
  325. "abi:x86 not supported well, skip install minicap")
  326. elif int(self.sdk) > 30:
  327. logger.info("Android R (sdk:30) has no minicap resource")
  328. else:
  329. for url in self.minicap_urls:
  330. self.push_url(url)
  331. # self._install_jars() # disable jars
  332. if self.is_apk_outdated():
  333. logger.info(
  334. "Install com.github.uiautomator, com.github.uiautomator.test %s",
  335. __apk_version__)
  336. self._install_uiautomator_apks()
  337. else:
  338. logger.info("Already installed com.github.uiautomator apks")
  339. self.setup_atx_agent()
  340. print("Successfully init %s" % self._device)
  341. def uninstall(self):
  342. self._device.shell([self.atx_agent_path, "server", "--stop"])
  343. self._device.shell(["rm", self.atx_agent_path])
  344. logger.info("atx-agent stopped and removed")
  345. self._device.shell(["rm", "/data/local/tmp/minicap"])
  346. self._device.shell(["rm", "/data/local/tmp/minicap.so"])
  347. self._device.shell(["rm", "/data/local/tmp/minitouch"])
  348. logger.info("minicap, minitouch removed")
  349. self._device.shell(["pm", "uninstall", "com.github.uiautomator"])
  350. self._device.shell(["pm", "uninstall", "com.github.uiautomator.test"])
  351. logger.info("com.github.uiautomator uninstalled, all done !!!")
  352. if __name__ == "__main__":
  353. import adbutils
  354. serial = None
  355. device = adbutils.adb.device(serial)
  356. init = Initer(device, loglevel=logging.DEBUG)
  357. print(init.check_install())