├── .dockerignore ├── .gitattributes ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── DEVELOP.md ├── Dockerfile ├── LICENSE ├── README.md ├── asyncadb.py ├── build-docker-push.sh ├── core ├── __init__.py ├── fetching.py ├── freeport.py └── utils.py ├── device.py ├── device_names.py ├── heartbeat.py ├── install-adb.sh ├── main.py ├── package.json ├── requirements.txt ├── run.sh ├── settings.py ├── tcpproxy.js └── vendor ├── download-apks.py ├── download-atx-agent.py └── keys ├── adbkey └── adbkey.pub /.dockerignore: -------------------------------------------------------------------------------- 1 | # git 2 | .git 3 | .gitattributes 4 | .gitignore 5 | 6 | # nodejs 7 | ## logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | ## Dependency directories 14 | node_modules/ 15 | 16 | ## Tempfile 17 | cache-* 18 | tmpfile-* 19 | 20 | ## common 21 | README.md 22 | CHANGELOG.md 23 | docker-compose.yml 24 | Dockerfile 25 | 26 | ## Python 27 | __pycache__/ 28 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ./vendor/multios-adbs/ filter=lfs diff=lfs merge=lfs -text 2 | adb filter=lfs diff=lfs merge=lfs -text 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Warning** 2 | 3 | These issues are not tracked. Please create new issues in the main _atxserver2_ 4 | repository: https://github.com/openatx/atxserver2/issues/new 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .vscode/ 113 | .dmypy.json 114 | dmypy.json 115 | testdata/ 116 | cache-* 117 | 118 | # Pyre type checker 119 | .pyre/ 120 | # JS 121 | node_modules/ 122 | package-lock.json 123 | 124 | vendor/*.apk 125 | vendor/*.zip 126 | vendor/*.part 127 | vendor/app-uiautomator-* 128 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | # command to install dependencies 5 | install: 6 | - pip install -r requirements.txt 7 | # command to run tests 8 | script: 9 | - echo "Skip" 10 | after_success: 11 | - echo "$DOCKER_PASSWORD" | docker login -u $DOCKER_USERNAME --password-stdin 12 | - sh build-docker-push.sh 13 | -------------------------------------------------------------------------------- /DEVELOP.md: -------------------------------------------------------------------------------- 1 | ## 开发者文档 2 | 能看到这篇文档,说明你有开发者的潜质 ^_^ 3 | 4 | ## Docker构建 5 | 目前Linux/Amd64可以通过Dockerhub自动构建。树莓派Linux/Arm需要手动构建 6 | 7 | ```bash 8 | # 在树莓派上运行 9 | 10 | # 安装Docker 11 | curl -fsSL https://get.docker.com | sh 12 | 13 | # 登录Docker 14 | docker login 15 | 16 | # 构建镜像 17 | git clone https://github.com/openatx/atxserver2-android-provider 18 | cd atxserver2-android-provider 19 | git lfs install 20 | git lfs pull 21 | 22 | IMAGE="codeskyblue/atxserver2-android-provider:raspberry" 23 | docker build -t $IMAGE . 24 | docker push $IMAGE 25 | ``` 26 | 27 | Dockerhub Repo地址 https://cloud.docker.com/repository/docker/codeskyblue/atxserver2-android-provider 28 | 29 | ## Docker相关 30 | Multiarch support 31 | 32 | ```bash 33 | docker manifest create codeskyblue/atxserver2-android-provider:latest \ 34 | codeskyblue/atxserver2-android-provider:linux \ 35 | codeskyblue/atxserver2-android-provider:raspberry \ 36 | --amend 37 | docker manifest push --purge codeskyblue/atxserver2-android-provider:latest 38 | ``` 39 | 40 | > amend and purge show up here, because https://github.com/docker/cli/issues/954 41 | 42 | 测试一下 43 | 44 | ```bash 45 | $ docker run mplatform/mquery codeskyblue/atxserver2-android-provider 46 | Image: codeskyblue/atxserver2-android-provider 47 | * Manifest List: Yes 48 | * Supported platforms: 49 | - linux/amd64 50 | - linux/arm 51 | ``` 52 | 53 | 参考资料:https://medium.com/@mauridb/docker-multi-architecture-images-365a44c26be6 54 | 55 | ## /vendor 大文件 56 | 虽说是大文件,其实也不大 57 | 58 | `/vendor`目录下的文件通过`git-lfs`管理 59 | 60 | - `stf-binaries-master.zip` 直接去 https://github.com/codeskyblue/stf-binaries 下载zip 61 | - `atx-agent-latest.zip` 需要cd到vendor目录,运行`download-atx-agent.py`去生成 62 | 63 | ## Heartbeat Protocol 64 | 通过该协议,服务端(atxserver2)能够知道有哪些设备接入了系统。以及当前连接的设备的状态。 65 | 66 | 协议基于WebSocket,传递的内容均为JSON格式 67 | 68 | 当前atxserver2的websocket地址为 `ws://$SERVER_HOST/websocket/heartbeat` 69 | 70 | **握手请求:provider -> atxserver2** 71 | 72 | ```json 73 | { 74 | "command": "handshake", 75 | "name": "--provider-name--", 76 | "owner": "--owner-of-devices--", 77 | "secret": "--需要跟server端保持一致,不会会被拒--", 78 | "url": "--provider-url--", 79 | "priority": 1 80 | } 81 | ``` 82 | 83 | priority主要通过手工去设置,数值越大代表该provider性能越好,网速越快。 84 | url字段代表provider的url,通过约定好的格式,可以实现安装,释放设备的操作 85 | 86 | **握手回复:atxserver2 -> provider** 87 | 88 | 握手成功返回 89 | 90 | ```json 91 | { 92 | "success": true, 93 | "id": "xxxxx-xxxx-xxx" 94 | } 95 | ``` 96 | 97 | 失败返回 98 | 99 | ```json 100 | { 101 | "success": false, 102 | "description": "xxxx", 103 | } 104 | ``` 105 | 106 | **更新设备状态: provider -> atxserver2** 107 | 108 | _Android设备上线_ 109 | 110 | 111 | ```json 112 | { 113 | "command": "update", 114 | "platform": "android", 115 | "udid": "xxxx-设备的唯一编号-xxxxx", 116 | "properties": { 117 | "serial": "xxxx", 118 | "brand": "xxxx", 119 | "version": "xxxx", 120 | "model": "xxxx", 121 | "name": "xxxx", 122 | }, 123 | "provider": { 124 | "atxAgentAddress": "10.0.0.1:7912", 125 | "remoteConnectAddress": "10.0.0.1:5555", 126 | "whatsInputAddress": "10.0.0.2:9955" 127 | } 128 | } 129 | ``` 130 | 131 | properties通常为不常变动的信息。 132 | 133 | _Android设备离线_ 134 | 135 | ```json 136 | { 137 | "command": "update", 138 | "udid": "xxxx-设备的唯一编号-xxxxx", 139 | "provider": null, 140 | } 141 | ``` 142 | 143 | 当WebSocket断线时,这个时候需要重连,Provider需要重发一次手机的信息。 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | RUN curl -sL https://deb.nodesource.com/setup_11.x | bash - 4 | RUN apt-get install -y nodejs wget 5 | 6 | ADD . /app 7 | WORKDIR /app 8 | 9 | RUN sh install-adb.sh 10 | 11 | RUN npm install 12 | RUN pip install -r requirements.txt 13 | 14 | ENTRYPOINT [] 15 | CMD ["python", "main.py", "--server", "http://localhost:4000"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2019 codeskyblue 3 | 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 19 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 20 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 21 | OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # atxserver2-android-provider 2 | android provider for [atxserver2](https://github.com/openatx/atxserver2) 3 | 4 | ## Install with docker 5 | 仅限`Linux`系统,Mac,Windows除外 6 | 7 | 推荐用这种方式部署,命令有点长,但是部署简单 8 | 9 | 如果你还没有安装docker,并且你用的是Linux,有一个很简单的命令就可以一键安装上去。 10 | 11 | ```bash 12 | curl -fsSL https://get.docker.com | sh 13 | ``` 14 | 15 | 使用dockerhub上的image(当前有Linux/amd64和Linux/arm的镜像) 16 | 17 | ```bash 18 | SERVER_URL="http://10.0.0.1:4000" # 这个修改成自己的atxserver2地址 19 | IMAGE="codeskyblue/atxserver2-android-provider" 20 | docker pull $IMAGE 21 | docker run --rm --privileged -v /dev/bus/usb:/dev/bus/usb --net host \ 22 | ${IMAGE} python main.py --server ${SERVER_URL} 23 | ``` 24 | 25 | ## Install from source (Mac, Windows推荐) 26 | 依赖 `Python3.6+`, `NodeJS 8` 27 | 28 | **NodeJS**版本太高了也不行,一定要NodeJS 8,推荐使用[nvm](https://github.com/nvm-sh/nvm)管理node版本 29 | 30 | Clone代码到本地 31 | 32 | ```bash 33 | git clone https://github.com/openatx/atxserver2-android-provider 34 | cd atxserver2-android-provider 35 | 36 | # 安装依赖 37 | npm install 38 | 39 | # 准备Python虚拟环境(可选) 40 | python3 -m venv venv 41 | . venv/bin/activate 42 | # venv/Scripts/activate.bat # for windows 43 | 44 | pip install -r requirements.txt 45 | 46 | # 启动,需要指定atxserver2的地址, 假设地址为 http://localhost:4000 47 | python3 main.py --server localhost:4000 48 | ``` 49 | 50 | Provider可以通过`adb track-devices`自动发现已经接入的设备,当手机接入到电脑上时,会自动给手机安装`minicap`, `minitouch`, `atx-agent`, `app-uiautomator-[test].apk`, `whatsinput-apk` 51 | 52 | 接入的设备需要配置好`开发者选项`, 不同设备的设置方案放到了该项目的[Issue中, tag: `device-settings`](https://github.com/openatx/atxserver2-android-provider/issues?q=is%3Aissue+is%3Aopen+label%3Adevice-settings) 如果没有该机型,可以自助添加 53 | 54 | ### 命令行参数 55 | 56 | - `--port` 本地监听的端口号 57 | - `--server` atxserver2的地址,默认`localhost:4000` 58 | - `--allow-remote` 允许远程设备,默认会忽略类似`10.0.0.1:5555`的设备 59 | - `--owner`, 邮箱地址或用户所在Group名,如果设置了,默认连接的设备都为私有设备,只有owner或管理员账号能看到 60 | 61 | ## Provider提供的接口(繁體字好漂亮) 62 | 主要有兩個接口,冷卻設備和安裝應用。 63 | 認證:url query中增加secret=来实现认证。secret可以在provider启动的时候看到 64 | 65 | ### 安装应用 66 | 通过URL安装应用 67 | 68 | ```bash 69 | $ http POST $SERVER/app/install?udid=${UDID} secret=$SECRET url==http://example.com/demo.apk 70 | { 71 | "success": true, 72 | "output": "Success\r\n" 73 | } 74 | ``` 75 | 76 | 之後的接口將省略掉secret 77 | 78 | ### 冷却设备 79 | 留出时间让设备降降温,以及做一些软件清理的工作 80 | 81 | ```bash 82 | $ http POST $SERVER/cold?udid=${UDID} 83 | { 84 | "success": true, 85 | "description": "Device is colding" 86 | } 87 | ``` 88 | 89 | ## Developers 90 | Read the [developers page](DEVELOP.md). 91 | 92 | ## LICENSE 93 | [MIT](LICENSE) 94 | -------------------------------------------------------------------------------- /asyncadb.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | # Refs adb SERVICES.TXT 4 | # https://github.com/aosp-mirror/platform_system_core/blob/master/adb/SERVICES.TXT 5 | 6 | import os 7 | import subprocess 8 | from collections import namedtuple 9 | 10 | import tornado.iostream 11 | from logzero import logger 12 | from tornado import gen 13 | from tornado.tcpclient import TCPClient 14 | 15 | 16 | OKAY = "OKAY" 17 | FAIL = "FAIL" 18 | 19 | 20 | DeviceItem = namedtuple("Device", ['serial', 'status']) 21 | DeviceEvent = namedtuple('DeviceEvent', ['present', 'serial', 'status']) 22 | ForwardItem = namedtuple("ForwardItem", ['serial', 'local', 'remote']) 23 | 24 | 25 | class AdbError(Exception): 26 | """ adb error """ 27 | 28 | 29 | class AdbStreamConnection(tornado.iostream.IOStream): 30 | """ 31 | Example usgae: 32 | async with AdbStreamConnection(host, port) as c: 33 | c.send_cmd("host:kill") 34 | """ 35 | 36 | def __init__(self, host, port): 37 | self.__host = host 38 | self.__port = port 39 | self.__stream = None 40 | 41 | @property 42 | def stream(self): 43 | return self.__stream 44 | 45 | async def send_cmd(self, cmd: str): 46 | await self.stream.write("{:04x}{}".format(len(cmd), 47 | cmd).encode('utf-8')) 48 | 49 | async def read_bytes(self, num: int): 50 | return (await self.stream.read_bytes(num)).decode() 51 | 52 | async def read_string(self): 53 | lenstr = await self.read_bytes(4) 54 | msgsize = int(lenstr, 16) 55 | return await self.read_bytes(msgsize) 56 | 57 | async def check_okay(self): 58 | data = await self.read_bytes(4) 59 | if data == FAIL: 60 | raise AdbError(await self.read_string()) 61 | elif data == OKAY: 62 | return 63 | else: 64 | raise AdbError("Unknown data: %s" % data) 65 | 66 | async def connect(self): 67 | adb_host = self.__host or os.environ.get( 68 | "ANDROID_ADB_SERVER_HOST", "127.0.0.1") 69 | adb_port = self.__port or int(os.environ.get( 70 | "ANDROID_ADB_SERVER_PORT", 5037)) 71 | stream = await TCPClient().connect(adb_host, adb_port) 72 | self.__stream = stream 73 | return self 74 | 75 | async def __aenter__(self): 76 | return await self.connect() 77 | 78 | async def __aexit__(self, exc_type, exc, tb): 79 | self.stream.close() 80 | 81 | 82 | class AdbClient(object): 83 | def __init__(self): 84 | self._stream = None 85 | 86 | def connect(self, host=None, port=None) -> AdbStreamConnection: 87 | return AdbStreamConnection(host, port) 88 | 89 | async def server_version(self) -> int: 90 | async with self.connect() as c: 91 | await c.send_cmd("host:version") 92 | await c.check_okay() 93 | return int(await c.read_string(), 16) 94 | 95 | async def track_devices(self): 96 | """ 97 | yield DeviceEvent according to track-devices 98 | 99 | Example: 100 | async for event in track_devices(): 101 | print(event) 102 | # output: DeviceEvent(present=True, serial='xxxx', status='device') 103 | """ 104 | orig_devices = [] 105 | while True: 106 | try: 107 | async for content in self._unsafe_track_devices(): 108 | curr_devices = self.output2devices( 109 | content, limit_status=['device']) 110 | for evt in self._diff_devices(orig_devices, curr_devices): 111 | yield evt 112 | orig_devices = curr_devices 113 | except tornado.iostream.StreamClosedError: 114 | # adb server maybe killed 115 | for evt in self._diff_devices(orig_devices, []): 116 | yield evt 117 | orig_devices = [] 118 | 119 | sleep = 1.0 120 | logger.info( 121 | "adb connection is down, retry after %.1fs" % sleep) 122 | await gen.sleep(sleep) 123 | subprocess.run(['adb', 'start-server']) 124 | version = await self.server_version() 125 | logger.info("adb-server started, version: %d", version) 126 | 127 | async def _unsafe_track_devices(self): 128 | async with self.connect() as conn: 129 | await conn.send_cmd("host:track-devices") 130 | await conn.check_okay() 131 | while True: 132 | yield await conn.read_string() 133 | 134 | def _diff_devices(self, orig_devices: list, curr_devices: list): 135 | """ Return iter(DeviceEvent) """ 136 | for d in set(orig_devices).difference(curr_devices): 137 | yield DeviceEvent(False, d.serial, d.status) 138 | for d in set(curr_devices).difference(orig_devices): 139 | yield DeviceEvent(True, d.serial, d.status) 140 | 141 | def output2devices(self, output: str, limit_status=[]): 142 | """ 143 | Args: 144 | outptu: str of adb devices output 145 | 146 | Returns: 147 | list of DeviceItem 148 | """ 149 | results = [] 150 | for line in output.splitlines(): 151 | fields = line.strip().split("\t", maxsplit=1) 152 | if len(fields) != 2: 153 | continue 154 | serial, status = fields[0], fields[1] 155 | 156 | if limit_status: 157 | if status in limit_status: 158 | results.append(DeviceItem(serial, status)) 159 | else: 160 | results.append(DeviceItem(serial, status)) 161 | return results 162 | 163 | async def shell(self, serial: str, command: str): 164 | async with self.connect() as conn: 165 | await conn.send_cmd("host:transport:"+serial) 166 | await conn.check_okay() 167 | await conn.send_cmd("shell:"+command) 168 | await conn.check_okay() 169 | output = await conn.stream.read_until_close() 170 | return output.decode('utf-8') 171 | 172 | async def forward_list(self): 173 | async with self.connect() as conn: 174 | # adb 1.0.40 not support host-local 175 | await conn.send_cmd("host:list-forward") 176 | await conn.check_okay() 177 | content = await conn.read_string() 178 | for line in content.splitlines(): 179 | parts = line.split() 180 | if len(parts) != 3: 181 | continue 182 | yield ForwardItem(*parts) 183 | 184 | async def forward_remove(self, local=None): 185 | async with self.connect() as conn: 186 | if local: 187 | await conn.send_cmd("host:killforward:"+local) 188 | else: 189 | await conn.send_cmd("host:killforward-all") 190 | await conn.check_okay() 191 | 192 | async def forward(self, serial: str, local: str, remote: str, norebind=False): 193 | """ 194 | Args: 195 | serial: device serial 196 | local, remote (str): tcp: | localabstract: 197 | norebind(bool): set to true will fail it when 198 | there is already a forward connection from 199 | """ 200 | async with self.connect() as conn: 201 | cmds = ["host-serial", serial, "forward"] 202 | if norebind: 203 | cmds.append('norebind') 204 | cmds.append(local+";"+remote) 205 | await conn.send_cmd(":".join(cmds)) 206 | await conn.check_okay() 207 | 208 | async def devices(self): 209 | """ 210 | Return: 211 | list of devices 212 | """ 213 | async with self.connect() as conn: 214 | await conn.send_cmd("host:devices") 215 | await conn.check_okay() 216 | content = await conn.read_string() 217 | return self.output2devices(content) 218 | 219 | 220 | adb = AdbClient() 221 | -------------------------------------------------------------------------------- /build-docker-push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -x 2 | # 3 | 4 | set -e 5 | 6 | TAG= 7 | case "$(uname -m)" in 8 | x86_64) 9 | TAG="linux" 10 | ;; 11 | armv*l) 12 | TAG="raspberry" 13 | ;; 14 | *) 15 | echo "Unknown arch: $(uname -m)" 16 | exit 1 17 | ;; 18 | esac 19 | 20 | IMAGE="codeskyblue/atxserver2-android-provider:$TAG" 21 | echo "IMAGE: ${IMAGE}" 22 | 23 | docker build -t $IMAGE . 24 | docker push $IMAGE 25 | 26 | docker manifest create codeskyblue/atxserver2-android-provider:latest \ 27 | codeskyblue/atxserver2-android-provider:linux \ 28 | codeskyblue/atxserver2-android-provider:raspberry \ 29 | --amend 30 | docker manifest push --purge codeskyblue/atxserver2-android-provider:latest -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openatx/atxserver2-android-provider/98aed0698901aaf6155f62fe323f75e89d1b3b22/core/__init__.py -------------------------------------------------------------------------------- /core/fetching.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os 3 | import shutil 4 | import tarfile 5 | import tempfile 6 | import zipfile 7 | 8 | import humanize 9 | import requests 10 | from logzero import logger 11 | from uiautomator2.version import __apk_version__, __atx_agent_version__ 12 | 13 | import settings 14 | 15 | __all__ = [ 16 | "get_atx_agent_bundle", "get_uiautomator_apks", "get_whatsinput_apk" 17 | ] 18 | 19 | 20 | def get_atx_agent_bundle() -> str: 21 | """ 22 | bundle all platform atx-agent binary into one zip file 23 | """ 24 | version = settings.atx_agent_version 25 | target_zip = f"vendor/atx-agent-{version}.zip" 26 | if not os.path.isfile(target_zip): 27 | os.makedirs("vendor", exist_ok=True) 28 | create_atx_agent_bundle(version, target_zip) 29 | return target_zip 30 | 31 | 32 | def get_uiautomator_apks() -> tuple: 33 | version = __apk_version__ 34 | print(">>> app-uiautomator.apk verison:", version) 35 | apk_url = f"https://github.com/openatx/android-uiautomator-server/releases/download/{version}/app-uiautomator.apk" 36 | target_dir = f"vendor/app-uiautomator-{version}" 37 | apk_path = mirror_download(apk_url, 38 | os.path.join(target_dir, "app-uiautomator.apk")) 39 | 40 | apk_test_url = f"https://github.com/openatx/android-uiautomator-server/releases/download/{version}/app-uiautomator-test.apk" 41 | print(">>> app-uiautomator-test.apk verison:", version) 42 | apk_test_path = mirror_download( 43 | apk_test_url, os.path.join(target_dir, "app-uiautomator-test.apk")) 44 | return (apk_path, apk_test_path) 45 | 46 | 47 | def get_whatsinput_apk() -> str: 48 | target_path = "vendor/WhatsInput-1.0.apk" 49 | mirror_download( 50 | "https://github.com/openatx/atxserver2-android-provider/releases/download/v0.2.0/WhatsInput_v1.0.apk", 51 | target_path) 52 | return target_path 53 | 54 | 55 | def get_stf_binaries() -> str: 56 | """ 57 | Download from https://github.com/openatx/stf-binaries 58 | 59 | Tag 0.2, support to Android P 60 | Tag 0.3.0 use stf/@devicefarmer 61 | """ 62 | version = "0.3.0" 63 | target_path = f"vendor/stf-binaries-{version}.zip" 64 | mirror_download( 65 | f"https://github.com/openatx/stf-binaries/archive/{version}.zip", 66 | target_path) 67 | return target_path 68 | 69 | 70 | def get_all(): 71 | get_atx_agent_bundle() 72 | get_uiautomator_apks() 73 | get_whatsinput_apk() 74 | get_stf_binaries() 75 | 76 | 77 | def create_atx_agent_bundle(version: str, target_zip: str): 78 | print(">>> Bundle atx-agent verison:", version) 79 | if not target_zip: 80 | target_zip = f"atx-agent-{version}.zip" 81 | 82 | def binary_url(version: str, arch: str) -> str: 83 | return "https://github.com/openatx/atx-agent/releases/download/{0}/atx-agent_{0}_linux_{1}.tar.gz".format( 84 | version, arch) 85 | 86 | with tempfile.TemporaryDirectory(prefix="tmp-") as tmpdir: 87 | tmp_target_zip = target_zip + ".part" 88 | 89 | with zipfile.ZipFile(tmp_target_zip, 90 | "w", 91 | compression=zipfile.ZIP_DEFLATED) as z: 92 | z.writestr(version, "") 93 | 94 | for arch in ("386", "amd64", "armv6", "armv7"): 95 | storepath = tmpdir + "/atx-agent-%s.tar.gz" % arch 96 | url = binary_url(version, arch) 97 | mirror_download(url, storepath) 98 | 99 | with tarfile.open(storepath, "r:gz") as t: 100 | t.extract("atx-agent", path=tmpdir + "/" + arch) 101 | z.write("/".join([tmpdir, arch, "atx-agent"]), 102 | "atx-agent-" + arch) 103 | shutil.move(tmp_target_zip, target_zip) 104 | print(">>> Zip created", target_zip) 105 | 106 | 107 | def mirror_download(url: str, target: str) -> str: 108 | """ 109 | Returns: 110 | target path 111 | """ 112 | if os.path.exists(target): 113 | return target 114 | github_host = "https://github.com" 115 | if url.startswith(github_host): 116 | mirror_url = "http://tool.appetizer.io" + url[len( 117 | github_host):] # mirror of github 118 | try: 119 | return download(mirror_url, target) 120 | except (requests.RequestException, ValueError) as e: 121 | logger.debug("download from mirror error, use origin source") 122 | 123 | return download(url, target) 124 | 125 | 126 | def download(url: str, storepath: str): 127 | target_dir = os.path.dirname(storepath) or "." 128 | os.makedirs(target_dir, exist_ok=True) 129 | 130 | r = requests.get(url, stream=True) 131 | r.raise_for_status() 132 | total_size = int(r.headers.get("Content-Length", "-1")) 133 | bytes_so_far = 0 134 | prefix = "Downloading %s" % os.path.basename(storepath) 135 | chunk_length = 16 * 1024 136 | with open(storepath + '.part', 'wb') as f: 137 | for buf in r.iter_content(chunk_length): 138 | bytes_so_far += len(buf) 139 | print(f"\r{prefix} {bytes_so_far} / {total_size}", 140 | end="", 141 | flush=True) 142 | f.write(buf) 143 | print(" [Done]") 144 | if total_size != -1 and os.path.getsize(storepath + ".part") != total_size: 145 | raise ValueError("download size mismatch") 146 | shutil.move(storepath + '.part', storepath) 147 | 148 | 149 | if __name__ == "__main__": 150 | bundle_path = get_atx_agent_bundle() 151 | print("Bundle:", bundle_path) 152 | -------------------------------------------------------------------------------- /core/freeport.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | import socket 5 | 6 | 7 | class FreePort(object): 8 | def __init__(self): 9 | self._start = 20000 10 | self._end = 40000 11 | self._now = self._start-1 12 | 13 | def get(self): 14 | while True: 15 | self._now += 1 16 | if self._now > self._end: 17 | self._now = self._start 18 | if not self.is_port_in_use(self._now): 19 | return self._now 20 | 21 | def is_port_in_use(self, port): 22 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 23 | return s.connect_ex(('localhost', port)) == 0 24 | 25 | 26 | freeport = FreePort() 27 | 28 | 29 | if __name__ == "__main__": 30 | for i in range(10): 31 | print(freeport.get()) 32 | -------------------------------------------------------------------------------- /core/utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | import collections 5 | import random 6 | import re 7 | import socket 8 | import string 9 | 10 | 11 | def current_ip(): 12 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 13 | s.connect(("8.8.8.8", 80)) 14 | ip = s.getsockname()[0] 15 | s.close() 16 | return ip 17 | 18 | 19 | def update_recursive(d: dict, u: dict) -> dict: 20 | for k, v in u.items(): 21 | if isinstance(v, collections.Mapping): 22 | # d.get(k) may return None 23 | d[k] = update_recursive(d.get(k) or {}, v) 24 | else: 25 | d[k] = v 26 | return d 27 | 28 | 29 | def fix_url(url, scheme=None): 30 | if not re.match(r"^(http|ws)s?://", url): 31 | url = "http://"+url 32 | if scheme: 33 | url = re.compile(r"^http").sub(scheme, url) 34 | return url 35 | 36 | 37 | def id_generator(length=10): 38 | return ''.join(random.choices(string.ascii_uppercase + string.digits, k=length)) 39 | -------------------------------------------------------------------------------- /device.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | import os 5 | import subprocess 6 | import traceback 7 | import zipfile 8 | 9 | from adbutils import adb as adbclient 10 | from logzero import logger 11 | 12 | import apkutils2 as apkutils 13 | from asyncadb import adb 14 | from device_names import device_names 15 | from core.freeport import freeport 16 | from core.utils import current_ip 17 | from core import fetching 18 | 19 | STATUS_INIT = "init" 20 | STATUS_OKAY = "ready" 21 | STATUS_FAIL = "fail" 22 | 23 | 24 | class InitError(Exception): 25 | """ device init error """ 26 | 27 | 28 | async def nop_callback(*args, **kwargs): 29 | pass 30 | 31 | 32 | class AndroidDevice(object): 33 | def __init__(self, serial: str, callback=nop_callback): 34 | self._serial = serial 35 | self._procs = [] 36 | self._current_ip = current_ip() 37 | self._device = adbclient.device(serial) 38 | self._callback = callback 39 | 40 | def __repr__(self): 41 | return "[" + self._serial + "]" 42 | 43 | @property 44 | def serial(self): 45 | return self._serial 46 | 47 | async def run_forever(self): 48 | try: 49 | await self.init() 50 | except Exception as e: 51 | logger.warning("Init failed: %s", e) 52 | 53 | async def init(self): 54 | """ 55 | do forward and start proxy 56 | """ 57 | logger.info("Init device: %s", self._serial) 58 | self._callback(STATUS_INIT) 59 | 60 | self._init_binaries() 61 | self._init_apks() 62 | await self._init_forwards() 63 | 64 | await adb.shell(self._serial, 65 | "/data/local/tmp/atx-agent server --stop") 66 | await adb.shell(self._serial, 67 | "/data/local/tmp/atx-agent server --nouia -d") 68 | 69 | async def open_identify(self): 70 | await adb.shell( 71 | self._serial, 72 | "am start -n com.github.uiautomator/.IdentifyActivity -e theme black" 73 | ) 74 | 75 | def _init_binaries(self): 76 | # minitouch, minicap, minicap.so 77 | d = self._device 78 | sdk = d.getprop("ro.build.version.sdk") # eg 26 79 | abi = d.getprop('ro.product.cpu.abi') # eg arm64-v8a 80 | abis = (d.getprop('ro.product.cpu.abilist').strip() or abi).split(",") 81 | # pre = d.getprop('ro.build.version.preview_sdk') # eg 0 82 | # if pre and pre != "0": 83 | # sdk = sdk + pre 84 | 85 | logger.debug("%s sdk: %s, abi: %s, abis: %s", self, sdk, abi, abis) 86 | 87 | stf_zippath = fetching.get_stf_binaries() 88 | zip_folder, _ = os.path.splitext(os.path.basename(stf_zippath)) 89 | prefix = zip_folder + "/node_modules/@devicefarmer/minicap-prebuilt/prebuilt/" 90 | self._push_stf(prefix + abi + "/lib/android-" + sdk + "/minicap.so", 91 | "/data/local/tmp/minicap.so", 92 | mode=0o644, 93 | zipfile_path=stf_zippath) 94 | self._push_stf(prefix + abi + "/bin/minicap", 95 | "/data/local/tmp/minicap", 96 | zipfile_path=stf_zippath) 97 | 98 | prefix = zip_folder + "/node_modules/minitouch-prebuilt/prebuilt/" 99 | self._push_stf(prefix + abi + "/bin/minitouch", 100 | "/data/local/tmp/minitouch", 101 | zipfile_path=stf_zippath) 102 | 103 | # atx-agent 104 | abimaps = { 105 | 'armeabi-v7a': 'atx-agent-armv7', 106 | 'arm64-v8a': 'atx-agent-armv7', 107 | 'armeabi': 'atx-agent-armv6', 108 | 'x86': 'atx-agent-386', 109 | } 110 | okfiles = [abimaps[abi] for abi in abis if abi in abimaps] 111 | if not okfiles: 112 | raise InitError("no avaliable abilist", abis) 113 | logger.debug("%s use atx-agent: %s", self, okfiles[0]) 114 | zipfile_path = fetching.get_atx_agent_bundle() 115 | self._push_stf(okfiles[0], 116 | "/data/local/tmp/atx-agent", 117 | zipfile_path=zipfile_path) 118 | 119 | def _push_stf(self, 120 | path: str, 121 | dest: str, 122 | zipfile_path: str, 123 | mode=0o755): 124 | """ push minicap and minitouch from zip """ 125 | with zipfile.ZipFile(zipfile_path) as z: 126 | if path not in z.namelist(): 127 | logger.warning("stf stuff %s not found", path) 128 | return 129 | src_info = z.getinfo(path) 130 | dest_info = self._device.sync.stat(dest) 131 | if dest_info.size == src_info.file_size and dest_info.mode & mode == mode: 132 | logger.debug("%s already pushed %s", self, path) 133 | return 134 | with z.open(path) as f: 135 | self._device.sync.push(f, dest, mode) 136 | 137 | def _init_apks(self): 138 | whatsinput_apk_path = fetching.get_whatsinput_apk() 139 | self._install_apk(whatsinput_apk_path) 140 | for apk_path in fetching.get_uiautomator_apks(): 141 | print("APKPath:", apk_path) 142 | self._install_apk(apk_path) 143 | 144 | def _install_apk(self, path: str): 145 | assert path, "Invalid %s" % path 146 | try: 147 | m = apkutils.APK(path).manifest 148 | info = self._device.package_info(m.package_name) 149 | if info and m.version_code == info[ 150 | 'version_code'] and m.version_name == info['version_name']: 151 | logger.debug("%s already installed %s", self, path) 152 | else: 153 | print(info, ":", m.version_code, m.version_name) 154 | logger.debug("%s install %s", self, path) 155 | self._device.install(path, force=True) 156 | except Exception as e: 157 | traceback.print_exc() 158 | logger.warning("%s Install apk %s error %s", self, path, e) 159 | 160 | async def _init_forwards(self): 161 | logger.debug("%s forward atx-agent", self) 162 | self._atx_proxy_port = await self.proxy_device_port(7912) 163 | self._whatsinput_port = await self.proxy_device_port(6677) 164 | 165 | port = self._adb_remote_port = freeport.get() 166 | logger.debug("%s adbkit start, port %d", self, port) 167 | 168 | self.run_background([ 169 | 'node', 'node_modules/adbkit/bin/adbkit', 'usb-device-to-tcp', 170 | '-p', 171 | str(self._adb_remote_port), self._serial 172 | ], 173 | silent=True) 174 | 175 | def addrs(self): 176 | def port2addr(port): 177 | return self._current_ip + ":" + str(port) 178 | 179 | return { 180 | "atxAgentAddress": port2addr(self._atx_proxy_port), 181 | "remoteConnectAddress": port2addr(self._adb_remote_port), 182 | "whatsInputAddress": port2addr(self._whatsinput_port), 183 | } 184 | 185 | def adb_call(self, *args): 186 | """ call adb with serial """ 187 | cmds = ['adb', '-s', self._serial] + list(args) 188 | logger.debug("RUN: %s", subprocess.list2cmdline(cmds)) 189 | return subprocess.call(cmds) 190 | 191 | async def adb_forward_to_any(self, remote: str) -> int: 192 | """ FIXME(ssx): not finished yet """ 193 | # if already forwarded, just return 194 | async for f in adb.forward_list(): 195 | if f.serial == self._serial: 196 | if f.remote == remote and f.local.startswith("tcp:"): 197 | return int(f.local[4:]) 198 | 199 | local_port = freeport.get() 200 | await adb.forward(self._serial, 'tcp:{}'.format(local_port), remote) 201 | return local_port 202 | 203 | async def proxy_device_port(self, device_port: int) -> int: 204 | """ reverse-proxy device:port to *:port """ 205 | local_port = await self.adb_forward_to_any("tcp:" + str(device_port)) 206 | listen_port = freeport.get() 207 | logger.debug("%s tcpproxy.js start *:%d -> %d", self, listen_port, 208 | local_port) 209 | self.run_background([ 210 | 'node', 'tcpproxy.js', 211 | str(listen_port), 'localhost', 212 | str(local_port) 213 | ], 214 | silent=True) 215 | return listen_port 216 | 217 | def run_background(self, *args, **kwargs): 218 | silent = kwargs.pop('silent', False) 219 | if silent: 220 | kwargs['stdout'] = subprocess.DEVNULL 221 | kwargs['stderr'] = subprocess.DEVNULL 222 | p = subprocess.Popen(*args, **kwargs) 223 | self._procs.append(p) 224 | return p 225 | 226 | async def getprop(self, name: str) -> str: 227 | value = await adb.shell(self._serial, "getprop " + name) 228 | return value.strip() 229 | 230 | async def properties(self): 231 | brand = await self.getprop("ro.product.brand") 232 | model = await self.getprop("ro.product.model") 233 | version = await self.getprop("ro.build.version.release") 234 | 235 | return { 236 | "serial": self._serial, 237 | "brand": brand, 238 | "version": version, 239 | "model": model, 240 | "name": device_names.get(model, model), 241 | } 242 | 243 | async def reset(self): 244 | """ 設備使用完后的清理工作 """ 245 | self.close() 246 | await adb.shell(self._serial, "input keyevent HOME") 247 | await self.init() 248 | 249 | def wait(self): 250 | for p in self._procs: 251 | p.wait() 252 | 253 | def close(self): 254 | for p in self._procs: 255 | p.terminate() 256 | self._procs = [] 257 | -------------------------------------------------------------------------------- /device_names.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding:utf-8 -*- 3 | # Author: ljw 4 | # Modified: codeskyblue 5 | # 6 | # 根据model对应设备名,model = $(adb shell getprop ro.product.model) 7 | 8 | device_names = { 9 | "1107": "OPPO 1107", 10 | "15 Plus": "魅族 15 Plus", 11 | "15": "魅族 15", 12 | "1501-A02": "360奇酷 F4", 13 | "1503-A01": "360奇酷 N4", 14 | "1505-A01": "360奇酷 N4S", 15 | "1505-A02": "360 N4S", 16 | "1515-A01": "360奇酷 Q5", 17 | "16 X": "魅族 16X", 18 | "1603-A03": "360奇酷 N4A", 19 | "1605-A01": "360奇酷 N5", 20 | "1607-A01": "360奇酷 N5S", 21 | "16th Plus": "魅族 16th Plus", 22 | "16th": "魅族 16th", 23 | "1707-A01": "360 N6", 24 | "1801-A01": "360 N6 Pro", 25 | "1803-A01": "360 N7 Lite", 26 | "1807-A01": "360 N7", 27 | "1809-A01": "360 N7 Pro", 28 | "2013022": "红米 手机", 29 | "2014011": "红米 1S", 30 | "2014501": "红米 1S ", 31 | "2014813": "红米 2A", 32 | "401SO": "索尼 Xperia Z3 ", 33 | "506SH": "夏普 AQUOS Xx3", 34 | "831C": "HTC One M8", 35 | "8676-A01": "酷派 大神 Note 3", 36 | "8681-M02": "360奇酷 青春版", 37 | "8692-A00": "360奇酷 旗舰版", 38 | "A0001": "一加 1", 39 | "A11": "OPPO A11", 40 | "A31": "OPPO A31", 41 | "ALE-UL00": "华为 P8 青春版", 42 | "ALP-AL00": "华为 Mate 10", 43 | "ALP-TL00": "华为 Mate 10 移动版", 44 | "ANE-AL00": "华为 nova 3e", 45 | "ARE-AL00": "华为 荣耀 8X Max", 46 | "ASUS_T00J": "华硕 ZenFone 5", 47 | "ASUS_X550": "华硕 飞马2 Plus", 48 | "ASUS_Z00UD": "Zenfone Selfie", 49 | "ASUS_Z012DE": "华硕 ZenFone 3", 50 | "ASUS_Z016DA": "华硕 ZenFone 3", 51 | "ASUS_Z01QD": "华硕 ROG", 52 | "ATH-AL00": "华为 荣耀 7i", 53 | "ATH-UL00": "华为 荣耀 7i", 54 | "ATU-AL10": "华为 畅享 8e", 55 | "AUM-AL20": "华为 荣耀畅玩 7A", 56 | "BAC-AL00": "华为 nova 2 Plus", 57 | "BAC-TL00": "华为 nova 2 Plus", 58 | "BKL-AL00": "华为 荣耀 V10", 59 | "BKL-AL20": "华为 荣耀 V10", 60 | "BLA-AL00": "华为 Mate 10 Pro", 61 | "BLN-AL10": "华为 荣耀畅玩 6X", 62 | "BLN-AL20": "华为 荣耀畅玩 6X", 63 | "BLN-AL30": "华为 荣耀畅玩 6X", 64 | "BLN-AL40": "华为 荣耀畅玩 6X", 65 | "BLN-TL10": "华为 荣耀畅玩 6X", 66 | "BND-AL00": "华为 荣耀畅玩 7X", 67 | "BND-AL10": "华为 荣耀畅玩 7X", 68 | "BTV-DL09": "华为 MediaPad M3 LTE", 69 | "BTV-W09": "华为 MediaPad M3", 70 | "C105": "酷派 cool S1", 71 | "C106": "酷派 cool1", 72 | "C107-9": "酷派 cool 1C", 73 | "C2105": "索尼 C2105", 74 | "C8817D": "华为 荣耀畅玩4", 75 | "CAM-AL00": "华为 荣耀畅玩 5A", 76 | "CAM-TL00": "华为 荣耀畅玩5A", 77 | "CHE-TL00": "华为 荣耀畅玩 4X", 78 | "Che1-CL20": "华为 荣耀畅玩 4X", 79 | "Che2-TL00M": "华为 荣耀畅玩 4X", 80 | "CHM-CL00": "华为 荣耀 4C 电信版", 81 | "CHM-UL00": "华为 荣耀 4C", 82 | "CHM-TL00H": "华为 荣耀畅玩 4C", 83 | "CLT-AL00": "华为 P20", 84 | "CLT-AL01": "华为 P20 Pro", 85 | "CLT-TL01": "华为 P20 Pro", 86 | "COL-AL10": "华为 荣耀 10", 87 | "Coolpad 8297-T01": "酷派 大神 F1", 88 | "Coolpad 8297W": "酷派 大神 F1", 89 | "Coolpad 8675": "酷派 大神 F2 移动版", 90 | "Coolpad 8675-A": "酷派 大神 F2", 91 | "Coolpad 8702D": "酷派 8720D", 92 | "Coolpad 8705": "酷派 8705", 93 | "Coolpad Y70 C": "酷派 Y70 C", 94 | "Coolpad Y75": "酷派 锋尚", 95 | "Coolpad Y803-9": "酷派 锋尚3", 96 | "Coolpad Y80D": "酷派 锋尚", 97 | "COR-AL00": "华为 荣耀Play", 98 | "COR-AL10": "华为 荣耀Play", 99 | "CUN-TL00": "华为 荣耀畅玩 5", 100 | "d-02H": "华为 docomo dtab Compact", 101 | "DE106": "坚果 R1", 102 | "DIG-AL00": "华为 畅享 6S", 103 | "DLI-AL10": "华为 荣耀畅玩 6A", 104 | "DOOV L9": "朵唯 L9", 105 | "DOOV S2": "朵唯 S2", 106 | "DRA-AL00": "华为 畅享 8e ", 107 | "DUA-AL00": "华为 荣耀畅玩 7", 108 | "DUK-AL20": "华为 荣耀 V9", 109 | "E6": "金立 E6", 110 | "E6533": "索尼 Xperia Z3", 111 | "E6653": "索尼 Xperia Z", 112 | "E6883": "索尼 Xperia Z5", 113 | "E6mini": "金立 E6 mini", 114 | "E7": "金立 E7", 115 | "EDI-AL10": "华为 荣耀 Note8", 116 | "ELE-AL00": "华为 P30", 117 | "EML-AL00": "华为 P20", 118 | "EVA-AL00": "华为 P9", 119 | "EVA-AL10": "华为 P9", 120 | "EVA-TL00": "华为 P9 移动版", 121 | "EVR-AL00": "华为 Mate 20 X", 122 | "F-01H": "富士通 ARROWS ", 123 | "F-01J": "富士通 ARROWS", 124 | "F-02G": "富士通 F-02G", 125 | "F-02H": "富士通 ARROWS NX ", 126 | "F-04G": "富士通 ARROWS NX", 127 | "F-04H": "ARROWS Tab", 128 | "F100": "金立 F100", 129 | "F100S": "金立 F100", 130 | "F103": "金立 F103", 131 | "F106": "金立 F106", 132 | "F301": "金立 F301", 133 | "F8332": "索尼 Xperia XZ", 134 | "FIG-AL00": "华为 畅享 7S", 135 | "FLA-AL10": "华为 畅享 8 Plus", 136 | "FRD-AL00": "华为 荣耀 8", 137 | "FRD-AL10": "华为 荣耀 8", 138 | "FS8010": "夏普 AQUOS S2", 139 | "G620S-UL00": "华为 荣耀畅玩4", 140 | "G8232": "索尼 Xperia XZs", 141 | "GEM-703L": "华为 荣耀 X2", 142 | "GIONEE F205L": "金立 F205", 143 | "GIONEE F6L": "金立 F6", 144 | "GIONEE GN5007": "金立 大金刚 2", 145 | "GIONEE M7": "金立 M7", 146 | "GIONEE S10": "金立 S10", 147 | "GIONEE S10L": "金立 S10 移动版", 148 | "GIONEE S11": "金立 S11", 149 | "GIONEE S11S": "金立 S11s", 150 | "GN151": "金立 GN151", 151 | "GN5001S": "金立 金钢", 152 | "GN5003": "金立 大金钢", 153 | "GN5005": "金立 金钢 2", 154 | "GN8002S": "金立 M6 Plus", 155 | "GN8003": "金立 M6", 156 | "GN9000": "金立 S5.5", 157 | "GN9000L": "金立 S5.5L", 158 | "GN9005": "金立 S5.1", 159 | "GN9006": "金立 S7", 160 | "GN9012": "金立 S6 Pro", 161 | "GRA-A0": "酷派 酷玩 6C", 162 | "GT-810": "掠夺者8 A5002", 163 | "GT-I9060I": "三星 Galaxy Grand Neo Plus", 164 | "GT-I9152": "三星 GT-I9152", 165 | "GT-I9158P": "三星 Galaxy Mega", 166 | "GT-I9208": "三星 GT-I9208", 167 | "GT-I9300": "三星 Galaxy S3", 168 | "GT-I9500": "三星 GalaxyS4", 169 | "GT-I9507V": "三星 Galaxy S4", 170 | "GT-I9508": "三星 Galaxy S4", 171 | "GT-N7100": "三星 Galaxy Note 2", 172 | "GT-N7108": "三星 Galaxy Note 2", 173 | "H30-T00": "华为 荣耀3C", 174 | "H30-T10": "华为 荣耀3C", 175 | "H30-U10": "华为 荣耀3C", 176 | "H60-L01": "华为 荣耀 6", 177 | "H60-L03": "华为 荣耀 6", 178 | "H8296": "索尼 Xperia XZ2", 179 | "Hisense A2": "海信 A2", 180 | "Hisense E76": "海信 E76", 181 | "HLA NOTE1-L": "红辣椒 Note", 182 | "HM 1S": "红米 1S", 183 | "HM 2A": "红米 2A", 184 | "HM NOTE 1LTE": "红米 Note", 185 | "HM NOTE 1S": "红米 Note", 186 | "HM NOTE 1TD": "红米 Note", 187 | "HMA-AL00": "华为 Mate 20", 188 | "HMA-TL00": "华为 Mate 20", 189 | "HTC 2Q4D200": "HTC U11+", 190 | "HTC 2Q4R400": "HTC U11 EYEs", 191 | "HTC 2Q55300": "HTC U12+", 192 | "HTC 802d": "HTC One 电信版", 193 | "HTC 802w": "HTC One M7", 194 | "HTC 8088": "HTC One max", 195 | "HTC 9088": "HTC Butterfly S", 196 | "HTC A9w": "HTC One A9", 197 | "HTC D10w": "HTC Desire 10 Pro", 198 | "HTC D816w": "HTC Desire 816", 199 | "HTC D820u": "HTC D820u", 200 | "HTC D830u": "HTC Desire 830", 201 | "HTC Desire EYE": "HTC Desire Eye", 202 | "HTC M10u": "HTC 10", 203 | "HTC M8t": "HTC One E8", 204 | "HTC One A9": "HTC One A9", 205 | "HTC One M9PLUS": "HTC M9 台版", 206 | "HTC One X9 dual sim": "HTC One X9", 207 | "HTC U Ultra": "HTC U Ultra 港版", 208 | "HTC U-1w": "HTC U Ultra", 209 | "HTC_M10h": "HTC 10", 210 | "HTC_M9u": "HTC One M9", 211 | "HUAWEI CAZ-AL10": "华为 nova", 212 | "HUAWEI CAZ-TL20": "华为 nova", 213 | "HUAWEI G606-T00": "华为 G606-T00", 214 | "HUAWEI G610-U00": "华为 G610", 215 | "HUAWEI G7-TL00": "华为 G7", 216 | "HUAWEI G700-T00": "华为 G700-T00", 217 | "HUAWEI G700-U00": "华为 G700-U00", 218 | "HUAWEI G750-T01": "华为 荣耀3X", 219 | "HUAWEI GRA-CL10": "华为 P8 高配版", 220 | "HUAWEI GRA-TL00": "华为 P8", 221 | "HUAWEI GRA-UL00": "华为 P8", 222 | "HUAWEI M2-801W": "华为 MediaPad", 223 | "HUAWEI M2-A01W": "华为 MediaPad", 224 | "HUAWEI MLA-AL00": "华为 麦芒 5", 225 | "HUAWEI MLA-AL10": "华为 麦芒 5", 226 | "HUAWEI MLA-TL10": "华为 G9 Plus", 227 | "HUAWEI MLA-UL00": "华为 G9 Plus", 228 | "HUAWEI MT2-L01": "华为 Mate 2", 229 | "HUAWEI MT7-TL00": "华为 Mate 7", 230 | "HUAWEI MT7-TL10": "华为 Mate 7", 231 | "HUAWEI NXT-AL10": "华为 Mate 8", 232 | "HUAWEI NXT-DL00": "华为 Mate 8", 233 | "HUAWEI NXT-TL00": "华为 Mate 8 移动版", 234 | "HUAWEI P6 S-U06": "华为 P6 S", 235 | "HUAWEI P6-T00": "华为 P6", 236 | "HUAWEI P7-L00": "华为 P7", 237 | "HUAWEI P7-L05": "华为 P7", 238 | "HUAWEI P7-L07": "华为 P7", 239 | "HUAWEI P8max": "华为 P8max", 240 | "HUAWEI RIO-AL00": "华为 麦芒 4", 241 | "HUAWEI RIO-CL00": "华为 麦芒 4", 242 | "HUAWEI RIO-TL00": "华为 G7 Plus", 243 | "HUAWEI TAG-AL00": "华为 畅享 5S", 244 | "HUAWEI TAG-TL00": "华为 畅享 5S", 245 | "HUAWEI TIT-AL00": "华为 畅享 5", 246 | "HUAWEI VNS-AL00": "华为 G9", 247 | "HUAWEI VNS-DL00": "华为 G9 青春版", 248 | "HUAWEI VNS-L21": "华为 P9 Lite", 249 | "HUAWEI VNS-TL00": "华为 G9", 250 | "HWI-AL00": "华为 nova 2S", 251 | "HWT31": "华为 au Qua tab", 252 | "IM-A870L": "泛泰 VEGA", 253 | "INE-AL00": "华为 nova 3i", 254 | "JKM-AL00": "华为 畅享 9 Plus", 255 | "JKM-AL00b": "华为 畅享 9 Plus", 256 | "JMM-AL00": "华为 荣耀 V9 Play", 257 | "JSN-AL00": "华为 荣耀 8X", 258 | "JSN-AL00a": "华为 荣耀 8X", 259 | "K011": "华硕 Memo Pad", 260 | "KIW-AL10": "华为 荣耀畅玩 5X", 261 | "KIW-TL00H": "华为 荣耀畅玩 5X", 262 | "KNT-AL10": "华为 荣耀 V8", 263 | "KNT-AL20": "华为 荣耀 V8", 264 | "KNT-TL10": "华为 荣耀 V8", 265 | "KNT-UL10": "华为 荣耀 V8", 266 | "KOB-W09": "华为 荣耀畅玩", 267 | "KYT31": "KYOCERA au Qua tab", 268 | "L36h": "索尼 Z", 269 | "L39h": "索尼 Xperia Z1", 270 | "L50w": "索尼 Xperia Z2", 271 | "LA7-L": "小辣椒 7", 272 | "LDN-AL00": "华为 畅享 8", 273 | "Le X507": "乐视 乐1s", 274 | "Le X620": "乐视 乐2", 275 | "Le X625": "乐视 乐2 Pro", 276 | "Le X820": "乐视 乐Max 2", 277 | "Lenovo A788t": "联想 A788t", 278 | "Lenovo A808t": "联想 黄金斗士A8", 279 | "Lenovo A808t-i": "联想 黄金斗士A8", 280 | "Lenovo A850": "联想 A850", 281 | "Lenovo K30-T": "联想 乐檬 K3", 282 | "Lenovo K900": "联想 K900", 283 | "Lenovo K920": "联想 K920", 284 | "Lenovo L78011": "联想 Z5", 285 | "Lenovo L78071": "联想 Z5s", 286 | "Lenovo P2c72": "联想 VIBE P2", 287 | "Lenovo P70-t": "联想 P70t", 288 | "Lenovo PB2-690N": "联想 Phab2 Pro", 289 | "Lenovo S60-t": "联想 S60t", 290 | "Lenovo S850t": "联想 S850t", 291 | "Lenovo S868t": "联想 S868t", 292 | "Lenovo S90-t": "联想 笋尖S90", 293 | "Lenovo X2-TO": "联想 VIBE X2", 294 | "Letv X500": "乐视 乐1s", 295 | "LEX720": "乐视 乐Pro3", 296 | "LG-D486": "LG D486", 297 | "LG-D855": "LG G3", 298 | "LG-E985T": "LG E985T", 299 | "LG-H818": "LG G4", 300 | "LG-H860": "LG G5", 301 | "LG-H968": "LG V10", 302 | "LG-M250": "LG K10 2017", 303 | "LGMS210": "LG Aristo MS210", 304 | "LGT31": "LGT 31", 305 | "LGV32": "LG G4", 306 | "LLD-AL00": "华为 荣耀 9 青春版", 307 | "LLD-AL20": "华为 荣耀 9i", 308 | "LLD-AL30": "华为 荣耀 9i", 309 | "LND-AL30": "华为 荣耀畅玩 7C", 310 | "LON-AL00": "华为 Mate 9 Pro", 311 | "LYA-AL00": "华为 Mate 20 Pro", 312 | "LYA-AL10": "华为 Mate 20 Pro", 313 | "M040": "魅族 MX2", 314 | "M1 E": "魅族 魅蓝 E", 315 | "m1 metal": "魅族 魅蓝 Metal", 316 | "m1 note": "魅族 魅蓝 Note", 317 | "m1": "魅族 魅蓝", 318 | "M15": "魅族 M15", 319 | "M1813": "魅族 V8 高配版", 320 | "M1816": "魅族 V8 标准版", 321 | "M1852": "魅族 X8", 322 | "M2 E": "魅族 魅蓝 E2", 323 | "m2 note": "魅族 魅蓝 Note 2", 324 | "m2": "魅族 魅蓝 2", 325 | "M3 Max": "魅族 魅蓝 Max", 326 | "m3 note": "魅族 魅蓝 Note 3", 327 | "M351": "魅族 MX3", 328 | "M3s": "魅族 魅蓝 3S", 329 | "M3X": "魅族 魅蓝 X", 330 | "M463C": "魅族 魅蓝 Note 电信版", 331 | "M5 Note": "魅族 魅蓝 Note 5", 332 | "M5": "魅族 魅蓝 5", 333 | "M5s": "魅族 魅蓝 5S", 334 | "M6 Note": "魅族 魅蓝 Note 6", 335 | "M6": "魅族 魅蓝 6", 336 | "M623C": "中国移动 A1", 337 | "M651CY": "中国移动 A3", 338 | "M760": "中国移动 A4s", 339 | "Mblu E3": "魅族 魅蓝 E3", 340 | "Meitu M4": "美图 M4", 341 | "Meizu 6T": "魅族 魅蓝 6T", 342 | "MEIZU E3": "魅族 魅蓝 E3", 343 | "Meizu S6": "魅族 魅蓝 S6", 344 | "MHA-AL00": "华为 Mate 9", 345 | "MHA-L29": "华为 Mate 9 国际版", 346 | "MI 2": "小米 2", 347 | "MI 2A": "小米 2A", 348 | "MI 3": "小米 3 移动版", 349 | "MI 3C": "小米 3 电信版", 350 | "MI 3W": "小米 3 联通版", 351 | "MI 4LTE": "小米 4 ", 352 | "MI 4S": "小米 4S", 353 | "MI 4W": "小米 4 联通版", 354 | "MI 5": "小米 5", 355 | "MI 5C": "小米 5C", 356 | "MI 5s Plus": "小米 5S Plus", 357 | "MI 5s": "小米 5S", 358 | "MI 5X": "小米 5X", 359 | "MI 6": "小米 6", 360 | "MI 6X": "小米 6X", 361 | "MI 8 Explorer Edition": "小米 8 透明探索版", 362 | "MI 8 Lite": "小米 8 青春版", 363 | "MI 8 SE": "小米 8 SE", 364 | "MI 8 UD": "小米 8", 365 | "MI 8": "小米 8", 366 | "MI 9": "小米 9", 367 | "Mi A1": "小米 A1", 368 | "MI MAX": "小米 MAX", 369 | "MI MAX 2": "小米 MAX 2", 370 | "MI MAX 3": "小米 Max 3", 371 | "MI MAX": "小米 Max", 372 | "Mi Note 2": "小米 Note 2", 373 | "Mi Note 3": "小米 Note 3", 374 | "MI NOTE LTE": "小米 Note", 375 | "MI NOTE Pro": "小米 Note 顶配版", 376 | "MI PAD 2": "小米 平板2", 377 | "MI PAD 3": "小米 平板3", 378 | "MI PAD 4 PLUS": "小米 平板4 Plus", 379 | "MI PAD 4": "小米 平板4", 380 | "MI PAD": "小米 平板", 381 | "Mi-4c": "小米 4C 标准版", 382 | "MI-ONE Plus": "小米 1", 383 | "MIX 2": "小米 MIX 2", 384 | "MIX 2S": "小米 MIX 2S", 385 | "MIX 3": "小米 MIX 3", 386 | "MIX": "小米 MIX", 387 | "Moto E (4)": "Moto E", 388 | "Moto G (5) Plus": "Moto G5 Plus", 389 | "Moto G (5)": "Moto G5", 390 | "Moto X Pro": "Moto X Pro", 391 | "MP1503": "美图 M6", 392 | "MX4 Pro": "魅族 MX4 Pro", 393 | "MX4": "魅族 MX4", 394 | "MX5": "魅族 MX5", 395 | "MX6": "魅族 MX6", 396 | "MYA-AL10": "华为 荣耀畅玩 6", 397 | "MYA-L22": "华为 Y5", 398 | "N1": "诺基亚 N1", 399 | "N1T": "OPPO N1T", 400 | "N5207": "OPPO N3", 401 | "NCE-AL00": "华为 畅享 6", 402 | "NCE-AL10": "华为 畅享 6", 403 | "NCE-TL10": "华为 畅享 6", 404 | "NEM-AL10": "华为 荣耀畅玩5C", 405 | "NEM-TL00": "华为 荣耀畅玩5C", 406 | "NEM-TL00H": "华为 荣耀畅玩5C", 407 | "NEO-AL00": "华为 Mate RS", 408 | "Nexus 4": "Google Nexus 4", 409 | "Nexus 5": "Google Nexus 5", 410 | "Nexus 5X": "Google Nexus 5X", 411 | "Nexus 6": "Google Nexus 6", 412 | "Nexus 6P": "Google Nexus 6P", 413 | "Nexus 9": "Google Nexus 9", 414 | "Nokia 7 plus": "诺基亚 7 Plus", 415 | "Nokia 8 Sirocco": "诺基亚 8 Sirocco", 416 | "Nokia X5": "诺基亚 X5", 417 | "Nokia X6": "诺基亚 X6", 418 | "NTS-AL00": "华为 荣耀 Magic", 419 | "NX403A": "努比亚 Z5S mini", 420 | "NX508J": "努比亚 Z9", 421 | "NX513J": "努比亚 My", 422 | "NX529J": "努比亚 Z11 mini", 423 | "NX531J": "努比亚 Z11", 424 | "NX563J": "努比亚 Z17", 425 | "NX569J": "努比亚 Z17 mini ", 426 | "NX573J": "努比亚 M2", 427 | "NX575J": "努比亚 N2", 428 | "NX595J": "努比亚 Z17S", 429 | "NX609J": "努比亚 红魔手机", 430 | "NX611J": "努比亚 Z18 mini", 431 | "NXT-AL10": "华为 Mate 8", 432 | "OC105": "锤子 坚果 3", 433 | "OD103": "锤子 坚果 Pro", 434 | "OE106": "锤子 坚果 Pro 2S", 435 | "ONE A2001": "一加 2", 436 | "ONEPLUS A3000": "一加 3", 437 | "ONEPLUS A3010": "一加 3T", 438 | "ONEPLUS A5000": "一加 5", 439 | "ONEPLUS A5010": "一加 5T", 440 | "ONEPLUS A6000": "一加 6", 441 | "OPPO A30": "OPPO A30", 442 | "OPPO A33": "OPPO A33", 443 | "OPPO A33m": "OPPO A33", 444 | "OPPO A37m": "OPPO A37", 445 | "OPPO A53": "OPPO A53", 446 | "OPPO A53m": "OPPO A53", 447 | "OPPO A57": "OPPO A57", 448 | "OPPO A57t": "OPPO A57 移动版", 449 | "OPPO A59m": "OPPO A59", 450 | "OPPO A59s": "OPPO A59s", 451 | "OPPO A73": "OPPO A73", 452 | "OPPO A77": "OPPO A77", 453 | "OPPO A79": "OPPO A79", 454 | "OPPO A83": "OPPO A83", 455 | "OPPO R11 Plus": "OPPO R11 Plus", 456 | "OPPO R11 Plusk": "OPPO R11 Plus", 457 | "OPPO R11": "OPPO R11", 458 | "OPPO R11s Plus": "OPPO R11s Plus", 459 | "OPPO R11s": "OPPO R11s", 460 | "OPPO R11st": "OPPO R11s", 461 | "OPPO R11t": "OPPO R11", 462 | "OPPO R7": "OPPO R7", 463 | "OPPO R7s": "OPPO R7s ", 464 | "OPPO R7sm": "OPPO R7s 全网通", 465 | "OPPO R9 Plusm A": "OPPO R9 Plus", 466 | "OPPO R9 Plustm A": "OPPO R9 Plus", 467 | "OPPO R9m": "OPPO R9", 468 | "OPPO R9s Plus": "OPPO R9s Plus", 469 | "OPPO R9s": "OPPO R9s", 470 | "OPPO R9st": "OPPO R9s 移动版", 471 | "OPPO R9sk": "OPPO R9s", 472 | "OPPO R9tm": "OPPO R9", 473 | "OS105": "锤子 坚果 Pro 2", 474 | "PAAM00": "OPPO R15", 475 | "PACM00": "OPPO R15 梦境版", 476 | "PADM00": "OPPO A3", 477 | "PAFM00": "OPPO Find X", 478 | "PAR-AL00": "华为 nova 3", 479 | "PBAM00": "OPPO A5", 480 | "PBBM00": "OPPO A7x", 481 | "PBEM00": "OPPO R17", 482 | "PCT-AL10": "荣耀 V20", 483 | "PE-CL00": "华为 荣耀 6 Plus", 484 | "PE-TL10": "华为 荣耀 6 Plus", 485 | "PH-1": "Essential Phone", 486 | "PIC-AL00": "华为 nova 2", 487 | "Pixel 2 XL": "Google Pixel 2 XL", 488 | "Pixel XL": "Google Pixel XL", 489 | "Pixel": "Google Pixel", 490 | "PLAYER": "小辣椒 Player", 491 | "PLK-AL10": "华为 荣耀 7", 492 | "PLK-CL00": "华为 荣耀 7", 493 | "PLK-TL01H": "华为 荣耀 7", 494 | "PLK-UL00": "华为 荣耀 7", 495 | "POCOPHONE F1": "POCOPHONE F1", 496 | "PP5600": "PPTV M1", 497 | "PRA-AL00": "华为 荣耀 8 青春版", 498 | "PRA-AL00X": "华为 荣耀 8 青春版", 499 | "PRO 5": "魅族 Pro 5", 500 | "PRO 6 Plus": "魅族 Pro 6 Plus", 501 | "PRO 6": "魅族 Pro 6", 502 | "PRO 6s": "魅族 Pro 6s", 503 | "PRO 7 Plus": "魅族 Pro 7 Plus", 504 | "PRO 7-S": "魅族 Pro 7", 505 | "R11s": "OPPO R11s", 506 | "R2017": "OPPO R2017", 507 | "R6007": "OPPO R6007", 508 | "R7007": "OPPO R3", 509 | "R7c": "OPPO R7 电信版", 510 | "R7Plus": "OPPO R7 Plus 移动版", 511 | "R7Plusm": "OPPO R7 Plus 全网通", 512 | "R8107": "OPPO R8107", 513 | "R819T": "OPPO R819T", 514 | "R8207": "OPPO R1C", 515 | "R821T": "OPPO R821T", 516 | "R831S": "OPPO R831s", 517 | "R831T": "OPPO R831T", 518 | "ramos MOS 1": "蓝魔 MOS1", 519 | "ramos MOS1max": "蓝魔 MOS1 MAX", 520 | "Redmi 3": "红米 3", 521 | "Redmi 3S": "红米 3S", 522 | "Redmi 3X": "红米 3X", 523 | "Redmi 4": "红米 4", 524 | "Redmi 4A": "红米 4A", 525 | "Redmi 4X": "红米 4X", 526 | "Redmi 5 Plus": "红米 5 Plus", 527 | "Redmi 5": "红米 5", 528 | "Redmi 5A": "红米 5A", 529 | "Redmi 6": "红米 6", 530 | "Redmi 6 Pro": "红米 6 Pro", 531 | "Redmi 6A": "红米 6A", 532 | "Redmi Note 2": "红米 Note 2", 533 | "Redmi Note 3": "红米 Note 3", 534 | "Redmi Note 4": "红米 Note 4", 535 | "Redmi Note 4X": "红米 Note 4X", 536 | "Redmi Note 5": "红米 Note 5", 537 | "Redmi Note 5A": "红米 Note 5A", 538 | "Redmi Note 7": "红米 Note 7", 539 | "Redmi Pro": "红米 Pro", 540 | "Redmi S2": "红米 S2", 541 | "RNE-AL00": "华为 麦芒 6", 542 | "RVL-AL09": "华为 荣耀 Note10", 543 | "S39h": "索尼 Xperia C", 544 | "S9": "金立 S9", 545 | "SAMSUNG-SM-G930A": "三星 Galaxy S7 美版", 546 | "SC-03J": "三星 Galaxy S8", 547 | "SC-04E": "三星 Galaxy S4", 548 | "SC-04F": "三星 Galaxy S5", 549 | "SC-04G": "三星 Galaxy S6 Edg", 550 | "SC-04J": "三星 Galaxy Feel", 551 | "SC-05G": "三星 Galaxy S6", 552 | "SCH-I939I": "三星 Galaxy S3 Neo+", 553 | "SCL-AL00": "华为 荣耀 4A", 554 | "SCV31": "三星 Galaxy S6 Edge", 555 | "SCV35": "三星 Galaxy S8+", 556 | "SCV36": "三星 Galaxy S8", 557 | "SD4930UR": "Amzon Fire Phone", 558 | "SGH-N075T": "三星 Galaxy J", 559 | "SKR-A0": "黑鲨 游戏手机", 560 | "SLA-AL00": "华为 畅享 7", 561 | "SM-A3000": "三星 Galaxy A3", 562 | "SM-A5000": "三星 Galaxy A5", 563 | "SM-A5100": "三星 Galaxy A5", 564 | "SM-A520S": "三星 Galaxy A5", 565 | "SM-A530F": "三星 Galaxy A8", 566 | "SM-A7000": "三星 Galaxy A7", 567 | "SM-C5010": "三星 Galaxy C5 Pro", 568 | "SM-C7000": "三星 Galaxy C7", 569 | "SM-C7010": "三星 Galaxy C7 Pro", 570 | "SM-C7100": "三星 Galaxy C8", 571 | "SM-C9000": "三星 Galaxy C9 Pro", 572 | "SM-E7000": "三星 Galaxy E7", 573 | "SM-G5309W": "三星 Galaxy GRAND", 574 | "SM-G530H": "三星 Galaxy Grand Prime ", 575 | "SM-G532F": "三星 Grand Prime Plus", 576 | "SM-G5500": "三星 Galaxy On5", 577 | "SM-G6000": "三星 Galaxy On7", 578 | "SM-G7108V": "三星 Galaxy Grand 2", 579 | "SM-G8508S": "三星 Galaxy Alpha", 580 | "SM-G8750": "三星 Galaxy S ", 581 | "SM-G9006W": "三星 Galaxy S5", 582 | "SM-G9008V": "三星 Galaxy S5", 583 | "SM-G900F": "三星 Galaxy S5", 584 | "SM-G900V": "三星 Galaxy S5", 585 | "SM-G910S": "三星 Galaxy Round", 586 | "SM-G9200": "三星 Galaxy S6", 587 | "SM-G920F": "三星 Galaxy S6", 588 | "SM-G920V": "三星 Galaxy S6", 589 | "SM-G9250": "三星 Galaxy S6 Edge", 590 | "SM-G925V": "三星 Galaxy S6 Edge", 591 | "SM-G9280": "三星 Galaxy S6 Edge+", 592 | "SM-G9300": "三星 Galaxy S7", 593 | "SM-G9308": "三星 Galaxy S7", 594 | "SM-G930F": "三星 Galaxy S7", 595 | "SM-G9350": "三星 Galaxy S7 Edge", 596 | "SM-G935F": "三星 Galaxy S7 Edge", 597 | "SM-G935V": "三星 Galaxy S7 Edge", 598 | "SM-G9500": "三星 Galaxy S8", 599 | "SM-G950F": "三星 Galaxy S8", 600 | "SM-G9550": "三星 Galaxy S8+", 601 | "SM-G955F": "三星 Galaxy S8+", 602 | "SM-G955N": "三星 Galaxy S8+", 603 | "SM-G9600": "三星 Galaxy S9", 604 | "SM-G960F": "三星 Galaxy S9", 605 | "SM-G960N": "三星 Galaxy S9 ", 606 | "SM-G960U": "三星 Galaxy S9", 607 | "SM-G965N": "三星 Galaxy S9+", 608 | "SM-G965U": "三星 Galaxy S9+", 609 | "SM-J5008": "三星 Galaxy J5", 610 | "SM-J7008": "三星 Galaxy J7", 611 | "SM-J701F": "三星 Galaxy J7", 612 | "SM-J7109": "三星 Galaxy J7", 613 | "SM-J730GM": "三星 Galaxy J7 Pro", 614 | "SM-N9002": "三星 Galaxy Note 3", 615 | "SM-N9008V": "三星 Galaxy Note 3", 616 | "SM-N900A": "三星 Galaxy Note 3", 617 | "SM-N9100": "三星 Galaxy Note 4", 618 | "SM-N910U": "三星 Galaxy Note 4", 619 | "SM-N9200": "三星 Galaxy Note 5", 620 | "SM-N9208": "三星 Galaxy Note 5", 621 | "SM-N920K": "三星 Galaxy Note 5", 622 | "SM-N9500": "三星 Galaxy Note 8", 623 | "SM-N950N": "三星 Galaxy Note 8", 624 | "SM-N950U1": "三星 Galaxy Note 8", 625 | "SM-N9600": "三星 Galaxy Note 9", 626 | "SM-P601": "三星 Galaxy Note 10.1", 627 | "SM-T110": "三星 Galaxy Tab3", 628 | "SM-T113": "三星 Tab E Lite", 629 | "SM-T310": "三星 GALAXY Tab3", 630 | "SM-T560": "三星 Galaxy Tab E", 631 | "SM-T580": "三星 Galaxy Tab A", 632 | "SM-T817V": "三星 Galaxy Tab S2", 633 | "SM-W2017": "三星 W2017", 634 | "SM701": "锤子 T1", 635 | "SM901": "锤子 M1", 636 | "SM919": "锤子 M1L", 637 | "SNE-AL00": "华为 麦芒 7", 638 | "SO-03G": "索尼 Xperia Z4", 639 | "SO-04H": "索尼 Xperia X Performance", 640 | "SO-04J": "索尼 Xperia XZ Premium", 641 | "SOV31": "索尼 Xperia Z4", 642 | "SOV35": "索尼 Xperia XZs", 643 | "STF-AL00": "华为 荣耀 9", 644 | "STF-AL10": "华为 荣耀 9", 645 | "TA-1000": "诺基亚 6", 646 | "TA-1041": "诺基亚 7", 647 | "TA-1052": "诺基亚 8", 648 | "TCL 520": "TCL 520", 649 | "TCL 750": "TCL 750", 650 | "TNY-AL00": "荣耀 Magic2", 651 | "TRT-AL00": "华为 畅享 7 Plus", 652 | "TRT-AL00A": "华为 畅享 7 Plus", 653 | "U10": "魅族 魅蓝 U10", 654 | "V1732A": "vivo Y81s", 655 | "V1809A": "vivo X23", 656 | "V1813A": "vivo Y97", 657 | "V1813BA": "vivo Z3", 658 | "V182": "金立 V182", 659 | "V183": "金立 V183", 660 | "V185": "金立 V185", 661 | "V188S": "金立 V188S", 662 | "V8": "天语 V8", 663 | "VCR-A0": "酷派 酷玩 6", 664 | "VCE-AL00": "华为 Nova 4", 665 | "VIE-AL10": "华为 P9 Plus", 666 | "Vivo 5R": "BLU vivo 5R", 667 | "vivo NEX A": "vivo NEX 标准版", 668 | "vivo NEX S": "vivo NEX 旗舰版", 669 | "vivo V3": "vivo V3", 670 | "vivo V3M A": "vivo V3M", 671 | "vivo V3Max A": "vivo V3Max", 672 | "vivo V3Max": "vivo V3Max", 673 | "vivo X20A": "vivo X20", 674 | "vivo X20Plus A": "vivo X20 Plus", 675 | "vivo X20Plus UD": "vivo X20 PlusUD", 676 | "vivo X20Plus": "vivo X20 Plus", 677 | "vivo X21A": "vivo X21", 678 | "vivo X21i A": "vivo X21i", 679 | "vivo X21UD A": "vivo X21UD", 680 | "vivo X3L": "vivo X3L", 681 | "vivo X3t": "vivo X3T", 682 | "vivo X5L": "vivo X5L", 683 | "vivo X5M": "vivo X5M", 684 | "vivo X5Max V": "vivo X5 Max V", 685 | "vivo X5Max+": "vivo X5Max+", 686 | "vivo X5Pro D": "vivo X5Pro", 687 | "vivo X5Pro V": "vivo X5Pro V", 688 | "vivo X5S L": "vivo X5 SL", 689 | "vivo X6A": "vivo X6A", 690 | "vivo X6D": "vivo X6D", 691 | "vivo X6Plus A": "vivo X6 Plus A", 692 | "vivo X6Plus D": "vivo X6 Plus", 693 | "vivo X6Plus L": "vivo X6 Plus", 694 | "vivo X6S A": "vivo X6S", 695 | "vivo X6SPlus D": "vivo X6S Plus", 696 | "vivo X7": "vivo X7", 697 | "vivo X7Plus": "vivo X7 Plus", 698 | "vivo X9": "vivo X9", 699 | "vivo X9i": "vivo X9", 700 | "vivo X9L": "vivo X9L", 701 | "vivo X9Plus": "vivo X9 Plus", 702 | "vivo X9s Plus": "vivo X9s Plus", 703 | "vivo X9s": "vivo X9s", 704 | "vivo Xplay3S": "vivo Xplay 3S", 705 | "vivo Xplay5A": "vivo Xplay5", 706 | "vivo Xplay6": "vivo Xplay6", 707 | "vivo Y11i T": "vivo Y11iT", 708 | "vivo Y13L": "vivo Y13L", 709 | "vivo Y23L": "vivo Y23L", 710 | "vivo Y27": "vivo Y27", 711 | "vivo Y29L": "vivo Y29L", 712 | "vivo Y31A": "vivo Y31A", 713 | "vivo Y33": "vivo Y33", 714 | "vivo Y35": "vivo Y35L", 715 | "vivo Y35A": "vivo Y35A", 716 | "vivo Y35L": "vivo Y35L", 717 | "vivo Y37": "vivo Y37", 718 | "vivo Y51": "vivo Y51", 719 | "vivo Y51A": "vivo Y51A", 720 | "vivo Y53": "vivo Y53", 721 | "vivo Y55": "vivo Y55A", 722 | "vivo Y66": "vivo Y66", 723 | "vivo Y66L": "vivo Y66L", 724 | "vivo Y67": "vivo Y67", 725 | "vivo Y67A": "vivo Y67", 726 | "vivo Y69A": "vivo Y69", 727 | "vivo Y71A": "vivo Y71", 728 | "vivo Y75": "vivo Y75", 729 | "vivo Y75s": "vivo Y75s", 730 | "vivo Y79A": "vivo Y79", 731 | "vivo Y83A": "vivo Y83(HIH-PHO-3360)", 732 | "vivo Y85A": "vivo Y85", 733 | "vivo Y913": "vivo Y13L", 734 | "vivo Z1": "vivo Z1(HIH-PHO-3368)", 735 | "vivo Z1i": "vivo Z1i(HIH-PHO-3506)", 736 | "vivo": "vivo Y75", 737 | "VKY-AL00": "华为 P10 Plus", 738 | "VTR-AL00": "华为 P10", 739 | "VTR-TL00": "华为 P10 移动版", 740 | "W800": "金立 天鉴 w800", 741 | "WAS-AL00": "华为 nova 青春版", 742 | "X1 7.0": "华为 MediaPad X1", 743 | "X9007": "OPPO Find 7 轻装版", 744 | "X9077": "OPPO Find 7", 745 | "XT1052": "Moto X(一代)", 746 | "XT1060": "Moto X(一代)", 747 | "XT1079": "Moto G 二代", 748 | "XT1085": "Moto X 二代", 749 | "XT1096": "Moto X 二代", 750 | "XT1570": "Moto X Style", 751 | "XT1635-03": "Moto Z Play", 752 | "XT1650": "Moto Z", 753 | "XT1650-05": "Moto Z", 754 | "XT1662": "Moto M(XT1662)", 755 | "XT1710-08": "Moto Z2 Play", 756 | "XT1789-05": "Moto Z 2018", 757 | "XT1799-2": "Moto 青柚", 758 | "XT907": "Moto XT907", 759 | "Y13L": "vivo Y13L", 760 | "Y23L": "vivo Y23L", 761 | "Y35A": "vivo Y35A", 762 | "YQ601": "锤子 坚果手机", 763 | "Z999": "中兴 Axon M", 764 | "ZTE A0722": "中兴 Blade A4", 765 | "ZTE BA610C": "中兴 远航4", 766 | "ZTE BV0710": "中兴 V7 MAX", 767 | "ZTE BV0720": "中兴 Blade A2", 768 | "ZTE BV0730": "中兴 Blade A2 Plus", 769 | "ZTE C2017": "中兴 天机7Max", 770 | "ZTE C880U": "中兴 Blade A1", 771 | "ZTE U930HD": "中兴 U930 HD", 772 | "ZTE V0900": "中兴 Blade V9", 773 | "ZTE V975": "中兴 V975", 774 | "ZUK Z1": "ZUK Z1(Z1221)", 775 | "ZUK Z2121": "ZUK Z2 Pro(Z2121)", 776 | "ZUK Z2131": "ZUK Z2", 777 | "ZUK Z2151": "ZUK Edge" 778 | } 779 | -------------------------------------------------------------------------------- /heartbeat.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | # updated: 2019/03/13 4 | # updated: 2019/04/11 codeskyblue: add owner 5 | 6 | 7 | import json 8 | import re 9 | from collections import defaultdict 10 | 11 | from logzero import logger 12 | from tornado.ioloop import IOLoop 13 | from tornado.queues import Queue 14 | from tornado import websocket 15 | from tornado import gen 16 | 17 | from core.utils import update_recursive, current_ip 18 | 19 | 20 | async def heartbeat_connect( 21 | server_url: str, 22 | self_url: str = "", 23 | secret: str = "", 24 | platform: str = "android", 25 | priority: int = 2, 26 | **kwargs): 27 | addr = server_url.replace("http://", "").replace("/", "") 28 | url = "ws://" + addr + "/websocket/heartbeat" 29 | 30 | hbc = HeartbeatConnection( 31 | url, secret, platform=platform, priority=priority, **kwargs) 32 | hbc._provider_url = self_url 33 | await hbc.open() 34 | return hbc 35 | 36 | 37 | class SafeWebSocket(websocket.WebSocketClientConnection): 38 | async def write_message(self, message, binary=False): 39 | if isinstance(message, dict): 40 | message = json.dumps(message) 41 | return await super().write_message(message) 42 | 43 | 44 | class HeartbeatConnection(object): 45 | """ 46 | 与atxserver2建立连接,汇报当前已经连接的设备 47 | """ 48 | 49 | def __init__(self, 50 | url="ws://localhost:4000/websocket/heartbeat", 51 | secret='', 52 | platform='android', 53 | priority=2, 54 | owner=None): 55 | self._server_ws_url = url 56 | self._provider_url = None 57 | self._name = "pyclient" 58 | self._owner = owner 59 | self._secret = secret 60 | 61 | self._platform = platform 62 | self._priority = priority 63 | self._queue = Queue() 64 | self._db = defaultdict(dict) 65 | 66 | async def open(self): 67 | self._ws = await self.connect() 68 | IOLoop.current().spawn_callback(self._drain_ws_message) 69 | IOLoop.current().spawn_callback(self._drain_queue) 70 | 71 | async def _drain_queue(self): 72 | """ 73 | Logic: 74 | - send message to server when server is alive 75 | - update local db 76 | """ 77 | while True: 78 | message = await self._queue.get() 79 | if message is None: 80 | logger.info("Resent messages: %s", self._db) 81 | for _, v in self._db.items(): 82 | await self._ws.write_message(v) 83 | continue 84 | 85 | if 'udid' in message: # ping消息不包含在裡面 86 | udid = message['udid'] 87 | update_recursive(self._db, {udid: message}) 88 | self._queue.task_done() 89 | 90 | if self._ws: 91 | try: 92 | await self._ws.write_message(message) 93 | logger.debug("websocket send: %s", message) 94 | except TypeError as e: 95 | logger.info("websocket write_message error: %s", e) 96 | 97 | async def _drain_ws_message(self): 98 | while True: 99 | message = await self._ws.read_message() 100 | logger.debug("WS read message: %s", message) 101 | if message is None: 102 | self._ws = None 103 | logger.warning("WS closed") 104 | self._ws = await self.connect() 105 | await self._queue.put(None) 106 | logger.info("WS receive message: %s", message) 107 | 108 | async def connect(self): 109 | """ 110 | Returns: 111 | tornado.WebSocketConnection 112 | """ 113 | cnt = 0 114 | while True: 115 | try: 116 | ws = await self._connect() 117 | cnt = 0 118 | return ws 119 | except Exception as e: 120 | cnt = min(30, cnt + 1) 121 | logger.warning("WS connect error: %s, reconnect after %ds", e, 122 | cnt + 1) 123 | await gen.sleep(cnt + 1) 124 | 125 | async def _connect(self): 126 | ws = await websocket.websocket_connect(self._server_ws_url) 127 | ws.__class__ = SafeWebSocket 128 | 129 | await ws.write_message({ 130 | "command": "handshake", 131 | "name": self._name, 132 | "owner": self._owner, 133 | "secret": self._secret, 134 | "url": self._provider_url, 135 | "priority": self._priority, # the large the importanter 136 | }) 137 | 138 | msg = await ws.read_message() 139 | logger.info("WS receive: %s", msg) 140 | return ws 141 | 142 | async def device_update(self, data: dict): 143 | """ 144 | Args: 145 | data (dict) should contains keys 146 | - provider (dict: optional) 147 | - coding (bool: optional) 148 | - properties (dict: optional) 149 | """ 150 | data['command'] = 'update' 151 | data['platform'] = self._platform 152 | 153 | await self._queue.put(data) 154 | 155 | async def ping(self): 156 | await self._ws.write_message({"command": "ping"}) 157 | 158 | 159 | async def async_main(): 160 | hbc = await heartbeat_connect( 161 | "ws://localhost:4000/websocket/heartbeat", "123456", platform='apple') 162 | await hbc.device_update({ 163 | "udid": "kj3rklzvlkjsdfawefw", 164 | "colding": False, 165 | "provider": { 166 | "wdaUrl": 167 | "http://localhost:5600" # "http://"+current_ip()+":18000/127.0.0.1:8100" 168 | } 169 | }) 170 | while True: 171 | await gen.sleep(5) 172 | # await hbc.ping() 173 | 174 | 175 | if __name__ == "__main__": 176 | IOLoop.current().run_sync(async_main) 177 | -------------------------------------------------------------------------------- /install-adb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -x 2 | # 3 | # Create: 2018/4/8 4 | # Author: codeskyblue 5 | # System: Linux 6 | 7 | set -e 8 | 9 | SYSTEM= 10 | 11 | case "$(uname -s)" in 12 | Linux) 13 | SYSTEM="linux" 14 | ;; 15 | Darwin) 16 | echo "Unsupported Mac platform, run command: brew cask install android-platform-tools" 17 | exit 1 18 | ;; 19 | *) 20 | echo "Unsupported system $(uname -s)" 21 | exit 1 22 | ;; 23 | esac 24 | 25 | TAG= 26 | case "$(uname -m)" in 27 | x86_64) 28 | TAG="linux-amd64" 29 | ;; 30 | armv*l) 31 | TAG="linux-armhf" 32 | ;; 33 | *) 34 | echo "Unknown arch: $(uname -m)" 35 | exit 1 36 | ;; 37 | esac 38 | 39 | wget -q "https://github.com/openatx/adb-binaries/raw/master/1.0.40/$TAG/adb" -O /usr/local/bin/adb-tmp 40 | 41 | chmod +x /usr/local/bin/adb-tmp 42 | mv /usr/local/bin/adb-tmp /usr/local/bin/adb 43 | 44 | if ! test -d /root/.android 45 | then 46 | mkdir -m 0750 /root/.android 47 | fi 48 | 49 | 50 | 51 | cp vendor/keys/adbkey /root/.android/adbkey 52 | cp vendor/keys/adbkey.pub /root/.android/adbkey.pub 53 | 54 | adb version 55 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding: utf-8 3 | # 4 | 5 | import argparse 6 | import collections 7 | import glob 8 | import hashlib 9 | import json 10 | import os 11 | import pprint 12 | import re 13 | import shutil 14 | import socket 15 | import subprocess 16 | import sys 17 | import tempfile 18 | import time 19 | from concurrent.futures import ThreadPoolExecutor 20 | from functools import partial 21 | 22 | import apkutils2 as apkutils 23 | import requests 24 | import tornado.web 25 | from logzero import logger 26 | from tornado import gen, websocket 27 | from tornado.concurrent import run_on_executor 28 | from tornado.ioloop import IOLoop 29 | from tornado.web import RequestHandler 30 | from tornado.websocket import WebSocketHandler, websocket_connect 31 | 32 | import adbutils 33 | from adbutils import adb as adbclient 34 | from asyncadb import adb 35 | from device import STATUS_FAIL, STATUS_INIT, STATUS_OKAY, AndroidDevice 36 | from heartbeat import heartbeat_connect 37 | from core.utils import current_ip, fix_url, id_generator, update_recursive 38 | from core import fetching 39 | import uiautomator2 as u2 40 | import settings 41 | 42 | __curdir__ = os.path.dirname(os.path.abspath(__file__)) 43 | hbconn = None 44 | udid2device = {} 45 | secret = id_generator(10) 46 | 47 | 48 | class CorsMixin(object): 49 | CORS_ORIGIN = '*' 50 | CORS_METHODS = 'GET,POST,OPTIONS' 51 | CORS_CREDENTIALS = True 52 | CORS_HEADERS = "x-requested-with,authorization" 53 | 54 | def set_default_headers(self): 55 | self.set_header("Access-Control-Allow-Origin", self.CORS_ORIGIN) 56 | self.set_header("Access-Control-Allow-Headers", self.CORS_HEADERS) 57 | self.set_header('Access-Control-Allow-Methods', self.CORS_METHODS) 58 | 59 | def options(self): 60 | # no body 61 | self.set_status(204) 62 | self.finish() 63 | 64 | 65 | class InstallError(Exception): 66 | def __init__(self, stage: str, reason): 67 | self.stage = stage 68 | self.reason = reason 69 | 70 | 71 | def app_install_local(serial: str, apk_path: str, launch: bool = False) -> str: 72 | """ 73 | install apk to device 74 | 75 | Returns: 76 | package name 77 | 78 | Raises: 79 | AdbInstallError, FileNotFoundError 80 | """ 81 | # 解析apk文件 82 | device = adbclient.device(serial) 83 | try: 84 | apk = apkutils.APK(apk_path) 85 | except apkutils.apkfile.BadZipFile: 86 | raise InstallError("ApkParse", "Bad zip file") 87 | 88 | # 提前将重名包卸载 89 | package_name = apk.manifest.package_name 90 | pkginfo = device.package_info(package_name) 91 | if pkginfo: 92 | logger.debug("uninstall: %s", package_name) 93 | device.uninstall(package_name) 94 | 95 | # 解锁手机,防止锁屏 96 | # ud = u2.connect_usb(serial) 97 | # ud.open_identify() 98 | try: 99 | # 推送到手机 100 | dst = "/data/local/tmp/tmp-%d.apk" % int(time.time() * 1000) 101 | logger.debug("push %s %s", apk_path, dst) 102 | device.sync.push(apk_path, dst) 103 | logger.debug("install-remote %s", dst) 104 | # 调用pm install安装 105 | device.install_remote(dst) 106 | except adbutils.errors.AdbInstallError as e: 107 | raise InstallError("install", e.output) 108 | # finally: 109 | # 停止uiautomator2服务 110 | # logger.debug("uiautomator2 stop") 111 | # ud.session().press("home") 112 | # ud.service("uiautomator").stop() 113 | 114 | # 启动应用 115 | if launch: 116 | logger.debug("launch %s", package_name) 117 | device.app_start(package_name) 118 | return package_name 119 | 120 | 121 | class AppHandler(CorsMixin, tornado.web.RequestHandler): 122 | _install_executor = ThreadPoolExecutor(4) 123 | _download_executor = ThreadPoolExecutor(1) 124 | 125 | def cache_filepath(self, text: str) -> str: 126 | m = hashlib.md5() 127 | m.update(text.encode('utf-8')) 128 | return "cache-" + m.hexdigest() 129 | 130 | @run_on_executor(executor="_download_executor") 131 | def cache_download(self, url: str) -> str: 132 | """ download with local cache """ 133 | target_path = self.cache_filepath(url) 134 | logger.debug("Download %s to %s", url, target_path) 135 | 136 | if os.path.exists(target_path): 137 | logger.debug("Cache hited") 138 | return target_path 139 | 140 | # TODO: remove last 141 | for fname in glob.glob("cache-*"): 142 | logger.debug("Remove old cache: %s", fname) 143 | os.unlink(fname) 144 | 145 | tmp_path = target_path + ".tmp" 146 | r = requests.get(url, stream=True) 147 | r.raise_for_status() 148 | 149 | with open(tmp_path, "wb") as tfile: 150 | content_length = int(r.headers.get("content-length", 0)) 151 | if content_length: 152 | for chunk in r.iter_content(chunk_size=40960): 153 | tfile.write(chunk) 154 | else: 155 | shutil.copyfileobj(r.raw, tfile) 156 | 157 | os.rename(tmp_path, target_path) 158 | return target_path 159 | 160 | @run_on_executor(executor='_install_executor') 161 | def app_install_url(self, serial: str, apk_path: str, **kwargs): 162 | pkg_name = app_install_local(serial, apk_path, **kwargs) 163 | return { 164 | "success": True, 165 | "description": "Success", 166 | "packageName": pkg_name, 167 | } 168 | 169 | async def post(self, udid=None): 170 | udid = udid or self.get_argument("udid") 171 | device = udid2device[udid] 172 | url = self.get_argument("url") 173 | launch = self.get_argument("launch", 174 | "false") in ['true', 'True', 'TRUE', '1'] 175 | 176 | try: 177 | apk_path = await self.cache_download(url) 178 | ret = await self.app_install_url(device.serial, 179 | apk_path, 180 | launch=launch) 181 | self.write(ret) 182 | except InstallError as e: 183 | self.set_status(400) 184 | self.write({ 185 | "success": False, 186 | "description": "{}: {}".format(e.stage, e.reason) 187 | }) 188 | except Exception as e: 189 | self.set_status(500) 190 | self.write(str(e)) 191 | 192 | 193 | class ColdingHandler(tornado.web.RequestHandler): 194 | async def post(self, udid=None): 195 | """ 设备清理 """ 196 | udid = udid or self.get_argument("udid") 197 | logger.info("Receive colding request for %s", udid) 198 | request_secret = self.get_argument("secret") 199 | if secret != request_secret: 200 | logger.warning("secret not match, expect %s, got %s", secret, 201 | request_secret) 202 | return 203 | 204 | if udid not in udid2device: 205 | return 206 | 207 | device = udid2device[udid] 208 | await device.reset() 209 | await hbconn.device_update({ 210 | "udid": udid, 211 | "colding": False, 212 | "provider": device.addrs(), 213 | }) 214 | self.write({"success": True, "description": "Device colded"}) 215 | 216 | 217 | def make_app(): 218 | app = tornado.web.Application([ 219 | (r"/app/install", AppHandler), 220 | (r"/cold", ColdingHandler), 221 | ]) 222 | return app 223 | 224 | 225 | async def device_watch(allow_remote: bool = False): 226 | serial2udid = {} 227 | udid2serial = {} 228 | 229 | def callback(udid: str, status: str): 230 | if status == STATUS_OKAY: 231 | print("Good") 232 | 233 | async for event in adb.track_devices(): 234 | logger.debug("%s", event) 235 | # udid = event.serial # FIXME(ssx): fix later 236 | if not allow_remote: 237 | if re.match(r"(\d+)\.(\d+)\.(\d+)\.(\d+):(\d+)", event.serial): 238 | logger.debug("Skip remote device: %s", event) 239 | continue 240 | if event.present: 241 | try: 242 | udid = serial2udid[event.serial] = event.serial 243 | udid2serial[udid] = event.serial 244 | 245 | device = AndroidDevice(event.serial, partial(callback, udid)) 246 | 247 | await device.init() 248 | await device.open_identify() 249 | 250 | udid2device[udid] = device 251 | 252 | await hbconn.device_update({ 253 | # "private": False, # TODO 254 | "udid": udid, 255 | "platform": "android", 256 | "colding": False, 257 | "provider": device.addrs(), 258 | "properties": await device.properties(), 259 | }) # yapf: disable 260 | logger.info("Device:%s is ready", event.serial) 261 | except RuntimeError: 262 | logger.warning("Device:%s initialize failed", event.serial) 263 | except Exception as e: 264 | logger.error("Unknown error: %s", e) 265 | import traceback 266 | traceback.print_exc() 267 | else: 268 | udid = serial2udid[event.serial] 269 | if udid in udid2device: 270 | udid2device[udid].close() 271 | udid2device.pop(udid, None) 272 | 273 | await hbconn.device_update({ 274 | "udid": udid, 275 | "provider": None, # not present 276 | }) 277 | 278 | 279 | async def async_main(): 280 | parser = argparse.ArgumentParser( 281 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 282 | # yapf: disable 283 | parser.add_argument('-s', '--server', default='localhost:4000', help='server address') 284 | parser.add_argument("--allow-remote", action="store_true", help="allow remote connect device") 285 | parser.add_argument('-t', '--test', action="store_true", help="run test code") 286 | parser.add_argument('-p', '--port', type=int, default=3500, help='listen port') 287 | parser.add_argument("--atx-agent-version", default=u2.version.__atx_agent_version__, help="set atx-agent version") 288 | parser.add_argument("--owner", type=str, help="provider owner email") 289 | parser.add_argument("--owner-file", type=argparse.FileType("r"), help="provider owner email from file") 290 | args = parser.parse_args() 291 | # yapf: enable 292 | 293 | settings.atx_agent_version = args.atx_agent_version 294 | 295 | owner_email = args.owner 296 | if args.owner_file: 297 | with args.owner_file as file: 298 | owner_email = file.read().strip() 299 | logger.info("Owner: %s", owner_email) 300 | 301 | if args.test: 302 | for apk_name in ("cloudmusic.apk", ): # , "apkinfo.exe"): 303 | apk_path = "testdata/" + apk_name 304 | logger.info("Install %s", apk_path) 305 | # apk_path = r"testdata/cloudmusic.apk" 306 | ret = app_install_local("6EB0217704000486", apk_path, launch=True) 307 | logger.info("Ret: %s", ret) 308 | return 309 | 310 | # start local server 311 | provider_url = "http://" + current_ip() + ":" + str(args.port) 312 | app = make_app() 313 | app.listen(args.port) 314 | logger.info("ProviderURL: %s", provider_url) 315 | 316 | fetching.get_all() 317 | 318 | # connect to atxserver2 319 | global hbconn 320 | hbconn = await heartbeat_connect(args.server, 321 | secret=secret, 322 | self_url=provider_url, 323 | owner=owner_email) 324 | 325 | await device_watch(args.allow_remote) 326 | 327 | 328 | async def test_asyncadb(): 329 | devices = await adb.devices() 330 | print(devices) 331 | # output = await adb.shell("3578298f", "getprop ro.product.brand") 332 | # print(output) 333 | version = await adb.server_version() 334 | print("ServerVersion:", version) 335 | 336 | await adb.forward_remove() 337 | await adb.forward("3578298f", "tcp:8888", "tcp:7912") 338 | async for f in adb.forward_list(): 339 | print(f) 340 | 341 | 342 | if __name__ == '__main__': 343 | # if os.path.getsize(os.path.join(__curdir__, 344 | # "vendor/app-uiautomator.apk")) < 1000: 345 | # sys.exit("Did you forget run\n\tgit lfs install\n\tgit lfs pull") 346 | 347 | try: 348 | IOLoop.current().run_sync(async_main) 349 | except KeyboardInterrupt: 350 | logger.info("Interrupt catched") 351 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "adbkit": "^2.11.1", 4 | "commander": "^2.19.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | logzero==1.5.* 2 | adbutils>=0.8.1,<1.0 3 | tornado 4 | requests 5 | apkutils2 6 | uiautomator2>=2.5.9 7 | humanize 8 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | 4 | if test -d venv 5 | then 6 | . venv/bin/activate 7 | fi 8 | 9 | exec python3 main.py "$@" 10 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | atx_agent_version = "" # set from command line 5 | -------------------------------------------------------------------------------- /tcpproxy.js: -------------------------------------------------------------------------------- 1 | var net = require("net"); 2 | 3 | process.on("uncaughtException", function(error) { 4 | console.error(error); 5 | }); 6 | 7 | if (process.argv.length != 5) { 8 | console.log("usage: %s ", process.argv[1]); 9 | process.exit(); 10 | } 11 | 12 | var localport = process.argv[2]; 13 | var remotehost = process.argv[3]; 14 | var remoteport = process.argv[4]; 15 | 16 | var server = net.createServer(function (localsocket) { 17 | var remotesocket = new net.Socket(); 18 | 19 | remotesocket.connect(remoteport, remotehost); 20 | 21 | localsocket.on('connect', function (data) { 22 | console.log(">>> connection #%d from %s:%d", 23 | server.connections, 24 | localsocket.remoteAddress, 25 | localsocket.remotePort 26 | ); 27 | }); 28 | 29 | localsocket.on('data', function (data) { 30 | var flushed = remotesocket.write(data); 31 | if (!flushed) { 32 | localsocket.pause(); 33 | } 34 | }); 35 | 36 | remotesocket.on('data', function(data) { 37 | var flushed = localsocket.write(data); 38 | if (!flushed) { 39 | remotesocket.pause(); 40 | } 41 | }); 42 | 43 | localsocket.on('drain', function() { 44 | remotesocket.resume(); 45 | }); 46 | 47 | remotesocket.on('drain', function() { 48 | localsocket.resume(); 49 | }); 50 | 51 | localsocket.on('close', function(had_error) { 52 | console.log("%s:%d - closing remote", 53 | localsocket.remoteAddress, 54 | localsocket.remotePort 55 | ); 56 | remotesocket.end(); 57 | }); 58 | 59 | remotesocket.on('close', function(had_error) { 60 | console.log("%s:%d - closing local", 61 | localsocket.remoteAddress, 62 | localsocket.remotePort 63 | ); 64 | localsocket.end(); 65 | }); 66 | 67 | }); 68 | 69 | server.listen(localport); 70 | 71 | console.log("redirecting connections from 127.0.0.1:%d to %s:%d", localport, remotehost, remoteport); 72 | -------------------------------------------------------------------------------- /vendor/download-apks.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | import progress.bar 5 | import requests 6 | from uiautomator2.version import __apk_version__ 7 | 8 | 9 | def download(url, target): 10 | print("Download", target) 11 | r = requests.get(url, stream=True) 12 | r.raise_for_status() 13 | 14 | bar = progress.bar.Bar() 15 | bar.max = int(r.headers.get("content-length")) 16 | with open(target, "wb") as f: 17 | for chunk in r.iter_content(chunk_size=4096): 18 | f.write(chunk) 19 | bar.next(len(chunk)) 20 | bar.finish() 21 | 22 | 23 | def main(): 24 | url_apk = "https://github.com/openatx/android-uiautomator-server/releases/download/{}/app-uiautomator.apk".format( 25 | __apk_version__) 26 | url_test_apk = "https://github.com/openatx/android-uiautomator-server/releases/download/{}/app-uiautomator-test.apk".format( 27 | __apk_version__) 28 | download(url_apk, "app-uiautomator.apk") 29 | download(url_test_apk, "app-uiautomator-test.apk") 30 | 31 | 32 | if __name__ == "__main__": 33 | main() 34 | -------------------------------------------------------------------------------- /vendor/download-atx-agent.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | # 4 | 5 | import shutil 6 | import tarfile 7 | import tempfile 8 | import zipfile 9 | 10 | import humanize 11 | import progress.bar 12 | import requests 13 | 14 | from logzero import logger 15 | 16 | 17 | class DownloadBar(progress.bar.Bar): 18 | message = "Downloading" 19 | suffix = '%(current_size)s / %(total_size)s' 20 | 21 | @property 22 | def total_size(self): 23 | return humanize.naturalsize(self.max, gnu=True) 24 | 25 | @property 26 | def current_size(self): 27 | return humanize.naturalsize(self.index, gnu=True) 28 | 29 | 30 | def mirror_download(url: str, target: str): 31 | github_host = "https://github.com" 32 | if url.startswith(github_host): 33 | mirror_url = "http://tool.appetizer.io" + url[len( 34 | github_host):] # mirror of github 35 | try: 36 | return download(mirror_url, target) 37 | except requests.RequestException as e: 38 | logger.debug("download from mirror error, use origin source") 39 | 40 | return download(url, target) 41 | 42 | 43 | def download(url: str, storepath: str): 44 | r = requests.get(url, stream=True) 45 | r.raise_for_status() 46 | file_size = int(r.headers.get("Content-Length")) 47 | 48 | bar = DownloadBar(storepath, max=file_size) 49 | chunk_length = 16 * 1024 50 | with open(storepath + '.part', 'wb') as f: 51 | for buf in r.iter_content(chunk_length): 52 | f.write(buf) 53 | bar.next(len(buf)) 54 | bar.finish() 55 | shutil.move(storepath + '.part', storepath) 56 | 57 | 58 | def get_binary_url(version: str, arch: str) -> str: 59 | """ 60 | get atx-agent url 61 | """ 62 | return "https://github.com/openatx/atx-agent/releases/download/{0}/atx-agent_{0}_linux_{1}.tar.gz".format( 63 | version, arch) 64 | 65 | 66 | def create_bundle(version: str): 67 | print(">>> Download atx-agent verison:", version) 68 | with tempfile.TemporaryDirectory(prefix="tmp-") as tmpdir: 69 | target_zip = f"atx-agent-{version}.zip" 70 | tmp_target_zip = target_zip + ".part" 71 | 72 | with zipfile.ZipFile(tmp_target_zip, "w", compression=zipfile.ZIP_DEFLATED) as z: 73 | z.writestr(version, "") 74 | 75 | for arch in ("386", "amd64", "armv6", "armv7"): 76 | storepath = tmpdir + "/atx-agent-%s.tar.gz" % arch 77 | url = get_binary_url(version, arch) 78 | mirror_download(url, storepath) 79 | 80 | with tarfile.open(storepath, "r:gz") as t: 81 | t.extract("atx-agent", path=tmpdir+"/"+arch) 82 | z.write( 83 | "/".join([tmpdir, arch, "atx-agent"]), "atx-agent-"+arch) 84 | shutil.move(tmp_target_zip, target_zip) 85 | print(">>> Zip created", target_zip) 86 | 87 | 88 | if __name__ == "__main__": 89 | from uiautomator2.version import __atx_agent_version__ 90 | create_bundle(__atx_agent_version__) 91 | -------------------------------------------------------------------------------- /vendor/keys/adbkey: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDc0R3qAb1UdH9a 3 | Uv6Iyga78SpYxDAikDtriTHsTXP72uiRFxFcdfDJzP0MhCfHhIudkMrZnNEMwxfY 4 | IjxNsyLILUVzmnvqmg63VuzWyaGTQwXR3n1COZvdhEvFshfrSdzyoErUmBzLeVZf 5 | dIuJvbys9bb2z7Q8lQ573Z7EjirpJdntoFnLmJ7rE7OV1lFlW8JiPyZ4U74WWo1X 6 | J2jylcCJvzXbVwoo1JhuxdCJgly4rGsJDZbUD7cxQau7W284uLl8WY3jJ06RJObX 7 | 31iEwa94KzNqflg52CIZ4orcawbuj1p0f2d+3wyy1cIHiDguyS/wFTd4cVEFTyRk 8 | Ddv0wAAnAgMBAAECggEAK1fgx04QG8PCO7iOvcrqB3oPFd1slVw6TaFga0HIwmb1 9 | v4UHo16DJRlXkl1ecYtS3FrPdmeRoS+qPoJ508aVzTUVrNtl92bF/LbTRlXNoVpG 10 | iith6t3j+lc9iFCs4UJeXEGabqBtBoOKsLHvsdnMbybMAyZB+qJHdYjK4UoGojyu 11 | aNqSdjWRHVfKH7PReaUaM+gUH4jRLuobUK01EUXmk7seEu5LD9eqRijlNqxllpUy 12 | k+1rBfE7SctkoLvVWljTknKLlYFrNcpQI1rY8OLs6Ix0xfHeKmDaPYOJtGEWEWLC 13 | AR2XO5LikmMF+UUa6ARI4MsI2SrEnsAnZOIE9FL4AQKBgQD09LH5SH2rd0hXHutN 14 | F8OccHN9WwS47JNSJMsgMvMdAS1gpduNWBSk3onaYxTwv0IEO8+ns7WBT0rrEGP9 15 | SWGSJQxwtUWYQWHPcda/7TnZl4Fo+IdU6XPNn5LHPdinDNxJctXEdRbq5dXPkzYH 16 | uetXCq8EwdlSqociy7vWCXaA9wKBgQDmxc6hIiZElqRd6E/tvyKUQLcfaSaRbV/d 17 | DUFscMUwZYLU7emyWFZbaF7Ck5v71duSj3iKwtCiPYhL46JPGv+iBarD3WJVsMQ/ 18 | Pvgc0O0iETF8Yy+blnYRpQW/9ZnO9PJcteO9SKAMrlI/lTcKn4yiqmp2xNmhMKgL 19 | DAiX8S3eUQKBgQDtXGNM7Hqh+U8G5LYbmQh6gdjXQqhbzRqgQXj3NYewtmBTQ8Rw 20 | vUYb0GyCSvqSorIEyjRZC+G4cK5nAxXw7Pd4FyBr4quScuCllGkEx4oEGDRVFGaG 21 | 2ETXnmYrXPmgPe0D2xvbZ56Sda3um3aCnBy41mhr0q+U1Btok0TrjXXgVwKBgB7p 22 | wnstQukPMOdvdj2HzA8F+EHZ6RO0DhJjcy8ekBuijXsOf66nTLIj8gWolk2O4UHp 23 | vCECZcZF7dsUnCpymGnQzoY8Qq7t5ev++GeLySg2G2XpN3hlGF3WuEV1lev2Pf0T 24 | VHWHpADu3Q+tYlkm9ETaBTbxuaFxDiGktAX+hcFxAoGAdc4YspvKEvrQPF6fb5uZ 25 | a5Alae0FnwG9MCZ2NI0ERCtL1Yt4yUhSW1ujJ8Su7tdudbU0IZWT5L5xOx9vUZmC 26 | kHqY6uIO5MrnAcS2/OwuNz2Mn1Dx//bHBaMfyJcEmzAlu8ldzUmvDkJ95kMrozxr 27 | GGZnEUBZci/ksGYCHqaR8BE= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /vendor/keys/adbkey.pub: -------------------------------------------------------------------------------- 1 | QAAAAGmQxuknAMD02w1kJE8FUXF4NxXwL8kuOIgHwtWyDN9+Z390Wo/uBmvciuIZItg5WH5qMyt4r8GEWN/X5iSRTifjjVl8ubg4b1u7q0Extw/Ulg0Ja6y4XIKJ0MVumNQoClfbNb+JwJXyaCdXjVoWvlN4Jj9iwltlUdaVsxPrnpjLWaDt2SXpKo7Ent17DpU8tM/2tvWsvL2Ji3RfVnnLHJjUSqDy3EnrF7LFS4TdmzlCfd7RBUOTocnW7Fa3Dprqe5pzRS3IIrNNPCLYF8MM0ZzZypCdi4THJ4QM/czJ8HVcEReR6Nr7c03sMYlrO5AiMMRYKvG7BsqI/lJaf3RUvQHqHdHcMnoplCGyvQJkQv9MpXQwN/ywqxV3E3sYThjMmVg64v2cYn4abRsknF0QC1hJj7xRkemh9wvzqfVYJr+goUjqu9HFir7tTiVGGB4gWIVjVoaGxtZLcqb/ZkWnsmurBw5/klwv7gan/yhzEJ/50Lxhf+Alx5iPF/YmODNmzib+KKzisdiRCbs4/+eUypaTDebpUlEuyeehk8HcXr8v9D8GRHjTMIBPd5ED44n7A5/x+4u2Rx7OeHbBYyNe4o7H2mhW/X+DsGF+vfZ5vepSNllpIKEDie0t93AeEVE/veX3jZRZ64g0QgDRiV3OLvCcRXqZoB395RehW1r+iOU8eUTzDwEAAQA= pi@RPi --------------------------------------------------------------------------------