├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── lib ├── controllers.js ├── helper.js ├── key-map.js ├── logger.js └── macaca-android.js ├── package.json └── test ├── macaca-android.test.js └── mocha.opts /.eslintignore: -------------------------------------------------------------------------------- 1 | **/.* 2 | **/node_modules 3 | **/coverage 4 | **/test 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true, 6 | "mocha": true 7 | }, 8 | "plugins": [ 9 | "mocha" 10 | ], 11 | // https://github.com/feross/eslint-config-standard 12 | "rules": { 13 | "accessor-pairs": 2, 14 | "block-scoped-var": 0, 15 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 16 | "camelcase": 0, 17 | "comma-dangle": [2, "never"], 18 | "comma-spacing": [2, { "before": false, "after": true }], 19 | "comma-style": [2, "last"], 20 | "complexity": 0, 21 | "consistent-return": 0, 22 | "consistent-this": 0, 23 | "curly": [2, "multi-line"], 24 | "default-case": 0, 25 | "dot-location": [2, "property"], 26 | "dot-notation": 0, 27 | "eol-last": 2, 28 | "eqeqeq": [2, "allow-null"], 29 | "func-names": 0, 30 | "func-style": 0, 31 | "generator-star-spacing": [2, "both"], 32 | "guard-for-in": 0, 33 | "handle-callback-err": [2, "^(err|error|anySpecificError)$" ], 34 | "indent": [2, 2], 35 | "key-spacing": [2, { "beforeColon": false, "afterColon": true }], 36 | "linebreak-style": 0, 37 | "max-depth": 0, 38 | "max-len": 0, 39 | "max-nested-callbacks": 0, 40 | "max-params": 0, 41 | "max-statements": 0, 42 | "new-cap": [2, { "newIsCap": true, "capIsNew": false }], 43 | "new-parens": 2, 44 | "no-alert": 0, 45 | "no-array-constructor": 2, 46 | "no-bitwise": 0, 47 | "no-caller": 2, 48 | "no-catch-shadow": 0, 49 | "no-cond-assign": 2, 50 | "no-console": 0, 51 | "no-constant-condition": 0, 52 | "no-continue": 0, 53 | "no-control-regex": 2, 54 | "no-debugger": 2, 55 | "no-delete-var": 2, 56 | "no-div-regex": 0, 57 | "no-dupe-args": 2, 58 | "no-dupe-keys": 2, 59 | "no-duplicate-case": 2, 60 | "no-else-return": 0, 61 | "no-empty": 0, 62 | "no-empty-character-class": 2, 63 | "no-eq-null": 0, 64 | "no-eval": 2, 65 | "no-ex-assign": 2, 66 | "no-extend-native": 2, 67 | "no-extra-bind": 2, 68 | "no-extra-boolean-cast": 2, 69 | "no-extra-semi": 0, 70 | "no-extra-strict": 0, 71 | "no-fallthrough": 2, 72 | "no-floating-decimal": 2, 73 | "no-func-assign": 2, 74 | "no-implied-eval": 2, 75 | "no-inline-comments": 0, 76 | "no-inner-declarations": [2, "functions"], 77 | "no-invalid-regexp": 2, 78 | "no-irregular-whitespace": 2, 79 | "no-iterator": 2, 80 | "no-label-var": 2, 81 | "no-labels": 2, 82 | "no-lone-blocks": 2, 83 | "no-lonely-if": 0, 84 | "no-loop-func": 0, 85 | "no-mixed-requires": 0, 86 | "no-mixed-spaces-and-tabs": [2, false], 87 | "no-multi-spaces": 2, 88 | "no-multi-str": 2, 89 | "no-multiple-empty-lines": [2, { "max": 1 }], 90 | "no-native-reassign": 2, 91 | "no-negated-in-lhs": 2, 92 | "no-nested-ternary": 0, 93 | "no-new": 0, 94 | "no-new-func": 2, 95 | "no-new-object": 2, 96 | "no-new-require": 2, 97 | "no-new-wrappers": 2, 98 | "no-obj-calls": 2, 99 | "no-octal": 2, 100 | "no-octal-escape": 2, 101 | "no-path-concat": 0, 102 | "no-plusplus": 0, 103 | "no-process-env": 0, 104 | "no-process-exit": 0, 105 | "no-proto": 2, 106 | "no-redeclare": 2, 107 | "no-regex-spaces": 2, 108 | "no-reserved-keys": 0, 109 | "no-restricted-modules": 0, 110 | "no-return-assign": 2, 111 | "no-script-url": 0, 112 | "no-self-compare": 2, 113 | "no-sequences": 2, 114 | "no-shadow": 0, 115 | "no-shadow-restricted-names": 2, 116 | "no-spaced-func": 2, 117 | "no-sparse-arrays": 2, 118 | "no-sync": 0, 119 | "no-ternary": 0, 120 | "no-throw-literal": 2, 121 | "no-trailing-spaces": 2, 122 | "no-undef": 2, 123 | "no-undef-init": 2, 124 | "no-undefined": 0, 125 | "no-underscore-dangle": 0, 126 | "no-unneeded-ternary": 2, 127 | "no-unreachable": 2, 128 | "no-unused-expressions": 0, 129 | "no-unused-vars": [2, { "vars": "all", "args": "none" }], 130 | "no-use-before-define": 0, 131 | "no-var": 0, 132 | "no-void": 0, 133 | "no-warning-comments": 0, 134 | "no-with": 2, 135 | "no-extra-parens": 0, 136 | "object-curly-spacing": 0, 137 | "one-var": [2, { "initialized": "never" }], 138 | "operator-assignment": 0, 139 | "operator-linebreak": [2, "after"], 140 | "padded-blocks": 0, 141 | "quote-props": 0, 142 | "quotes": [1, "single", "avoid-escape"], 143 | "radix": 2, 144 | "semi": [2, "always"], 145 | "semi-spacing": 0, 146 | "sort-vars": 0, 147 | "keyword-spacing": [2], 148 | "space-before-blocks": [2, "always"], 149 | "space-before-function-paren": 0, 150 | "space-in-parens": [2, "never"], 151 | "space-infix-ops": 2, 152 | "space-unary-ops": [2, { "words": true, "nonwords": false }], 153 | "spaced-comment": [2, "always"], 154 | "strict": 0, 155 | "use-isnan": 2, 156 | "valid-jsdoc": 0, 157 | "valid-typeof": 2, 158 | "vars-on-top": 0, 159 | "wrap-iife": [2, "any"], 160 | "wrap-regex": 0, 161 | "yoda": [2, "never"] 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | push: 7 | branches: 8 | - '**' 9 | paths-ignore: 10 | - '**.md' 11 | 12 | jobs: 13 | Runner: 14 | timeout-minutes: 10 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [ ubuntu-latest, macOS-latest ] 20 | node-version: [ 16 ] 21 | steps: 22 | - name: Checkout Git Source 23 | uses: actions/checkout@v3 24 | 25 | - name: Set up JDK 8 26 | uses: actions/setup-java@v3 27 | with: 28 | distribution: 'adopt' 29 | java-version: 8 30 | 31 | - name: Setup Android SDK 32 | uses: android-actions/setup-android@v2 33 | 34 | - name: Setup Gradle 35 | uses: gradle/wrapper-validation-action@v1 36 | 37 | - name: Setup Node.js 38 | uses: actions/setup-node@v3 39 | with: 40 | node-version: ${{ matrix.node-version }} 41 | 42 | - name: Install dependencies 43 | run: | 44 | npm i npm@6 -g 45 | npm i 46 | 47 | - name: Continuous integration 48 | run: | 49 | npm run lint 50 | npm run test 51 | 52 | - name: Code coverage 53 | uses: codecov/codecov-action@v3.0.0 54 | with: 55 | token: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output 3 | coverage 4 | *.sw* 5 | *.un~ 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to macaca-android 2 | 3 | We love pull requests from everyone. 4 | 5 | Fork, then clone the repo: 6 | 7 | git clone git@github.com:your-username/macaca-android.git 8 | 9 | Set up your machine: 10 | 11 | npm i 12 | 13 | Then make your change and make sure the tests pass: 14 | 15 | make test 16 | 17 | Push to your fork and [submit a pull request][pr]. 18 | 19 | [pr]: https://github.com/macacajs/macaca-android/compare/ 20 | 21 | At this point you're waiting on us. We like to at least comment on pull requests 22 | within three business days (and, typically, one business day). We may suggest 23 | some changes or improvements or alternatives. 24 | 25 | Some things that will increase the chance that your pull request is accepted: 26 | 27 | * Write tests. 28 | * Follow [JavaScript Style Guide][style]. 29 | * Write a [good commit message][commit]. 30 | 31 | [style]: https://github.com/airbnb/javascript 32 | [commit]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | 3 | Copyright (c) 2017 Alibaba Group Holding Limited and other contributors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # macaca-android 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![CI][CI-image]][CI-url] 5 | [![Test coverage][coveralls-image]][coveralls-url] 6 | [![node version][node-image]][node-url] 7 | [![npm download][download-image]][download-url] 8 | 9 | [npm-image]: https://img.shields.io/npm/v/macaca-android.svg 10 | [npm-url]: https://npmjs.org/package/macaca-android 11 | [CI-image]: https://github.com/macacajs/macaca-android/actions/workflows/ci.yml/badge.svg 12 | [CI-url]: https://github.com/macacajs/macaca-android/actions/workflows/ci.yml 13 | [coveralls-image]: https://img.shields.io/coveralls/macacajs/macaca-android.svg 14 | [coveralls-url]: https://coveralls.io/r/macacajs/macaca-android?branch=master 15 | [node-image]: https://img.shields.io/badge/node.js-%3E=_8-green.svg 16 | [node-url]: http://nodejs.org/download/ 17 | [download-image]: https://img.shields.io/npm/dm/macaca-android.svg 18 | [download-url]: https://npmjs.org/package/macaca-android 19 | 20 | > Macaca Android driver 21 | 22 | 23 | 24 | ## Contributors 25 | 26 | |[
xudafeng](https://github.com/xudafeng)
|[
ziczhu](https://github.com/ziczhu)
|[
SamuelZhaoY](https://github.com/SamuelZhaoY)
|[
kobe990](https://github.com/kobe990)
|[
CodeToSurvive1](https://github.com/CodeToSurvive1)
|[
kyowang](https://github.com/kyowang)
| 27 | | :---: | :---: | :---: | :---: | :---: | :---: | 28 | [
qichuan](https://github.com/qichuan)
|[
brucejcw](https://github.com/brucejcw)
|[
snapre](https://github.com/snapre)
|[
yaniswang](https://github.com/yaniswang)
29 | 30 | This project follows the git-contributor [spec](https://github.com/xudafeng/git-contributor), auto updated at `Sun Apr 24 2022 00:22:35 GMT+0800`. 31 | 32 | 33 | 34 | ## Installment 35 | 36 | ```bash 37 | $ npm i macaca-android -g 38 | ``` 39 | 40 | To use a mirror of the Maven center. 41 | 42 | ```bash 43 | $ MAVEN_MIRROR_URL=http://maven.aliyun.com/nexus/content/groups/public/ npm i macaca-android -g 44 | ``` 45 | 46 | ## Custom ChromeDriver Version 47 | 48 | [link](//github.com/macacajs/macaca-chromedriver#custom-version) 49 | 50 | ## License 51 | 52 | The MIT License (MIT) 53 | -------------------------------------------------------------------------------- /lib/controllers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const temp = require('temp'); 5 | const ADB = require('macaca-adb'); 6 | const xml2map = require('xml2map2'); 7 | const errors = require('webdriver-dfn-error-code').errors; 8 | 9 | const _ = require('./helper'); 10 | const keyMap = require('./key-map'); 11 | const pkg = require('../package.json'); 12 | 13 | const NATIVE = 'NATIVE_APP'; 14 | 15 | var controllers = {}; 16 | 17 | controllers.isWebContext = function() { 18 | return this.context && this.context !== NATIVE; 19 | }; 20 | 21 | controllers.getContext = function * () { 22 | return this.context; 23 | }; 24 | 25 | controllers.getContexts = function * () { 26 | const contexts = [NATIVE].concat(yield this.getWebviews()); 27 | this.contexts = contexts; 28 | return contexts; 29 | }; 30 | 31 | controllers.setContext = function * (name) { 32 | if (name !== NATIVE) { 33 | yield this.getContexts(); 34 | if (!~this.contexts.indexOf(name)) { 35 | throw new errors.NoSuchWindow(); 36 | } 37 | const result = yield this.proxy.sendCommand('/wd/hub/session/:sessionId/window', 'POST', { 38 | name: name 39 | }); 40 | _.parseWebDriverResult(result); 41 | } else { 42 | this.proxy = this.uiautomator; 43 | } 44 | this.context = name; 45 | }; 46 | 47 | controllers.getScreenshot = function * () { 48 | const swapFilePath = temp.path({ 49 | prefix: `${pkg.name}-screenshot`, 50 | suffix: '.png' 51 | }); 52 | 53 | const remoteFile = `${ADB.ANDROID_TMP_DIR}/screenshot.png`; 54 | const cmd = `/system/bin/rm ${remoteFile}; /system/bin/screencap -p ${remoteFile}`; 55 | yield this.adb.shell(cmd); 56 | 57 | yield this.adb.pull(remoteFile, swapFilePath); 58 | 59 | var base64 = null; 60 | 61 | try { 62 | let data = fs.readFileSync(swapFilePath); 63 | base64 = new Buffer(data).toString('base64'); 64 | } catch (e) { 65 | throw new errors.NoSuchWindow(); 66 | } 67 | 68 | _.rimraf(swapFilePath); 69 | return base64; 70 | }; 71 | 72 | controllers.get = function * (url) { 73 | const cmd = `am start -a android.intent.action.VIEW -d ${url}`; 74 | yield this.adb.shell(cmd); 75 | return null; 76 | }; 77 | 78 | controllers.url = function * () { 79 | const result = yield this.proxyCommand('/wd/hub/session/:sessionId/url', 'get', null); 80 | return result.value; 81 | }; 82 | 83 | controllers.back = function * () { 84 | yield this.adb.goBack(); 85 | return null; 86 | }; 87 | 88 | controllers.tap = function(action) { 89 | return this 90 | .proxyCommand('/wd/hub/session/:sessionId/touch/click', 'post', { 91 | element: action.element 92 | }).then(result => { 93 | return _.parseWebDriverResult(result); 94 | }); 95 | }; 96 | 97 | controllers.keys = function * (value) { 98 | value = value.join(''); 99 | var arrText = []; 100 | 101 | for (var i = 0; i < value.length; i++) { 102 | var key = value.charAt(i); 103 | 104 | const keyEvent = keyMap[key]; 105 | 106 | if (keyEvent) { 107 | // update for situation like : xxdd\uE007 108 | // the enter will go before real content. 109 | if (arrText.length) { 110 | yield this.proxyCommand('/wd/hub/session/:sessionId/element/1/value', 'post', { 111 | value: [arrText.join('')] 112 | }); 113 | arrText = []; 114 | } 115 | yield this.proxyCommand('/wd/hub/session/:sessionId/keys', 'post', { 116 | value: [keyEvent] 117 | }); 118 | } else { 119 | arrText.push(key); 120 | } 121 | } 122 | if (arrText.length) { 123 | yield this.proxyCommand('/wd/hub/session/:sessionId/element/1/value', 'post', { 124 | value: [arrText.join('')] 125 | }); 126 | } 127 | return null; 128 | }; 129 | 130 | controllers.getSource = function * () { 131 | 132 | if (!this.isWebContext()) { 133 | yield this.adb.shell(`touch ${ADB.ANDROID_TMP_DIR}/macaca-dump.xml`); 134 | } 135 | const result = yield this.proxyCommand('/wd/hub/session/:sessionId/source', 'get', null); 136 | var xml = result.value; 137 | 138 | if (this.isWebContext() || (!this.isWebContext() && this.chromedriver)) { 139 | return xml; 140 | } 141 | 142 | const hierarchy = xml2map.tojson(xml).hierarchy; 143 | 144 | // tojson: if 'node' has only one element, the property will become json object instead of JSONArray 145 | // for device under Android API 5.0, 'node' is always an single element, and hence need to be wrapped into array 146 | if (hierarchy.node && !_.isArray(hierarchy.node)) { 147 | hierarchy.node = [hierarchy.node]; 148 | } 149 | 150 | var res = _.filter(hierarchy.node, i => i.package !== 'com.android.systemui'); 151 | 152 | return JSON.stringify(res && res[0] || []); 153 | }; 154 | 155 | controllers.title = function * () { 156 | 157 | if (!this.isWebContext()) { 158 | const focusedActivity = yield this.adb.getFocusedActivity(); 159 | return focusedActivity; 160 | } 161 | const result = yield this.proxyCommand('/wd/hub/session/:sessionId/title', 'get', null); 162 | return result.value; 163 | }; 164 | 165 | module.exports = controllers; 166 | -------------------------------------------------------------------------------- /lib/helper.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const macacaUtils = require('macaca-utils'); 4 | const childProcess = require('child_process'); 5 | const errors = require('webdriver-dfn-error-code').errors; 6 | const getErrorByCode = require('webdriver-dfn-error-code').getErrorByCode; 7 | 8 | var _ = macacaUtils.merge({}, macacaUtils); 9 | 10 | _.sleep = function(ms) { 11 | return new Promise(resolve => { 12 | setTimeout(resolve, ms); 13 | }); 14 | }; 15 | 16 | _.exec = function(cmd, opts) { 17 | return new Promise(function(resolve, reject) { 18 | childProcess.exec(cmd, _.merge({ 19 | maxBuffer: 1024 * 512, 20 | wrapArgs: false 21 | }, opts || {}), function(err, stdout) { 22 | if (err) { 23 | return reject(err); 24 | } 25 | resolve(_.trim(stdout)); 26 | }); 27 | }); 28 | }; 29 | 30 | _.serialTasks = function () { 31 | return Array.prototype.slice.call(arguments).reduce( 32 | (pre, task) => pre.then(() => task()), Promise.resolve()); 33 | }; 34 | 35 | _.waitForCondition = function(func, wait/* ms*/, interval/* ms*/) { 36 | wait = wait || 5000; 37 | interval = interval || 500; 38 | let start = Date.now(); 39 | let end = start + wait; 40 | const fn = function() { 41 | return new Promise(function(resolve, reject) { 42 | const continuation = (res, rej) => { 43 | let now = Date.now(); 44 | if (now < end) { 45 | res(_.sleep(interval).then(fn)); 46 | } else { 47 | rej(`Wait For Condition timeout ${wait}`); 48 | } 49 | }; 50 | func().then(isOk => { 51 | if (isOk) { 52 | resolve(); 53 | } else { 54 | continuation(resolve, reject); 55 | } 56 | }).catch(() => { 57 | continuation(resolve, reject); 58 | }); 59 | }); 60 | }; 61 | return fn(); 62 | }; 63 | 64 | _.parseWebDriverResult = function(res) { 65 | const code = res.status; 66 | const value = res.value; 67 | if (code === 0) { 68 | return value; 69 | } else { 70 | const errorName = getErrorByCode(code); 71 | const errorMsg = value && value.message; 72 | throw new errors[errorName](errorMsg); 73 | } 74 | }; 75 | 76 | module.exports = _; 77 | -------------------------------------------------------------------------------- /lib/key-map.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // https://github.com/macacajs/webdriver-keycode/blob/master/lib/webdriver-keycode.js 4 | 5 | module.exports = { 6 | // COMMON KEYS 7 | '\uE002': 259, // HELP 8 | '\uE003': 67, // BACK_SPACE 9 | '\uE004': 61, // TAB 10 | '\uE005': 28, // CLEAR 11 | '\uE007': 66, // ENTER 12 | '\uE008': 59, // SHIFT 13 | '\uE009': 113, // CONTROL 14 | '\uE00A': 57, // ALT 15 | '\uE00B': 121, // PAUSE 16 | '\uE00C': 111, // ESCAPE 17 | '\uE00E': 92, // PAGE_UP 18 | '\uE00F': 93, // PAGE_DOWN 19 | '\uE010': 123, // END 20 | '\uE011': 122, // HOME 21 | '\uE012': 21, // ARROW_LEFT 22 | '\uE013': 19, // ARROW_UP 23 | '\uE014': 22, // ARROW_RIGHT 24 | '\uE015': 20, // ARROW_DOWN 25 | '\uE016': 124, // INSERT 26 | '\uE017': 112, // DELETE 27 | '\uE031': 131, // F1 28 | '\uE032': 132, // F2 29 | '\uE033': 133, // F3 30 | '\uE034': 134, // F4 31 | '\uE035': 135, // F5 32 | '\uE036': 136, // F6 33 | '\uE037': 137, // F7 34 | '\uE038': 138, // F8 35 | '\uE039': 139, // F9 36 | '\uE03A': 140, // F10 37 | '\uE03B': 141, // F11 38 | '\uE03C': 142, // F12 39 | '\uE03D': 117, // META 40 | 41 | // Number KEYS 42 | '\u0030': 7, // 0 43 | '\u0031': 8, // 1 44 | '\u0032': 9, // 2 45 | '\u0033': 10, // 3 46 | '\u0034': 11, // 4 47 | '\u0035': 12, // 5 48 | '\u0036': 13, // 6 49 | '\u0037': 14, // 7 50 | '\u0038': 15, // 8 51 | '\u0039': 16, // 9 52 | 53 | // KEYS A-Z 54 | '\u0041': 29, // A 55 | '\u0042': 30, // B 56 | '\u0043': 31, // C 57 | '\u0044': 32, // D 58 | '\u0045': 33, // E 59 | '\u0046': 34, // F 60 | '\u0047': 35, // G 61 | '\u0048': 36, // H 62 | '\u0049': 37, // I 63 | '\u004A': 38, // J 64 | '\u004B': 39, // K 65 | '\u004C': 40, // L 66 | '\u004D': 41, // M 67 | '\u004E': 42, // N 68 | '\u004F': 43, // O 69 | '\u0050': 44, // P 70 | '\u0051': 45, // Q 71 | '\u0052': 46, // R 72 | '\u0053': 47, // S 73 | '\u0054': 48, // T 74 | '\u0055': 49, // U 75 | '\u0056': 50, // V 76 | '\u0057': 51, // W 77 | '\u0058': 52, // X 78 | '\u0059': 53, // Y 79 | '\u005A': 54, // Z 80 | 81 | // KEYS a-z 82 | '\u0061': 29, // a 83 | '\u0062': 30, // b 84 | '\u0063': 31, // c 85 | '\u0064': 32, // d 86 | '\u0065': 33, // e 87 | '\u0066': 34, // f 88 | '\u0067': 35, // g 89 | '\u0068': 36, // h 90 | '\u0069': 37, // i 91 | '\u006A': 38, // j 92 | '\u006B': 39, // k 93 | '\u006C': 40, // l 94 | '\u006D': 41, // m 95 | '\u006E': 42, // n 96 | '\u006F': 43, // o 97 | '\u0070': 44, // p 98 | '\u0071': 45, // q 99 | '\u0072': 46, // r 100 | '\u0073': 47, // s 101 | '\u0074': 48, // t 102 | '\u0075': 49, // u 103 | '\u0076': 50, // v 104 | '\u0077': 51, // w 105 | '\u0078': 52, // x 106 | '\u0079': 53, // y 107 | '\u007A': 54, // z 108 | 109 | // HARD KEYS 110 | '\uE101': 26, // POWER 111 | '\uE102': 24, // VOLUME_UP 112 | '\uE103': 25, // VOLUME_DOWN 113 | '\uE104': 164, // VOLUME_MUTE 114 | '\uE105': 3, // HOME_SCREEN 115 | '\uE106': 4, // BACK 116 | '\uE107': 82, // MENU 117 | '\uE108': 27, // CAMERA 118 | '\uE109': 5, // CALL 119 | '\uE10A': 6, // END_CALL 120 | '\uE10B': 84, // SEARCH 121 | '\uE10C': 21, // DPAD_LEFT 122 | '\uE10D': 19, // DPAD_UP 123 | '\uE10E': 22, // DPAD_RIGHT 124 | '\uE10F': 20, // DPAD_DOWN 125 | '\uE110': 23 // DPAD_CENTER 126 | }; 127 | 128 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const logger = require('xlogger'); 5 | const options = { 6 | logFileDir: path.join(__dirname, '..', '..', 'logs') 7 | }; 8 | 9 | module.exports = logger.Logger(options); 10 | -------------------------------------------------------------------------------- /lib/macaca-android.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const EOL = require('os').EOL; 5 | const ADB = require('macaca-adb'); 6 | const UnlockApk = require('unlock-apk'); 7 | const DriverBase = require('driver-base'); 8 | const UIAutomatorWD = require('uiautomatorwd'); 9 | const ChromeDriver = require('macaca-chromedriver'); 10 | 11 | const _ = require('./helper'); 12 | const logger = require('./logger'); 13 | const controllers = require('./controllers'); 14 | 15 | const reuseStatus = {}; 16 | 17 | reuseStatus.noReuse = 0; 18 | reuseStatus.reuseEmu = 1; 19 | reuseStatus.reuseEmuApp = 2; 20 | reuseStatus.reuseEmuAppState = 3; 21 | 22 | _.sudoUserPermissionDenied(); 23 | 24 | class Android extends DriverBase { 25 | constructor() { 26 | super(); 27 | this.adb = null; 28 | this.apkInfo = null; 29 | this.args = null; 30 | this.chromedriver = null; 31 | this.chromeDriverPort = null; 32 | this.proxy = null; 33 | this.udid = null; 34 | this.uiautomator = null; 35 | this.isChrome = null; 36 | this.isVirtual = true; 37 | this.contexts = []; 38 | this.context = null; 39 | this.isWaitActivity = false; 40 | this.isActivityReady = false; 41 | } 42 | } 43 | 44 | Android.prototype.startDevice = function * (caps) { 45 | this.args = _.clone(caps); 46 | this.isChrome = this.args.browserName && this.args.browserName.toLowerCase() === 'chrome'; 47 | this.initReuse(); 48 | this.initAdb(); 49 | yield this.initDevice(); 50 | yield this.initUiautomator(caps.permissionPatterns); 51 | yield this.getApkInfo(); 52 | yield this.unlock(); 53 | yield this.launchApk(); 54 | 55 | this.autoAcceptAlerts = Boolean(caps.autoAcceptAlerts); 56 | this.autoDismissAlerts = Boolean(caps.autoDismissAlerts); 57 | this.isWaitActivity = Boolean(caps.isWaitActivity); 58 | 59 | if (this.isWaitActivity) { 60 | yield this.waitActivityReady(); 61 | } 62 | 63 | if (this.isChrome) { 64 | yield this.getWebviews(); 65 | } 66 | }; 67 | 68 | Android.prototype.stopDevice = function * () { 69 | this.chromedriver && this.chromedriver.stop(); 70 | if (this.isVirtual && this.args.reuse === reuseStatus.noReuse) { 71 | return ADB 72 | .emuKill() 73 | .catch(e => { 74 | logger.warn(e); 75 | }); 76 | } 77 | 78 | return Promise.resolve(); 79 | }; 80 | 81 | Android.prototype.isProxy = function() { 82 | return !!this.proxy; 83 | }; 84 | 85 | Android.prototype.whiteList = function(context) { 86 | var basename = path.basename(context.url); 87 | const whiteList = [ 88 | 'context', 89 | 'contexts', 90 | 'screenshot', 91 | 'back', 92 | 'tap', 93 | 'source', 94 | 'keys', 95 | 'url', 96 | 'title' 97 | ]; 98 | return !!~whiteList.indexOf(basename); 99 | }; 100 | 101 | Android.prototype.proxyCommand = function * (url, method, body) { 102 | 103 | if (this.autoAcceptAlerts) { 104 | const acceptUrl = '/wd/hub/session/:sessionId/accept_alert'; 105 | yield this.proxy.sendCommand(acceptUrl, 'POST', {}); 106 | } else if (this.autoDismissAlerts) { 107 | const dismissUrl = '/wd/hub/session/:sessionId/dismiss_alert'; 108 | yield this.proxy.sendCommand(dismissUrl, 'POST', {}); 109 | } 110 | 111 | url = url.replace('property', 'attribute'); 112 | return yield this.proxy.sendCommand(url, method, body); 113 | }; 114 | 115 | Android.prototype.waitActivityReady = function * () { 116 | 117 | yield _.sleep(1000); 118 | 119 | try { 120 | this.isActivityReady = yield this.adb.isActivityReady(this.apkInfo.package, this.apkInfo.activity); 121 | } catch (e) { 122 | logger.info(`waiting for activity: \`${this.apkInfo.activity}\` ready`); 123 | } 124 | 125 | if (!this.isActivityReady) { 126 | yield this.waitActivityReady(); 127 | } 128 | }; 129 | 130 | Android.prototype.initAdb = function() { 131 | this.adb = new ADB(); 132 | }; 133 | 134 | Android.prototype.initReuse = function() { 135 | let reuse = parseInt(this.args.reuse, 10); 136 | if (!reuse && reuse !== reuseStatus.noReuse) { 137 | reuse = reuseStatus.reuseEmu; 138 | } 139 | this.args.reuse = reuse; 140 | }; 141 | 142 | Android.prototype.initUiautomator = function * (permissionPatterns) { 143 | this.uiautomator = new UIAutomatorWD(); 144 | this.proxy = this.uiautomator; 145 | permissionPatterns = permissionPatterns || '[]'; 146 | logger.info(`checking permissionPatterns: ${permissionPatterns}`); 147 | yield this.uiautomator.init(this.adb, permissionPatterns); 148 | }; 149 | 150 | Android.prototype.initDevice = function * () { 151 | 152 | if (this.args.udid) { 153 | this.udid = this.args.udid; 154 | this.adb.setDeviceId(this.udid); 155 | return; 156 | } 157 | var devices = yield ADB.getDevices(); 158 | var device = devices[0]; 159 | 160 | if (device) { 161 | this.adb.setDeviceId(device.udid); 162 | this.udid = device.udid; 163 | } else { 164 | logger.info('no device, now create one from avd'); 165 | var env = global.process.env; 166 | var emulatorCommand = path.resolve(env.ANDROID_HOME, 'tools', 'emulator'); 167 | var androidCommand = path.resolve(env.ANDROID_HOME, 'tools', 'android'); 168 | 169 | var data = yield _.exec(`${androidCommand} list avd`); 170 | data = data.split(EOL); 171 | data.shift(); 172 | 173 | if (data.length === 0) { 174 | throw new Error('no avd created! Please create one avd first'); 175 | } else { 176 | var avdArr = data.filter(avd => { 177 | return /Name:/.test(avd); 178 | }).map(avd => { 179 | return _.trim(avd.split(':')[1]); 180 | }); 181 | 182 | _.exec(`${emulatorCommand} -avd ${avdArr[0]}`); 183 | 184 | var checkEmulator = () => { 185 | return new Promise((resolve, reject) => { 186 | ADB.getBootStatus().then(data => { 187 | resolve(data === 'stopped'); 188 | }).catch(() => { 189 | reject('check emulator failed'); 190 | }); 191 | }); 192 | }; 193 | yield _.waitForCondition(checkEmulator, 60 * 1000, 2 * 1000); 194 | 195 | devices = yield ADB.getDevices(); 196 | device = devices[0]; 197 | 198 | if (device) { 199 | this.adb.setDeviceId(device.udid); 200 | this.udid = device.udid; 201 | } else { 202 | throw new Error('emulator start failed or too slow!'); 203 | } 204 | } 205 | } 206 | this.isVirtual = device.type === 'virtual'; 207 | }; 208 | 209 | Android.prototype.getApkInfo = function * () { 210 | 211 | if (this.isChrome) { 212 | this.apkInfo = { 213 | package: 'com.android.browser', 214 | activity: '.BrowserActivity' 215 | }; 216 | return; 217 | } 218 | const pkg = this.args.package; 219 | const activity = this.args.activity; 220 | const app = this.args.app; 221 | const androidProcess = this.args.androidProcess; 222 | 223 | if (pkg) { 224 | this.apkInfo = { 225 | package: pkg, 226 | activity: activity, 227 | androidProcess: androidProcess 228 | }; 229 | } else if (app) { 230 | this.apkInfo = yield ADB.getApkMainifest(app); 231 | } else { 232 | throw new Error('Either app path or package name should be provided!'); 233 | } 234 | }; 235 | 236 | Android.prototype.unlock = function * () { 237 | 238 | if (!_.isExistedFile(UnlockApk.apkPath)) { 239 | logger.warn(`unlock apk not found in: ${UnlockApk.apkPath}`); 240 | return; 241 | } 242 | 243 | const isInstalled = yield this.adb.isInstalled(UnlockApk.package); 244 | 245 | if (isInstalled) { 246 | yield this.checkApkVersion(UnlockApk.apkPath, UnlockApk.package); 247 | } else { 248 | yield this.adb.install(UnlockApk.apkPath); 249 | } 250 | 251 | var isScreenLocked = yield this.adb.isScreenLocked(); 252 | 253 | if (isScreenLocked) { 254 | yield this.adb.startApp(UnlockApk); 255 | yield _.sleep(5000); 256 | yield this.unlock(); 257 | } 258 | }; 259 | 260 | Android.prototype.checkApkVersion = function * (app, pkg) { 261 | var newVersion = yield ADB.getApkVersion(app); 262 | var oldVersion = yield this.adb.getInstalledApkVersion(pkg); 263 | if (newVersion > oldVersion) { 264 | yield this.adb.install(app); 265 | } 266 | }; 267 | 268 | Android.prototype.launchApk = function * () { 269 | 270 | if (!this.isChrome) { 271 | const reuse = this.args.reuse; 272 | const app = this.args.app; 273 | const pkg = this.apkInfo.package; 274 | const isInstalled = yield this.adb.isInstalled(pkg); 275 | if (!isInstalled && !app) { 276 | throw new Error('App is neither installed, nor provided!'); 277 | } 278 | if (isInstalled) { 279 | switch (reuse) { 280 | case reuseStatus.noReuse: 281 | case reuseStatus.reuseEmu: 282 | if (app) { 283 | yield this.adb.unInstall(pkg); 284 | yield this.adb.install(app); 285 | } else { 286 | yield this.adb.clear(pkg); 287 | } 288 | break; 289 | case reuseStatus.reuseEmuApp: 290 | if (app) { 291 | yield this.adb.install(app); 292 | } 293 | break; 294 | case reuseStatus.reuseEmuAppState: 295 | // Keep app state, don't change to main activity. 296 | this.apkInfo.activity = ''; 297 | } 298 | } else { 299 | yield this.adb.install(app); 300 | } 301 | } 302 | 303 | logger.debug(`start app with: ${JSON.stringify(this.apkInfo)}`); 304 | yield this.adb.startApp(this.apkInfo); 305 | yield _.sleep(5 * 1000); 306 | }; 307 | 308 | Android.prototype.getWebviews = function * () { 309 | if (!this.chromedriver) { 310 | var webviewVersion = null; 311 | try { 312 | webviewVersion = yield this.adb.getWebviewVersion(); 313 | } catch (error) { 314 | console.log(error); 315 | logger.info('No webview version found from adb shell!'); 316 | webviewVersion = null; 317 | } 318 | try { 319 | if (webviewVersion) { 320 | yield this.initChromeDriver({ 321 | webviewVersion: webviewVersion 322 | }); 323 | } else { 324 | return []; 325 | } 326 | } catch (error) { 327 | logger.error('initChromeDriver failed!' + error); 328 | this.chromedriver = null; 329 | return []; 330 | } 331 | } 332 | this.proxy = this.chromedriver; 333 | 334 | var webviews = []; 335 | 336 | try { 337 | const result = yield this.proxy.sendCommand('/wd/hub/session/:sessionId/window_handles', 'GET', {}); 338 | webviews = _.parseWebDriverResult(result); 339 | } catch (e) { 340 | console.log(e); 341 | } 342 | 343 | return webviews; 344 | }; 345 | 346 | Android.prototype.initChromeDriver = function(options) { 347 | return new Promise((resolve, reject) => { 348 | this.chromedriver = new ChromeDriver(options); 349 | if (this.chromedriver.binPathReady) { 350 | logger.info('starting chromedriver service!'); 351 | this.chromedriver.start({ 352 | chromeOptions: { 353 | androidPackage: this.apkInfo.package, 354 | androidUseRunningApp: true, 355 | androidDeviceSerial: this.udid, 356 | androidProcess: this.apkInfo.androidProcess 357 | } 358 | }); 359 | } 360 | this.chromedriver.on(ChromeDriver.BIN_READY, data => { 361 | logger.info(`chromedriver bin file ready: ${data}`); 362 | }); 363 | this.chromedriver.on(ChromeDriver.EVENT_READY, data => { 364 | logger.info(`chromedriver ready with: ${JSON.stringify(data)}`); 365 | resolve(''); 366 | }); 367 | this.chromedriver.on(ChromeDriver.EVENT_ERROR, data => { 368 | logger.error(`chromedriver error with: ${data}`); 369 | reject(data); 370 | }); 371 | 372 | logger.info('starting chromedriver service!'); 373 | 374 | this.chromedriver.start({ 375 | chromeOptions: { 376 | androidPackage: this.apkInfo.package, 377 | androidUseRunningApp: true, 378 | androidDeviceSerial: this.udid, 379 | androidProcess: this.apkInfo.androidProcess 380 | } 381 | }); 382 | }); 383 | }; 384 | 385 | _.extend(Android.prototype, controllers); 386 | 387 | module.exports = Android; 388 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "macaca-android", 3 | "version": "2.2.2", 4 | "description": "Macaca Android driver", 5 | "keywords": [ 6 | "android", 7 | "macaca" 8 | ], 9 | "main": "./lib/macaca-android", 10 | "files": [ 11 | "lib/**/*.js" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/macacajs/macaca-android.git" 16 | }, 17 | "dependencies": { 18 | "driver-base": "~0.1.0", 19 | "macaca-adb": "~1.0.3", 20 | "macaca-chromedriver": "~1.0.1", 21 | "macaca-utils": "^1.0.0", 22 | "temp": "~0.8.3", 23 | "uiautomatorwd": "^1.2.1", 24 | "unlock-apk": "^1.2.0", 25 | "webdriver-dfn-error-code": "~1.0.1", 26 | "xlogger": "~1.0.0", 27 | "xml2map2": "^1.0.2" 28 | }, 29 | "devDependencies": { 30 | "eslint": "^4.14.0", 31 | "eslint-plugin-mocha": "^4.11.0", 32 | "git-contributor": "1", 33 | "husky": "^1.3.1", 34 | "mocha": "*", 35 | "nyc": "^13.3.0" 36 | }, 37 | "husky": { 38 | "hooks": { 39 | "pre-commit": "npm run lint" 40 | } 41 | }, 42 | "scripts": { 43 | "test": "nyc --reporter=lcov --reporter=text mocha", 44 | "lint": "eslint --fix lib test", 45 | "contributor": "git-contributor" 46 | }, 47 | "license": "MIT" 48 | } 49 | -------------------------------------------------------------------------------- /test/macaca-android.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | 5 | const Android = require('../lib/macaca-android'); 6 | 7 | describe('test', () => { 8 | it('should be ok', () => { 9 | assert.ok(Android); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | --------------------------------------------------------------------------------