├── .github
├── ISSUE_TEMPLATE.md
├── dependabot.yml
└── workflows
│ ├── functional-test.yml
│ ├── pr-title.yml
│ ├── publish.js.yml
│ └── unit-test.yml
├── .gitignore
├── .mocharc.js
├── .npmrc
├── .releaserc
├── .vscode
└── tasks.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── docs
├── COMMANDS.md
└── static
│ └── send-sms-screen.png
├── eslint.config.mjs
├── index.ts
├── keys
├── testkey.pk8
└── testkey.x509.pem
├── lib
├── adb.ts
├── helpers.js
├── logcat.js
├── logger.js
├── options.ts
├── tools
│ ├── aab-utils.js
│ ├── android-manifest.js
│ ├── apk-signing.js
│ ├── apk-utils.js
│ ├── apks-utils.js
│ ├── app-commands.js
│ ├── device-settings.js
│ ├── emu-constants.ts
│ ├── emulator-commands.js
│ ├── fs-commands.js
│ ├── general-commands.js
│ ├── keyboard-commands.js
│ ├── lockmgmt.js
│ ├── logcat-commands.js
│ ├── network-commands.js
│ ├── system-calls.js
│ └── types.ts
└── types.ts
├── package.json
├── test
├── .eslintrc
├── fixtures
│ ├── ApiDemos-debug.apk
│ ├── ContactManager-old.apk
│ ├── ContactManager-selendroid.apk
│ ├── ContactManager.apk
│ ├── Fingerprint.apk
│ ├── TestZip.zip
│ ├── appiumtest.keystore
│ ├── selendroid-test-app.apk
│ └── selendroid
│ │ ├── AndroidManifest.xml
│ │ └── selendroid.apk
├── functional
│ ├── adb-commands-e2e-specs.js
│ ├── adb-e2e-specs.js
│ ├── adb-emu-commands-e2e-specs.js
│ ├── android-manifest-e2e-specs.js
│ ├── apk-signing-e2e-specs.js
│ ├── apk-utils-e2e-specs.js
│ ├── helpers-specs-e2e-specs.js
│ ├── lock-mgmt-e2e-specs.js
│ ├── logcat-e2e-specs.js
│ ├── setup.js
│ └── syscalls-e2e-specs.js
└── unit
│ ├── adb-commands-specs.js
│ ├── adb-emu-commands-specs.js
│ ├── adb-specs.js
│ ├── apk-signing-specs.js
│ ├── apk-utils-specs.js
│ ├── helper-specs.js
│ ├── logcat-specs.js
│ └── syscalls-specs.js
└── tsconfig.json
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | **Warning:**
2 |
3 | These issues are not tracked. Please create new issues in the main Appium
4 | repository: https://github.com/appium/appium/issues/new
5 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "11:00"
8 | open-pull-requests-limit: 10
9 |
--------------------------------------------------------------------------------
/.github/workflows/functional-test.yml:
--------------------------------------------------------------------------------
1 | name: Functional Tests
2 |
3 | on: [pull_request]
4 |
5 | concurrency:
6 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
7 | cancel-in-progress: true
8 |
9 | jobs:
10 | test:
11 | strategy:
12 | matrix:
13 | include:
14 | - platformVersion: "16.0"
15 | apiLevel: 36
16 | emuTag: google_apis
17 | arch: x86_64
18 | - platformVersion: "12.0"
19 | apiLevel: 32
20 | emuTag: google_apis
21 | arch: x86_64
22 | - platformVersion: "11.0"
23 | apiLevel: 30
24 | emuTag: google_apis
25 | arch: x86
26 | - platformVersion: "9.0"
27 | apiLevel: 28
28 | emuTag: default
29 | arch: x86
30 | - platformVersion: "7.1"
31 | apiLevel: 25
32 | emuTag: default
33 | arch: x86
34 | - platformVersion: "5.1"
35 | apiLevel: 22
36 | emuTag: default
37 | arch: x86
38 | fail-fast: false
39 | env:
40 | CI: true
41 | ANDROID_AVD: emulator
42 | ANDROID_SDK_VERSION: "${{ matrix.apiLevel }}"
43 | PLATFORM_VERSION: "${{ matrix.platformVersion }}"
44 | EMU_TAG: "${{ matrix.emuTag }}"
45 | runs-on: ubuntu-latest
46 | steps:
47 | - uses: actions/checkout@v3
48 | - name: Enable KVM group perms
49 | run: |
50 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
51 | sudo udevadm control --reload-rules
52 | sudo udevadm trigger --name-match=kvm
53 | - uses: actions/setup-node@v3
54 | with:
55 | node-version: lts/*
56 | check-latest: true
57 | - uses: actions/setup-java@v3
58 | with:
59 | distribution: 'temurin'
60 | java-version: '17'
61 | - name: Setup Android SDK
62 | uses: android-actions/setup-android@v3
63 | - run: npm install --no-package-lock
64 | name: Install dev dependencies
65 | - name: AVD cache
66 | uses: actions/cache@v3
67 | id: avd-cache
68 | with:
69 | path: |
70 | ~/.android/avd/*
71 | ~/.android/adb*
72 | key: avd-${{ matrix.apiLevel }}
73 | # https://github.com/marketplace/actions/android-emulator-runner
74 | - uses: reactivecircus/android-emulator-runner@v2
75 | if: steps.avd-cache.outputs.cache-hit != 'true'
76 | name: Generate AVD snapshot for caching
77 | with:
78 | script: echo "Generated AVD snapshot for caching."
79 | avd-name: $ANDROID_AVD
80 | force-avd-creation: false
81 | api-level: ${{ matrix.apiLevel }}
82 | disable-spellchecker: true
83 | target: ${{ matrix.emuTag }}
84 | emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
85 | arch: ${{ matrix.arch }}
86 | disable-animations: true
87 | - run: nohup adb logcat > logcat.log &
88 | name: Capture Logcat
89 | - uses: reactivecircus/android-emulator-runner@v2
90 | name: api${{ matrix.apiLevel }}_e2e
91 | with:
92 | script: npm run e2e-test
93 | avd-name: $ANDROID_AVD
94 | force-avd-creation: false
95 | api-level: ${{ matrix.apiLevel }}
96 | disable-spellchecker: true
97 | target: ${{ matrix.emuTag }}
98 | emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
99 | arch: ${{ matrix.arch }}
100 | disable-animations: true
101 | - name: Save logcat output
102 | if: ${{ always() }}
103 | uses: actions/upload-artifact@master
104 | with:
105 | name: logcat-api${{ matrix.apiLevel }}
106 | path: logcat.log
107 |
--------------------------------------------------------------------------------
/.github/workflows/pr-title.yml:
--------------------------------------------------------------------------------
1 | name: Conventional Commits
2 | on:
3 | pull_request:
4 | types: [opened, edited, synchronize, reopened]
5 |
6 |
7 | jobs:
8 | lint:
9 | name: https://www.conventionalcommits.org
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: beemojs/conventional-pr-action@v3
13 | with:
14 | config-preset: angular
15 | env:
16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
17 |
--------------------------------------------------------------------------------
/.github/workflows/publish.js.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches: [ master ]
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - name: Use Node.js LTS
14 | uses: actions/setup-node@v3
15 | with:
16 | node-version: lts/*
17 | check-latest: true
18 | - run: npm install --no-package-lock
19 | name: Install dev dependencies
20 | - run: npm run build
21 | name: Run build
22 | - run: npm run test
23 | name: Run unit tests
24 | - run: npx semantic-release
25 | env:
26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
28 | name: Release
29 |
30 |
--------------------------------------------------------------------------------
/.github/workflows/unit-test.yml:
--------------------------------------------------------------------------------
1 | name: Unit Tests
2 |
3 | on: [pull_request, push]
4 |
5 |
6 | jobs:
7 | prepare_matrix:
8 | runs-on: ubuntu-latest
9 | outputs:
10 | versions: ${{ steps.generate-matrix.outputs.lts }}
11 | steps:
12 | - name: Select all current LTS versions of Node.js
13 | id: generate-matrix
14 | uses: msimerson/node-lts-versions@v1
15 |
16 | test:
17 | needs:
18 | - prepare_matrix
19 | strategy:
20 | matrix:
21 | node-version: ${{ fromJSON(needs.prepare_matrix.outputs.versions) }}
22 | runs-on: ubuntu-latest
23 | steps:
24 | - uses: actions/checkout@v3
25 | - uses: actions/setup-node@v3
26 | with:
27 | node-version: ${{ matrix.node-version }}
28 | - run: npm install --no-package-lock
29 | name: Install dev dependencies
30 | - run: npm run build
31 | name: Build
32 | - run: npm run lint
33 | name: Run linter
34 | - run: npm run test
35 | name: Run unit tests
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | lib-cov
3 | *.seed
4 | *.log
5 | *.csv
6 | *.dat
7 | *.out
8 | *.pid
9 | *.gz
10 |
11 | pids
12 | logs
13 | results
14 | build
15 |
16 | npm-debug.log
17 | node_modules
18 | coverage
19 | coverage-e2e
20 | package-lock.json*
21 | .DS_Store
22 | bugreport-*
23 | .vscode
24 |
--------------------------------------------------------------------------------
/.mocharc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | require: ['ts-node/register'],
3 | forbidOnly: Boolean(process.env.CI),
4 | color: true
5 | };
6 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
--------------------------------------------------------------------------------
/.releaserc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | ["@semantic-release/commit-analyzer", {
4 | "preset": "angular",
5 | "releaseRules": [
6 | {"type": "chore", "release": "patch"}
7 | ]
8 | }],
9 | ["@semantic-release/release-notes-generator", {
10 | "preset": "conventionalcommits",
11 | "presetConfig": {
12 | "types": [
13 | {"type": "feat", "section": "Features"},
14 | {"type": "fix", "section": "Bug Fixes"},
15 | {"type": "perf", "section": "Performance Improvements"},
16 | {"type": "revert", "section": "Reverts"},
17 | {"type": "chore", "section": "Miscellaneous Chores"},
18 | {"type": "refactor", "section": "Code Refactoring"},
19 | {"type": "docs", "section": "Documentation", "hidden": true},
20 | {"type": "style", "section": "Styles", "hidden": true},
21 | {"type": "test", "section": "Tests", "hidden": true},
22 | {"type": "build", "section": "Build System", "hidden": true},
23 | {"type": "ci", "section": "Continuous Integration", "hidden": true}
24 | ]
25 | }
26 | }],
27 | ["@semantic-release/changelog", {
28 | "changelogFile": "CHANGELOG.md"
29 | }],
30 | "@semantic-release/npm",
31 | ["@semantic-release/git", {
32 | "assets": ["docs", "package.json", "CHANGELOG.md"],
33 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
34 | }],
35 | "@semantic-release/github"
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "npm",
6 | "script": "dev",
7 | "problemMatcher": [],
8 | "label": "npm: dev",
9 | "detail": "npm run build -- --watch",
10 | "group": {
11 | "kind": "build",
12 | "isDefault": true
13 | }
14 | }
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright JS Foundation and other contributors, https://js.foundation
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | appium-adb
2 | ==========
3 |
4 | [](https://npmjs.org/package/appium-adb)
5 | [](https://npmjs.org/package/appium-adb)
6 |
7 | A wrapper over [Android Debugger Bridge](https://developer.android.com/tools/adb), implemented using ES6
8 | and along with `async/await`. This package is mainly used by Appium to perform all adb operations on Android devices.
9 |
10 | ## Installing
11 |
12 | ```bash
13 | npm install appium-adb
14 | ```
15 |
16 | ## Watch
17 |
18 | ```bash
19 | npm run dev
20 | ```
21 |
22 | ## Test
23 |
24 | ### unit tests
25 |
26 | ```bash
27 | npm run test
28 | ```
29 |
30 | ### functional tests
31 |
32 | By default the functional tests use an avd named `NEXUS_S_18_X86`, with API Level
33 | 18. To change this, you can use the environment variables `PLATFORM_VERSION`,
34 | `API_LEVEL`, and `ANDROID_AVD`. If `PLATFORM_VERSION` is set then it is not
35 | necessary to set `API_LEVEL` as it will be inferred.
36 |
37 | ```bash
38 | npm run e2e-test
39 | ```
40 |
41 | ## Usage:
42 |
43 | example:
44 |
45 | ```js
46 | import { ADB } from 'appium-adb';
47 |
48 | const adb = await ADB.createADB();
49 | console.log(await adb.getPIDsByName('com.android.phone'));
50 | ```
51 |
--------------------------------------------------------------------------------
/docs/COMMANDS.md:
--------------------------------------------------------------------------------
1 | # Usage
2 |
3 | # powerAC
4 |
5 | ```javascript
6 | let state = 'off';
7 | await adb.powerAC(state);
8 | ```
9 | Possible values:
10 | * on
11 | * off
12 |
13 | # powerCapacity
14 | ```javascript
15 | let batteryPercent = 50;
16 | await adb.powerCapacity(batteryPercent);
17 | ```
18 |
19 | # powerOFF
20 | ```javascript
21 | await adb.powerOFF();
22 | ```
23 |
24 | # gsmCall
25 | ```javascript
26 | let action = 'call';
27 | let phoneNumber = 4509;
28 | await adb.gsmCall(phoneNumber, action);
29 | ```
30 |
31 | Possible values:
32 | * call
33 | * accept
34 | * hold
35 | * cancel
36 |
37 | # gsmSignal
38 | ```javascript
39 | let signalStrengh = 0;
40 | await adb.gsmSignal(signalStrengh);
41 | ```
42 | Possible values: 0..4
43 |
44 | # gsmVoice
45 | ```javascript
46 | let state = 'roaming';
47 | await adb.gsmVoice(state);
48 | ```
49 |
50 | Possible values:
51 |
52 | * unregistered
53 | * home
54 | * roaming
55 | * searching
56 | * denied
57 | * off (unregistered alias)
58 | * on (home alias)
59 |
60 | # sendSMS
61 |
62 | ```javascript
63 | let phoneNumber = 4509;
64 | let message = "Hello Appium"
65 | await adb.sendSMS(phoneNumber, message);
66 | ```
67 |
68 |
69 |
70 |
71 |
72 |
73 | # rotate
74 |
75 | ```javascript
76 | await adb.rotate();
77 | ```
78 |
79 | # root
80 |
81 | ```javascript
82 | await adb.root();
83 | ```
84 |
85 | # unroot
86 |
87 | ```javascript
88 | await adb.unroot();
89 | ```
90 |
--------------------------------------------------------------------------------
/docs/static/send-sms-screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/docs/static/send-sms-screen.png
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import appiumConfig from '@appium/eslint-config-appium-ts';
2 |
3 | export default [
4 | ...appiumConfig,
5 | {
6 | ignores: [
7 | 'keys/**',
8 | ],
9 | },
10 | ];
11 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | import {install} from 'source-map-support';
2 | install();
3 |
4 | export * from './lib/adb';
5 | export {getAndroidBinaryPath} from './lib/tools/system-calls';
6 | export {getSdkRootFromEnv} from './lib/helpers';
7 | export type * from './lib/tools/types';
8 | export type * from './lib/types';
9 |
10 | import {ADB} from './lib/adb';
11 | export default ADB;
12 |
--------------------------------------------------------------------------------
/keys/testkey.pk8:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/keys/testkey.pk8
--------------------------------------------------------------------------------
/keys/testkey.x509.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIEqDCCA5CgAwIBAgIJAJNurL4H8gHfMA0GCSqGSIb3DQEBBQUAMIGUMQswCQYD
3 | VQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4g
4 | VmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UE
5 | AxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAe
6 | Fw0wODAyMjkwMTMzNDZaFw0zNTA3MTcwMTMzNDZaMIGUMQswCQYDVQQGEwJVUzET
7 | MBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4G
8 | A1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9p
9 | ZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZI
10 | hvcNAQEBBQADggENADCCAQgCggEBANaTGQTexgskse3HYuDZ2CU+Ps1s6x3i/waM
11 | qOi8qM1r03hupwqnbOYOuw+ZNVn/2T53qUPn6D1LZLjk/qLT5lbx4meoG7+yMLV4
12 | wgRDvkxyGLhG9SEVhvA4oU6Jwr44f46+z4/Kw9oe4zDJ6pPQp8PcSvNQIg1QCAcy
13 | 4ICXF+5qBTNZ5qaU7Cyz8oSgpGbIepTYOzEJOmc3Li9kEsBubULxWBjf/gOBzAzU
14 | RNps3cO4JFgZSAGzJWQTT7/emMkod0jb9WdqVA2BVMi7yge54kdVMxHEa5r3b97s
15 | zI5p58ii0I54JiCUP5lyfTwE/nKZHZnfm644oLIXf6MdW2r+6R8CAQOjgfwwgfkw
16 | HQYDVR0OBBYEFEhZAFY9JyxGrhGGBaR0GawJyowRMIHJBgNVHSMEgcEwgb6AFEhZ
17 | AFY9JyxGrhGGBaR0GawJyowRoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UE
18 | CBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMH
19 | QW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAG
20 | CSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJAJNurL4H8gHfMAwGA1Ud
21 | EwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAHqvlozrUMRBBVEY0NqrrwFbinZa
22 | J6cVosK0TyIUFf/azgMJWr+kLfcHCHJsIGnlw27drgQAvilFLAhLwn62oX6snb4Y
23 | LCBOsVMR9FXYJLZW2+TcIkCRLXWG/oiVHQGo/rWuWkJgU134NDEFJCJGjDbiLCpe
24 | +ZTWHdcwauTJ9pUbo8EvHRkU3cYfGmLaLfgn9gP+pWA7LFQNvXwBnDa6sppCccEX
25 | 31I828XzgXpJ4O+mDL1/dBd+ek8ZPUP0IgdyZm5MTYPhvVqGCHzzTy3sIeJFymwr
26 | sBbmg2OAUNLEMO6nwmocSdN2ClirfxqCzJOLSDE4QyS9BAH6EhY6UFcOaE0=
27 | -----END CERTIFICATE-----
28 |
--------------------------------------------------------------------------------
/lib/helpers.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { system, fs, zip, util } from '@appium/support';
3 | import { log } from './logger.js';
4 | import _ from 'lodash';
5 | import { exec } from 'teen_process';
6 |
7 | export const APKS_EXTENSION = '.apks';
8 | export const APK_EXTENSION = '.apk';
9 | export const APK_INSTALL_TIMEOUT = 60000;
10 | export const DEFAULT_ADB_EXEC_TIMEOUT = 20000; // in milliseconds
11 | const MODULE_NAME = 'appium-adb';
12 |
13 | /**
14 | * Calculates the absolute path to the current module's root folder
15 | *
16 | * @returns {Promise} The full path to module root
17 | * @throws {Error} If the current module root folder cannot be determined
18 | */
19 | const getModuleRoot = _.memoize(async function getModuleRoot () {
20 | let moduleRoot = path.dirname(path.resolve(__filename));
21 | let isAtFsRoot = false;
22 | while (!isAtFsRoot) {
23 | const manifestPath = path.join(moduleRoot, 'package.json');
24 | try {
25 | if (await fs.exists(manifestPath) &&
26 | JSON.parse(await fs.readFile(manifestPath, 'utf8')).name === MODULE_NAME) {
27 | return moduleRoot;
28 | }
29 | } catch {}
30 | moduleRoot = path.dirname(moduleRoot);
31 | isAtFsRoot = moduleRoot.length <= path.dirname(moduleRoot).length;
32 | }
33 | if (isAtFsRoot) {
34 | throw new Error(`Cannot find the root folder of the ${MODULE_NAME} Node.js module`);
35 | }
36 | return moduleRoot;
37 | });
38 |
39 | /**
40 | * Calculates the absolsute path to the given resource
41 | *
42 | * @param {string} relPath Relative path to the resource starting from the current module root
43 | * @returns {Promise} The full path to the resource
44 | * @throws {Error} If the absolute resource path cannot be determined
45 | */
46 | export const getResourcePath = _.memoize(async function getResourcePath (relPath) {
47 | const moduleRoot = await getModuleRoot();
48 | const resultPath = path.resolve(moduleRoot, relPath);
49 | if (!await fs.exists(resultPath)) {
50 | throw new Error(`Cannot find the resource '${relPath}' under the '${moduleRoot}' ` +
51 | `folder of ${MODULE_NAME} Node.js module`);
52 | }
53 | return resultPath;
54 | });
55 |
56 | /**
57 | * Retrieves the actual path to SDK root folder from the system environment
58 | *
59 | * @return {string|undefined} The full path to the SDK root folder
60 | */
61 | export function getSdkRootFromEnv () {
62 | return process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
63 | }
64 |
65 | /**
66 | * Retrieves the actual path to SDK root folder
67 | *
68 | * @param {string?} [customRoot]
69 | * @return {Promise} The full path to the SDK root folder
70 | * @throws {Error} If either the corresponding env variable is unset or is
71 | * pointing to an invalid file system entry
72 | */
73 | export async function requireSdkRoot (customRoot = null) {
74 | const sdkRoot = customRoot || getSdkRootFromEnv();
75 | const docMsg = 'Read https://developer.android.com/studio/command-line/variables for more details';
76 | if (_.isEmpty(sdkRoot)) {
77 | throw new Error(`Neither ANDROID_HOME nor ANDROID_SDK_ROOT environment variable was exported. ${docMsg}`);
78 | }
79 |
80 | if (!await fs.exists(/** @type {string} */ (sdkRoot))) {
81 | throw new Error(`The Android SDK root folder '${sdkRoot}' does not exist on the local file system. ${docMsg}`);
82 | }
83 | const stats = await fs.stat(/** @type {string} */ (sdkRoot));
84 | if (!stats.isDirectory()) {
85 | throw new Error(`The Android SDK root '${sdkRoot}' must be a folder. ${docMsg}`);
86 | }
87 | return /** @type {string} */ (sdkRoot);
88 | }
89 |
90 | /**
91 | * @param {string} zipPath
92 | * @param {string} dstRoot
93 | */
94 | export async function unzipFile (zipPath, dstRoot = path.dirname(zipPath)) {
95 | log.debug(`Unzipping '${zipPath}' to '${dstRoot}'`);
96 | await zip.assertValidZip(zipPath);
97 | await zip.extractAllTo(zipPath, dstRoot);
98 | log.debug('Unzip successful');
99 | }
100 |
101 | /** @type {() => Promise} */
102 | export const getJavaHome = _.memoize(async function getJavaHome () {
103 | const result = process.env.JAVA_HOME;
104 | if (!result) {
105 | throw new Error('The JAVA_HOME environment variable is not set for the current process');
106 | }
107 | if (!await fs.exists(result)) {
108 | throw new Error(`The JAVA_HOME location '${result}' must exist`);
109 | }
110 | const stats = await fs.stat(result);
111 | if (!stats.isDirectory()) {
112 | throw new Error(`The JAVA_HOME location '${result}' must be a valid folder`);
113 | }
114 | return result;
115 | });
116 |
117 | /** @type {() => Promise} */
118 | export const getJavaForOs = _.memoize(async function getJavaForOs () {
119 | let javaHome;
120 | let errMsg;
121 | try {
122 | javaHome = await getJavaHome();
123 | } catch (err) {
124 | errMsg = err.message;
125 | }
126 | const executableName = `java${system.isWindows() ? '.exe' : ''}`;
127 | if (javaHome) {
128 | const resultPath = path.resolve(javaHome, 'bin', executableName);
129 | if (await fs.exists(resultPath)) {
130 | return resultPath;
131 | }
132 | }
133 | try {
134 | return await fs.which(executableName);
135 | } catch {}
136 | throw new Error(`The '${executableName}' binary could not be found ` +
137 | `neither in PATH nor under JAVA_HOME (${javaHome ? path.resolve(javaHome, 'bin') : errMsg})`);
138 | });
139 |
140 | /**
141 | * Transforms given options into the list of `adb install.install-multiple` command arguments
142 | *
143 | * @param {number} apiLevel - The current API level
144 | * @param {InstallOptions} [options={}] - The options mapping to transform
145 | * @returns {string[]} The array of arguments
146 | */
147 | export function buildInstallArgs (apiLevel, options = {}) {
148 | const result = [];
149 |
150 | if (!util.hasValue(options.replace) || options.replace) {
151 | result.push('-r');
152 | }
153 | if (options.allowTestPackages) {
154 | result.push('-t');
155 | }
156 | if (options.useSdcard) {
157 | result.push('-s');
158 | }
159 | if (options.grantPermissions) {
160 | if (apiLevel < 23) {
161 | log.debug(`Skipping permissions grant option, since ` +
162 | `the current API level ${apiLevel} does not support applications ` +
163 | `permissions customization`);
164 | } else {
165 | result.push('-g');
166 | }
167 | }
168 | // For multiple-install
169 | if (options.partialInstall) {
170 | result.push('-p');
171 | }
172 |
173 | return result;
174 | }
175 |
176 | /**
177 | * Extracts various package manifest details
178 | * from the given application file.
179 | *
180 | * @this {import('./adb.js').ADB}
181 | * @param {string} apkPath Full path to the application file.
182 | * @returns {Promise}
183 | */
184 | export async function readPackageManifest(apkPath) {
185 | await this.initAapt2();
186 | const aapt2Binary = (/** @type {import('./tools/types').StringRecord} */ (this.binaries)).aapt2;
187 |
188 | const args = ['dump', 'badging', apkPath];
189 | log.debug(`Reading package manifest: '${util.quote([aapt2Binary, ...args])}'`);
190 | /** @type {string} */
191 | let stdout;
192 | try {
193 | ({stdout} = await exec(aapt2Binary, args));
194 | } catch (e) {
195 | const prefix = `Cannot read the manifest from '${apkPath}'`;
196 | const suffix = `Original error: ${e.stderr || e.message}`;
197 | if (_.includes(e.stderr, `Unable to open 'badging'`)) {
198 | throw new Error(`${prefix}. Update build tools to use a newer aapt2 version. ${suffix}`);
199 | }
200 | throw new Error(`${prefix}. ${suffix}`);
201 | }
202 |
203 | const extractValue = (
204 | /** @type {string} */ line,
205 | /** @type {RegExp} */ propPattern,
206 | /** @type {((x: string) => any)|undefined} */ valueTransformer
207 | ) => {
208 | const match = propPattern.exec(line);
209 | if (match) {
210 | return valueTransformer ? valueTransformer(match[1]) : match[1];
211 | }
212 | };
213 | const extractArray = (
214 | /** @type {string} */ line,
215 | /** @type {RegExp} */ propPattern,
216 | /** @type {((x: string) => any)|undefined} */ valueTransformer
217 | ) => {
218 | let match;
219 | const resultArray = [];
220 | while ((match = propPattern.exec(line))) {
221 | resultArray.push(valueTransformer ? valueTransformer(match[1]) : match[1]);
222 | }
223 | return resultArray;
224 | };
225 |
226 | const toInt = (/** @type {string} */ x) => parseInt(x, 10);
227 |
228 | /** @type {import('./tools/types').ApkManifest} */
229 | const result = {
230 | name: '',
231 | versionCode: 0,
232 | minSdkVersion: 0,
233 | compileSdkVersion: 0,
234 | usesPermissions: [],
235 | launchableActivity: {
236 | name: '',
237 | },
238 | architectures: [],
239 | locales: [],
240 | densities: [],
241 | };
242 | for (const line of stdout.split('\n')) {
243 | if (line.startsWith('package:')) {
244 | for (const [name, pattern, transformer] of [
245 | ['name', /name='([^']+)'/],
246 | ['versionCode', /versionCode='([^']+)'/, toInt],
247 | ['versionName', /versionName='([^']+)'/],
248 | ['platformBuildVersionName', /platformBuildVersionName='([^']+)'/],
249 | ['platformBuildVersionCode', /platformBuildVersionCode='([^']+)'/, toInt],
250 | ['compileSdkVersion', /compileSdkVersion='([^']+)'/, toInt],
251 | ['compileSdkVersionCodename', /compileSdkVersionCodename='([^']+)'/],
252 | ]) {
253 | const value = extractValue(
254 | line,
255 | /** @type {RegExp} */ (pattern),
256 | /** @type {((x: string) => any)|undefined} */ (transformer)
257 | );
258 | if (!_.isUndefined(value)) {
259 | result[/** @type {string} */ (name)] = value;
260 | }
261 | }
262 | } else if (line.startsWith('sdkVersion:') || line.startsWith('minSdkVersion:')) {
263 | const value = extractValue(line, /[sS]dkVersion:'([^']+)'/, toInt);
264 | if (value) {
265 | result.minSdkVersion = value;
266 | }
267 | } else if (line.startsWith('targetSdkVersion:')) {
268 | const value = extractValue(line, /targetSdkVersion:'([^']+)'/, toInt);
269 | if (value) {
270 | result.targetSdkVersion = value;
271 | }
272 | } else if (line.startsWith('uses-permission:')) {
273 | const value = extractValue(line, /name='([^']+)'/);
274 | if (value) {
275 | result.usesPermissions.push(/** @type {string} */ (value));
276 | }
277 | } else if (line.startsWith('launchable-activity:')) {
278 | for (const [name, pattern] of [
279 | ['name', /name='([^']+)'/],
280 | ['label', /label='([^']+)'/],
281 | ['icon', /icon='([^']+)'/],
282 | ]) {
283 | const value = extractValue(line, /** @type {RegExp} */ (pattern));
284 | if (value) {
285 | result.launchableActivity[/** @type {string} */ (name)] = value;
286 | }
287 | }
288 | } else if (line.startsWith('locales:')) {
289 | result.locales = /** @type {string[]} */ (extractArray(line, /'([^']+)'/g));
290 | } else if (line.startsWith('native-code:')) {
291 | result.architectures = /** @type {string[]} */ (extractArray(line, /'([^']+)'/g));
292 | } else if (line.startsWith('densities:')) {
293 | result.densities = /** @type {number[]} */ (extractArray(line, /'([^']+)'/g, toInt));
294 | }
295 | }
296 | return result;
297 | }
298 |
299 | /**
300 | * @typedef {Object} InstallOptions
301 | * @property {boolean} [allowTestPackages=false] - Set to true in order to allow test
302 | * packages installation.
303 | * @property {boolean} [useSdcard=false] - Set to true to install the app on sdcard
304 | * instead of the device memory.
305 | * @property {boolean} [grantPermissions=false] - Set to true in order to grant all the
306 | * permissions requested in the application's manifest
307 | * automatically after the installation is completed
308 | * under Android 6+.
309 | * @property {boolean} [replace=true] - Set it to false if you don't want
310 | * the application to be upgraded/reinstalled
311 | * if it is already present on the device.
312 | * @property {boolean} [partialInstall=false] - Install apks partially. It is used for 'install-multiple'.
313 | * https://android.stackexchange.com/questions/111064/what-is-a-partial-application-install-via-adb
314 | */
315 |
316 |
--------------------------------------------------------------------------------
/lib/logcat.js:
--------------------------------------------------------------------------------
1 | import { logger, util } from '@appium/support';
2 | import B from 'bluebird';
3 | import _ from 'lodash';
4 | import { EventEmitter } from 'node:events';
5 | import { SubProcess, exec } from 'teen_process';
6 | import { LRUCache } from 'lru-cache';
7 |
8 | const log = logger.getLogger('Logcat');
9 | const MAX_BUFFER_SIZE = 10000;
10 | const LOGCAT_PROC_STARTUP_TIMEOUT = 10000;
11 | const SUPPORTED_FORMATS = ['brief', 'process', 'tag', 'thread', 'raw', 'time', 'threadtime', 'long'];
12 | const SUPPORTED_PRIORITIES = ['v', 'd', 'i', 'w', 'e', 'f', 's'];
13 | const DEFAULT_PRIORITY = 'v';
14 | const DEFAULT_TAG = '*';
15 | const DEFAULT_FORMAT = 'threadtime';
16 | const TRACE_PATTERN = /W\/Trace/;
17 | const EXECVP_ERR_PATTERN = /execvp\(\)/;
18 |
19 | function requireFormat (format) {
20 | if (!SUPPORTED_FORMATS.includes(format)) {
21 | log.info(`The format value '${format}' is unknown. Supported values are: ${SUPPORTED_FORMATS}`);
22 | log.info(`Defaulting to '${DEFAULT_FORMAT}'`);
23 | return DEFAULT_FORMAT;
24 | }
25 | return format;
26 | }
27 |
28 | /**
29 | *
30 | * @param {string} message
31 | * @param {number} timestamp
32 | * @returns {import('./tools/types').LogEntry}
33 | */
34 | function toLogEntry(message, timestamp) {
35 | return {
36 | timestamp,
37 | level: 'ALL',
38 | message,
39 | };
40 | }
41 |
42 | /**
43 | *
44 | * @param {string} spec
45 | * @returns {string}
46 | */
47 | function requireSpec (spec) {
48 | const [tag, priority] = spec.split(':');
49 | let resultTag = tag;
50 | if (!resultTag) {
51 | log.info(`The tag value in spec '${spec}' cannot be empty`);
52 | log.info(`Defaulting to '${DEFAULT_TAG}'`);
53 | resultTag = DEFAULT_TAG;
54 | }
55 | if (!priority) {
56 | log.info(`The priority value in spec '${spec}' is empty. Defaulting to Verbose (${DEFAULT_PRIORITY})`);
57 | return `${resultTag}:${DEFAULT_PRIORITY}`;
58 | }
59 | if (!SUPPORTED_PRIORITIES.some((p) => _.toLower(priority) === _.toLower(p))) {
60 | log.info(`The priority value in spec '${spec}' is unknown. Supported values are: ${SUPPORTED_PRIORITIES}`);
61 | log.info(`Defaulting to Verbose (${DEFAULT_PRIORITY})`);
62 | return `${resultTag}:${DEFAULT_PRIORITY}`;
63 | }
64 | return spec;
65 | }
66 |
67 | /**
68 | *
69 | * @param {string|string[]} filterSpecs
70 | * @returns {string[]}
71 | */
72 | function formatFilterSpecs (filterSpecs) {
73 | if (!_.isArray(filterSpecs)) {
74 | filterSpecs = [filterSpecs];
75 | }
76 | return filterSpecs
77 | .filter((spec) => spec && _.isString(spec) && !spec.startsWith('-'))
78 | .map((spec) => spec.includes(':') ? requireSpec(spec) : spec);
79 | }
80 |
81 |
82 | export class Logcat extends EventEmitter {
83 | constructor (opts = {}) {
84 | super();
85 | this.adb = opts.adb;
86 | this.clearLogs = opts.clearDeviceLogsOnStart || false;
87 | this.debug = opts.debug;
88 | this.debugTrace = opts.debugTrace;
89 | this.maxBufferSize = opts.maxBufferSize || MAX_BUFFER_SIZE;
90 | /** @type {LRUCache} */
91 | this.logs = new LRUCache({
92 | max: this.maxBufferSize,
93 | });
94 | /** @type {number?} */
95 | this.logIndexSinceLastRequest = null;
96 | }
97 |
98 | async startCapture (opts = {}) {
99 | let started = false;
100 | return await new B(async (_resolve, _reject) => {
101 | const resolve = function (...args) {
102 | started = true;
103 | _resolve(...args);
104 | };
105 | const reject = function (...args) {
106 | started = true;
107 | _reject(...args);
108 | };
109 |
110 | if (this.clearLogs) {
111 | await this.clear();
112 | }
113 |
114 | const {
115 | format = DEFAULT_FORMAT,
116 | filterSpecs = [],
117 | } = opts;
118 | const cmd = [
119 | ...this.adb.defaultArgs,
120 | 'logcat',
121 | '-v', requireFormat(format),
122 | ...formatFilterSpecs(filterSpecs),
123 | ];
124 | log.debug(`Starting logs capture with command: ${util.quote([this.adb.path, ...cmd])}`);
125 | this.proc = new SubProcess(this.adb.path, cmd);
126 | this.proc.on('exit', (code, signal) => {
127 | log.error(`Logcat terminated with code ${code}, signal ${signal}`);
128 | this.proc = null;
129 | if (!started) {
130 | log.warn('Logcat not started. Continuing');
131 | resolve();
132 | }
133 | });
134 | this.proc.on('line-stderr', (line) => {
135 | if (!started && EXECVP_ERR_PATTERN.test(line)) {
136 | log.error('Logcat process failed to start');
137 | return reject(new Error(`Logcat process failed to start. stderr: ${line}`));
138 | }
139 | this.outputHandler(line, 'STDERR: ');
140 | resolve();
141 | });
142 | this.proc.on('line-stdout', (line) => {
143 | this.outputHandler(line);
144 | resolve();
145 | });
146 | await this.proc.start(0);
147 | // resolve after a timeout, even if no output was recorded
148 | setTimeout(resolve, LOGCAT_PROC_STARTUP_TIMEOUT);
149 | });
150 | }
151 |
152 | /**
153 | *
154 | * @param {string} logLine
155 | * @param {string} [prefix='']
156 | * @returns {void}
157 | */
158 | outputHandler (logLine, prefix = '') {
159 | const timestamp = Date.now();
160 | let recentIndex = -1;
161 | for (const key of this.logs.keys()) {
162 | recentIndex = key;
163 | break;
164 | }
165 | this.logs.set(++recentIndex, [logLine, timestamp]);
166 | if (this.listenerCount('output')) {
167 | this.emit('output', toLogEntry(logLine, timestamp));
168 | }
169 | if (this.debug && (this.debugTrace || !TRACE_PATTERN.test(logLine))) {
170 | log.debug(prefix + logLine);
171 | }
172 | }
173 |
174 | /**
175 | *
176 | * @returns {Promise}
177 | */
178 | async stopCapture () {
179 | log.debug('Stopping logcat capture');
180 | if (!this.proc?.isRunning) {
181 | log.debug('Logcat already stopped');
182 | this.proc = null;
183 | return;
184 | }
185 | this.proc.removeAllListeners('exit');
186 | await this.proc.stop();
187 | this.proc = null;
188 | }
189 |
190 | /**
191 | * @returns {import('./tools/types').LogEntry[]}
192 | */
193 | getLogs () {
194 | /** @type {import('./tools/types').LogEntry[]} */
195 | const result = [];
196 | /** @type {number?} */
197 | let recentLogIndex = null;
198 | for (const [index, [message, timestamp]] of /** @type {Generator} */ (this.logs.rentries())) {
199 | if (this.logIndexSinceLastRequest && index > this.logIndexSinceLastRequest
200 | || !this.logIndexSinceLastRequest) {
201 | recentLogIndex = index;
202 | result.push(toLogEntry(message, timestamp));
203 | }
204 | }
205 | if (_.isInteger(recentLogIndex)) {
206 | this.logIndexSinceLastRequest = recentLogIndex;
207 | }
208 | return result;
209 | }
210 |
211 | /**
212 | * @returns {import('./tools/types').LogEntry[]}
213 | */
214 | getAllLogs () {
215 | /** @type {import('./tools/types').LogEntry[]} */
216 | const result = [];
217 | for (const [message, timestamp] of /** @type {Generator} */ (this.logs.rvalues())) {
218 | result.push(toLogEntry(message, timestamp));
219 | }
220 | return result;
221 | }
222 |
223 | /**
224 | * @returns {Promise}
225 | */
226 | async clear () {
227 | log.debug('Clearing logcat logs from device');
228 | try {
229 | const args = [...this.adb.defaultArgs, 'logcat', '-c'];
230 | await exec(this.adb.path, args);
231 | } catch (err) {
232 | log.warn(`Failed to clear logcat logs: ${err.stderr || err.message}`);
233 | }
234 | }
235 | }
236 |
237 | export default Logcat;
238 |
--------------------------------------------------------------------------------
/lib/logger.js:
--------------------------------------------------------------------------------
1 | import { logger } from '@appium/support';
2 |
3 | export const log = logger.getLogger('ADB');
4 |
--------------------------------------------------------------------------------
/lib/options.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/lib/options.ts
--------------------------------------------------------------------------------
/lib/tools/aab-utils.js:
--------------------------------------------------------------------------------
1 | import { log } from '../logger.js';
2 | import path from 'path';
3 | import { fs, tempDir, util } from '@appium/support';
4 | import { LRUCache } from 'lru-cache';
5 | import { unzipFile } from '../helpers.js';
6 | import AsyncLock from 'async-lock';
7 | import B from 'bluebird';
8 | import crypto from 'crypto';
9 |
10 | /** @type {LRUCache} */
11 | const AAB_CACHE = new LRUCache({
12 | max: 10,
13 | dispose: (extractedFilesRoot) => fs.rimraf(/** @type {string} */ (extractedFilesRoot)),
14 | });
15 | const AAB_CACHE_GUARD = new AsyncLock();
16 | const UNIVERSAL_APK = 'universal.apk';
17 |
18 | process.on('exit', () => {
19 | if (!AAB_CACHE.size) {
20 | return;
21 | }
22 |
23 | const paths = /** @type {string[]} */ ([...AAB_CACHE.values()]);
24 | log.debug(`Performing cleanup of ${paths.length} cached .aab ` +
25 | util.pluralize('package', paths.length));
26 | for (const appPath of paths) {
27 | try {
28 | // Asynchronous calls are not supported in onExit handler
29 | fs.rimrafSync(appPath);
30 | } catch (e) {
31 | log.warn((/** @type {Error} */ (e)).message);
32 | }
33 | }
34 | });
35 |
36 |
37 | /**
38 | * Builds a universal .apk from the given .aab package. See
39 | * https://developer.android.com/studio/command-line/bundletool#generate_apks
40 | * for more details.
41 | *
42 | * @this {import('../adb.js').ADB}
43 | * @param {string} aabPath Full path to the source .aab package
44 | * @param {import('./types').ApkCreationOptions} [opts={}]
45 | * @returns The path to the resulting universal .apk. The .apk is stored in the internal cache
46 | * by default.
47 | * @throws {Error} If there was an error while creating the universal .apk
48 | */
49 | export async function extractUniversalApk (aabPath, opts = {}) {
50 | if (!await fs.exists(aabPath)) {
51 | throw new Error(`The file at '${aabPath}' either does not exist or is not accessible`);
52 | }
53 |
54 | const aabName = path.basename(aabPath);
55 | const apkName = aabName.substring(0, aabName.length - path.extname(aabName).length) + '.apk';
56 | const tmpRoot = await tempDir.openDir();
57 | const tmpApksPath = path.join(tmpRoot, `${aabName}.apks`);
58 | try {
59 | return await AAB_CACHE_GUARD.acquire(aabPath, async () => {
60 | const aabHash = await fs.hash(aabPath);
61 | const {
62 | keystore,
63 | keystorePassword,
64 | keyAlias,
65 | keyPassword,
66 | } = opts;
67 | let cacheHash = aabHash;
68 | if (keystore) {
69 | if (!await fs.exists(keystore)) {
70 | throw new Error(`The keystore file at '${keystore}' either does not exist ` +
71 | `or is not accessible`);
72 | }
73 | if (!keystorePassword || !keyAlias || !keyPassword) {
74 | throw new Error('It is mandatory to also provide keystore password, key alias, ' +
75 | 'and key password if the keystore path is set');
76 | }
77 | const keystoreHash = await fs.hash(keystore);
78 | const keyAliasHash = crypto.createHash('sha1');
79 | keyAliasHash.update(keyAlias);
80 | cacheHash = [cacheHash, keystoreHash, keyAliasHash.digest('hex')].join(':');
81 | }
82 | log.debug(`Calculated the cache key for '${aabPath}': ${cacheHash}`);
83 | if (AAB_CACHE.has(cacheHash)) {
84 | const resultPath = path.resolve(/** @type {string} */ (AAB_CACHE.get(cacheHash)), apkName);
85 | if (await fs.exists(resultPath)) {
86 | return resultPath;
87 | }
88 | AAB_CACHE.delete(cacheHash);
89 | }
90 |
91 | await this.initAapt2();
92 | const args = [
93 | 'build-apks',
94 | '--aapt2', (/** @type {import('./types').StringRecord} */ (this.binaries)).aapt2,
95 | '--bundle', aabPath,
96 | '--output', tmpApksPath,
97 | ...(keystore ? [
98 | '--ks', keystore,
99 | '--ks-pass', `pass:${keystorePassword}`,
100 | '--ks-key-alias', keyAlias,
101 | '--key-pass', `pass:${keyPassword}`,
102 | ] : []),
103 | '--mode=universal'
104 | ];
105 | log.debug(`Preparing universal .apks bundle from '${aabPath}'`);
106 | await this.execBundletool(args, `Cannot build a universal .apks bundle from '${aabPath}'`);
107 |
108 | log.debug(`Unpacking universal application bundle at '${tmpApksPath}' to '${tmpRoot}'`);
109 | await unzipFile(tmpApksPath, tmpRoot);
110 | let universalApkPath;
111 | const fileDeletionPromises = [];
112 | const allFileNames = await fs.readdir(tmpRoot);
113 | for (const fileName of allFileNames) {
114 | const fullPath = path.join(tmpRoot, fileName);
115 | if (fileName === UNIVERSAL_APK) {
116 | universalApkPath = fullPath;
117 | } else {
118 | fileDeletionPromises.push(fs.rimraf(fullPath));
119 | }
120 | }
121 | try {
122 | await B.all(fileDeletionPromises);
123 | } catch {}
124 | if (!universalApkPath) {
125 | log.debug(`The following items were extracted from the .aab bundle: ${allFileNames}`);
126 | throw new Error(`${UNIVERSAL_APK} cannot be found in '${aabPath}' bundle. ` +
127 | `Does the archive contain a valid application bundle?`);
128 | }
129 | const resultPath = path.join(tmpRoot, apkName);
130 | log.debug(`Found ${UNIVERSAL_APK} at '${universalApkPath}'. Caching it to '${resultPath}'`);
131 | await fs.mv(universalApkPath, resultPath);
132 | AAB_CACHE.set(cacheHash, tmpRoot);
133 | return resultPath;
134 | });
135 | } catch (e) {
136 | await fs.rimraf(tmpRoot);
137 | throw e;
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/lib/tools/android-manifest.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { exec } from 'teen_process';
3 | import { log } from '../logger.js';
4 | import {
5 | unzipFile,
6 | APKS_EXTENSION,
7 | readPackageManifest,
8 | } from '../helpers.js';
9 | import { fs, zip, tempDir, util } from '@appium/support';
10 | import path from 'path';
11 |
12 | /**
13 | * Extract package and main activity name from application manifest.
14 | *
15 | * @this {import('../adb.js').ADB}
16 | * @param {string} appPath - The full path to application .apk(s) package
17 | * @return {Promise} The parsed application info.
18 | * @throws {error} If there was an error while getting the data from the given
19 | * application package.
20 | */
21 | export async function packageAndLaunchActivityFromManifest (appPath) {
22 | if (appPath.endsWith(APKS_EXTENSION)) {
23 | appPath = await this.extractBaseApk(appPath);
24 | }
25 |
26 | const {
27 | name: apkPackage,
28 | launchableActivity: {
29 | name: apkActivity,
30 | },
31 | } = await readPackageManifest.bind(this)(appPath);
32 | log.info(`Package name: '${apkPackage}'`);
33 | log.info(`Main activity name: '${apkActivity}'`);
34 | return {apkPackage, apkActivity};
35 | }
36 |
37 | /**
38 | * Extract target SDK version from application manifest.
39 | *
40 | * @this {import('../adb.js').ADB}
41 | * @param {string} appPath - The full path to .apk(s) package.
42 | * @return {Promise} The version of the target SDK.
43 | * @throws {error} If there was an error while getting the data from the given
44 | * application package.
45 | */
46 | export async function targetSdkVersionFromManifest (appPath) {
47 | log.debug(`Extracting target SDK version of '${appPath}'`);
48 | const originalAppPath = appPath;
49 | if (appPath.endsWith(APKS_EXTENSION)) {
50 | appPath = await this.extractBaseApk(appPath);
51 | }
52 |
53 | const {targetSdkVersion} = await readPackageManifest.bind(this)(appPath);
54 | if (!targetSdkVersion) {
55 | throw new Error(
56 | `Cannot extract targetSdkVersion of '${originalAppPath}'. Does ` +
57 | `the package manifest define it?`
58 | );
59 | }
60 | return targetSdkVersion;
61 | }
62 |
63 | /**
64 | * Extract target SDK version from package information.
65 | *
66 | * @this {import('../adb.js').ADB}
67 | * @param {string} pkg - The class name of the package installed on the device under test.
68 | * @param {string?} [cmdOutput=null] - Optional parameter containing the output of
69 | * _dumpsys package_ command. It may speed up the method execution.
70 | * @return {Promise} The version of the target SDK.
71 | */
72 | export async function targetSdkVersionUsingPKG (pkg, cmdOutput = null) {
73 | const stdout = cmdOutput || await this.shell(['dumpsys', 'package', pkg]);
74 | const targetSdkVersionMatch = new RegExp(/targetSdk=([^\s\s]+)/g).exec(stdout);
75 | return targetSdkVersionMatch && targetSdkVersionMatch.length >= 2
76 | ? parseInt(targetSdkVersionMatch[1], 10)
77 | : 0;
78 | }
79 |
80 | /**
81 | * Create binary representation of package manifest (usually AndroidManifest.xml).
82 | * `${manifest}.apk` file will be created as the result of this method
83 | * containing the compiled manifest.
84 | *
85 | * @this {import('../adb.js').ADB}
86 | * @param {string} manifest - Full path to the initial manifest template
87 | * @param {string} manifestPackage - The name of the manifest package
88 | * @param {string} targetPackage - The name of the destination package
89 | */
90 | export async function compileManifest (manifest, manifestPackage, targetPackage) {
91 | const {platform, platformPath} = await getAndroidPlatformAndPath(/** @type {string} */ (this.sdkRoot));
92 | if (!platform || !platformPath) {
93 | throw new Error('Cannot compile the manifest. The required platform does not exist (API level >= 17)');
94 | }
95 | const resultPath = `${manifest}.apk`;
96 | const androidJarPath = path.resolve(platformPath, 'android.jar');
97 | if (await fs.exists(resultPath)) {
98 | await fs.rimraf(resultPath);
99 | }
100 | try {
101 | await this.initAapt2();
102 | // https://developer.android.com/studio/command-line/aapt2
103 | const args = [
104 | 'link',
105 | '-o', resultPath,
106 | '--manifest', manifest,
107 | '--rename-manifest-package', manifestPackage,
108 | '--rename-instrumentation-target-package', targetPackage,
109 | '-I', androidJarPath,
110 | '-v',
111 | ];
112 | log.debug(`Compiling the manifest using '${util.quote([
113 | (/** @type {import('./types').StringRecord} */ (this.binaries)).aapt2,
114 | ...args
115 | ])}'`);
116 | await exec((/** @type {import('./types').StringRecord} */ (this.binaries)).aapt2, args);
117 | } catch (e) {
118 | log.debug('Cannot compile the manifest using aapt2. Defaulting to aapt. ' +
119 | `Original error: ${e.stderr || e.message}`);
120 | await this.initAapt();
121 | const args = [
122 | 'package',
123 | '-M', manifest,
124 | '--rename-manifest-package', manifestPackage,
125 | '--rename-instrumentation-target-package', targetPackage,
126 | '-I', androidJarPath,
127 | '-F', resultPath,
128 | '-f',
129 | ];
130 | log.debug(`Compiling the manifest using '${util.quote([
131 | (/** @type {import('./types').StringRecord} */ (this.binaries)).aapt,
132 | ...args
133 | ])}'`);
134 | try {
135 | await exec((/** @type {import('./types').StringRecord} */ (this.binaries)).aapt, args);
136 | } catch (e1) {
137 | throw new Error(`Cannot compile the manifest. Original error: ${e1.stderr || e1.message}`);
138 | }
139 | }
140 | log.debug(`Compiled the manifest at '${resultPath}'`);
141 | }
142 |
143 | /**
144 | * Replace/insert the specially precompiled manifest file into the
145 | * particular package.
146 | *
147 | * @this {import('../adb.js').ADB}
148 | * @param {string} manifest - Full path to the precompiled manifest
149 | * created by `compileManifest` method call
150 | * without .apk extension
151 | * @param {string} srcApk - Full path to the existing valid application package, where
152 | * this manifest has to be insetred to. This package
153 | * will NOT be modified.
154 | * @param {string} dstApk - Full path to the resulting package.
155 | * The file will be overridden if it already exists.
156 | */
157 | export async function insertManifest (manifest, srcApk, dstApk) {
158 | log.debug(`Inserting manifest '${manifest}', src: '${srcApk}', dst: '${dstApk}'`);
159 | await zip.assertValidZip(srcApk);
160 | await unzipFile(`${manifest}.apk`);
161 | const manifestName = path.basename(manifest);
162 | try {
163 | await this.initAapt();
164 | await fs.copyFile(srcApk, dstApk);
165 | log.debug('Moving manifest');
166 | try {
167 | await exec((/** @type {import('./types').StringRecord} */ (this.binaries)).aapt, [
168 | 'remove', dstApk, manifestName
169 | ]);
170 | } catch {}
171 | await exec((/** @type {import('./types').StringRecord} */ (this.binaries)).aapt, [
172 | 'add', dstApk, manifestName
173 | ], {cwd: path.dirname(manifest)});
174 | } catch (e) {
175 | log.debug('Cannot insert manifest using aapt. Defaulting to zip. ' +
176 | `Original error: ${e.stderr || e.message}`);
177 | const tmpRoot = await tempDir.openDir();
178 | try {
179 | // Unfortunately NodeJS does not provide any reliable methods
180 | // to replace files inside zip archives without loading the
181 | // whole archive content into RAM
182 | log.debug(`Extracting the source apk at '${srcApk}'`);
183 | await zip.extractAllTo(srcApk, tmpRoot);
184 | log.debug('Moving manifest');
185 | await fs.mv(manifest, path.resolve(tmpRoot, manifestName));
186 | log.debug(`Collecting the destination apk at '${dstApk}'`);
187 | await zip.toArchive(dstApk, {
188 | cwd: tmpRoot,
189 | });
190 | } finally {
191 | await fs.rimraf(tmpRoot);
192 | }
193 | }
194 | log.debug(`Manifest insertion into '${dstApk}' is completed`);
195 | }
196 |
197 | /**
198 | * Check whether package manifest contains Internet permissions.
199 | *
200 | * @this {import('../adb.js').ADB}
201 | * @param {string} appPath - The full path to .apk(s) package.
202 | * @return {Promise} True if the manifest requires Internet access permission.
203 | */
204 | export async function hasInternetPermissionFromManifest (appPath) {
205 | log.debug(`Checking if '${appPath}' requires internet access permission in the manifest`);
206 | if (appPath.endsWith(APKS_EXTENSION)) {
207 | appPath = await this.extractBaseApk(appPath);
208 | }
209 |
210 | const {usesPermissions} = await readPackageManifest.bind(this)(appPath);
211 | return usesPermissions.some((/** @type {string} */ name) => name === 'android.permission.INTERNET');
212 | }
213 |
214 | // #region Private functions
215 |
216 | /**
217 | * Retrieve the path to the recent installed Android platform.
218 | *
219 | * @param {string} sdkRoot
220 | * @return {Promise} The resulting path to the newest installed platform.
221 | */
222 | export async function getAndroidPlatformAndPath (sdkRoot) {
223 | const propsPaths = await fs.glob('*/build.prop', {
224 | cwd: path.resolve(sdkRoot, 'platforms'),
225 | absolute: true,
226 | });
227 | /** @type {Record} */
228 | const platformsMapping = {};
229 | for (const propsPath of propsPaths) {
230 | const propsContent = await fs.readFile(propsPath, 'utf-8');
231 | const platformPath = path.dirname(propsPath);
232 | const platform = path.basename(platformPath);
233 | const match = /ro\.build\.version\.sdk=(\d+)/.exec(propsContent);
234 | if (!match) {
235 | log.warn(`Cannot read the SDK version from '${propsPath}'. Skipping '${platform}'`);
236 | continue;
237 | }
238 | platformsMapping[parseInt(match[1], 10)] = {
239 | platform,
240 | platformPath,
241 | };
242 | }
243 | if (_.isEmpty(platformsMapping)) {
244 | log.warn(`Found zero platform folders at '${path.resolve(sdkRoot, 'platforms')}'. ` +
245 | `Do you have any Android SDKs installed?`);
246 | return {
247 | platform: null,
248 | platformPath: null,
249 | };
250 | }
251 |
252 | const recentSdkVersion = _.keys(platformsMapping).sort().reverse()[0];
253 | const result = platformsMapping[recentSdkVersion];
254 | log.debug(`Found the most recent Android platform: ${JSON.stringify(result)}`);
255 | return result;
256 | }
257 |
258 | // #endregion
259 |
--------------------------------------------------------------------------------
/lib/tools/apk-signing.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import _fs from 'fs';
3 | import { exec } from 'teen_process';
4 | import path from 'path';
5 | import { log } from '../logger.js';
6 | import { tempDir, system, mkdirp, fs, util, zip } from '@appium/support';
7 | import { LRUCache } from 'lru-cache';
8 | import {
9 | getJavaForOs,
10 | getJavaHome,
11 | APKS_EXTENSION,
12 | getResourcePath,
13 | } from '../helpers.js';
14 |
15 | const DEFAULT_PRIVATE_KEY = path.join('keys', 'testkey.pk8');
16 | const DEFAULT_CERTIFICATE = path.join('keys', 'testkey.x509.pem');
17 | const BUNDLETOOL_TUTORIAL = 'https://developer.android.com/studio/command-line/bundletool';
18 | const APKSIGNER_VERIFY_FAIL = 'DOES NOT VERIFY';
19 | const SHA1 = 'sha1';
20 | const SHA256 = 'sha256';
21 | const SHA512 = 'sha512';
22 | const MD5 = 'md5';
23 | const DEFAULT_CERT_HASH = {
24 | [SHA256]: 'a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc'
25 | };
26 | const JAVA_PROPS_INIT_ERROR = 'java.lang.Error: Properties init';
27 | /** @type {LRUCache} */
28 | const SIGNED_APPS_CACHE = new LRUCache({
29 | max: 30,
30 | });
31 |
32 | /**
33 | * Execute apksigner utility with given arguments.
34 | *
35 | * @this {import('../adb.js').ADB}
36 | * @param {string[]} args - The list of tool arguments.
37 | * @return {Promise} - Command stdout
38 | * @throws {Error} If apksigner binary is not present on the local file system
39 | * or the return code is not equal to zero.
40 | */
41 | export async function executeApksigner (args) {
42 | const apkSignerJar = await getApksignerForOs.bind(this)();
43 | const fullCmd = [
44 | await getJavaForOs(), '-Xmx1024M', '-Xss1m',
45 | '-jar', apkSignerJar,
46 | ...args
47 | ];
48 | log.debug(`Starting apksigner: ${util.quote(fullCmd)}`);
49 | // It is necessary to specify CWD explicitly; see https://github.com/appium/appium/issues/14724#issuecomment-737446715
50 | const {stdout, stderr} = await exec(fullCmd[0], fullCmd.slice(1), {
51 | cwd: path.dirname(apkSignerJar),
52 | // @ts-ignore This works
53 | windowsVerbatimArguments: system.isWindows(),
54 | });
55 | for (let [name, stream] of [['stdout', stdout], ['stderr', stderr]]) {
56 | if (!_.trim(stream)) {
57 | continue;
58 | }
59 |
60 | if (name === 'stdout') {
61 | // Make the output less talkative
62 | stream = stream.split('\n')
63 | .filter((line) => !line.includes('WARNING:'))
64 | .join('\n');
65 | }
66 | log.debug(`apksigner ${name}: ${stream}`);
67 | }
68 | return stdout;
69 | }
70 |
71 | /**
72 | * (Re)sign the given apk file on the local file system with the default certificate.
73 | *
74 | * @this {import('../adb.js').ADB}
75 | * @param {string} apk - The full path to the local apk file.
76 | * @throws {Error} If signing fails.
77 | */
78 | export async function signWithDefaultCert (apk) {
79 | log.debug(`Signing '${apk}' with default cert`);
80 | if (!(await fs.exists(apk))) {
81 | throw new Error(`${apk} file doesn't exist.`);
82 | }
83 |
84 | const args = [
85 | 'sign',
86 | '--key', await getResourcePath(DEFAULT_PRIVATE_KEY),
87 | '--cert', await getResourcePath(DEFAULT_CERTIFICATE),
88 | apk,
89 | ];
90 | try {
91 | await this.executeApksigner(args);
92 | } catch (e) {
93 | throw new Error(`Could not sign '${apk}' with the default certificate. ` +
94 | `Original error: ${e.stderr || e.stdout || e.message}`);
95 | }
96 | }
97 |
98 | /**
99 | * (Re)sign the given apk file on the local file system with a custom certificate.
100 | *
101 | * @this {import('../adb.js').ADB}
102 | * @param {string} apk - The full path to the local apk file.
103 | * @throws {Error} If signing fails.
104 | */
105 | export async function signWithCustomCert (apk) {
106 | log.debug(`Signing '${apk}' with custom cert`);
107 | if (!(await fs.exists(/** @type {string} */(this.keystorePath)))) {
108 | throw new Error(`Keystore: ${this.keystorePath} doesn't exist.`);
109 | }
110 | if (!(await fs.exists(apk))) {
111 | throw new Error(`'${apk}' doesn't exist.`);
112 | }
113 |
114 | try {
115 | await this.executeApksigner(['sign',
116 | '--ks', /** @type {string} */(this.keystorePath),
117 | '--ks-key-alias', /** @type {string} */(this.keyAlias),
118 | '--ks-pass', `pass:${this.keystorePassword}`,
119 | '--key-pass', `pass:${this.keyPassword}`,
120 | apk]);
121 | } catch (err) {
122 | log.warn(`Cannot use apksigner tool for signing. Defaulting to jarsigner. ` +
123 | `Original error: ${err.stderr || err.stdout || err.message}`);
124 | try {
125 | if (await unsignApk(apk)) {
126 | log.debug(`'${apk}' has been successfully unsigned`);
127 | } else {
128 | log.debug(`'${apk}' does not need to be unsigned`);
129 | }
130 | const jarsigner = path.resolve(await getJavaHome(), 'bin',
131 | `jarsigner${system.isWindows() ? '.exe' : ''}`);
132 | /** @type {string[]} */
133 | const fullCmd = [jarsigner,
134 | '-sigalg', 'MD5withRSA',
135 | '-digestalg', 'SHA1',
136 | '-keystore', /** @type {string} */(this.keystorePath),
137 | '-storepass', /** @type {string} */(this.keystorePassword),
138 | '-keypass', /** @type {string} */(this.keyPassword),
139 | apk, /** @type {string} */(this.keyAlias)];
140 | log.debug(`Starting jarsigner: ${util.quote(fullCmd)}`);
141 | await exec(fullCmd[0], fullCmd.slice(1), {
142 | // @ts-ignore This works
143 | windowsVerbatimArguments: system.isWindows(),
144 | });
145 | } catch (e) {
146 | throw new Error(`Could not sign with custom certificate. ` +
147 | `Original error: ${e.stderr || e.message}`);
148 | }
149 | }
150 | }
151 |
152 | /**
153 | * (Re)sign the given apk file on the local file system with either
154 | * custom or default certificate based on _this.useKeystore_ property value
155 | * and Zip-aligns it after signing.
156 | *
157 | * @this {import('../adb.js').ADB}
158 | * @param {string} appPath - The full path to the local .apk(s) file.
159 | * @throws {Error} If signing fails.
160 | */
161 | export async function sign (appPath) {
162 | if (appPath.endsWith(APKS_EXTENSION)) {
163 | let message = 'Signing of .apks-files is not supported. ';
164 | if (this.useKeystore) {
165 | message += 'Consider manual application bundle signing with the custom keystore ' +
166 | `like it is described at ${BUNDLETOOL_TUTORIAL}`;
167 | } else {
168 | message += `Consider manual application bundle signing with the key at '${DEFAULT_PRIVATE_KEY}' ` +
169 | `and the certificate at '${DEFAULT_CERTIFICATE}'. Read ${BUNDLETOOL_TUTORIAL} for more details.`;
170 | }
171 | log.warn(message);
172 | return;
173 | }
174 |
175 | // it is necessary to apply zipalign only before signing
176 | // if apksigner is used
177 | await this.zipAlignApk(appPath);
178 |
179 | if (this.useKeystore) {
180 | await this.signWithCustomCert(appPath);
181 | } else {
182 | await this.signWithDefaultCert(appPath);
183 | }
184 | }
185 |
186 | /**
187 | * Perform zip-aligning to the given local apk file.
188 | *
189 | * @this {import('../adb.js').ADB}
190 | * @param {string} apk - The full path to the local apk file.
191 | * @returns {Promise} True if the apk has been successfully aligned
192 | * or false if the apk has been already aligned.
193 | * @throws {Error} If zip-align fails.
194 | */
195 | export async function zipAlignApk (apk) {
196 | await this.initZipAlign();
197 | try {
198 | await exec((/** @type {import('./types').StringRecord} */ (this.binaries)).zipalign, ['-c', '4', apk]);
199 | log.debug(`${apk}' is already zip-aligned. Doing nothing`);
200 | return false;
201 | } catch {
202 | log.debug(`'${apk}' is not zip-aligned. Aligning`);
203 | }
204 | try {
205 | await fs.access(apk, _fs.constants.W_OK);
206 | } catch {
207 | throw new Error(`The file at '${apk}' is not writeable. ` +
208 | `Please grant write permissions to this file or to its parent folder '${path.dirname(apk)}' ` +
209 | `for the Appium process, so it can zip-align the file`);
210 | }
211 | const alignedApk = await tempDir.path({prefix: 'appium', suffix: '.tmp'});
212 | await mkdirp(path.dirname(alignedApk));
213 | try {
214 | await exec(
215 | (/** @type {import('./types').StringRecord} */ (this.binaries)).zipalign,
216 | ['-f', '4', apk, alignedApk]
217 | );
218 | await fs.mv(alignedApk, apk, { mkdirp: true });
219 | return true;
220 | } catch (e) {
221 | if (await fs.exists(alignedApk)) {
222 | await fs.unlink(alignedApk);
223 | }
224 | throw new Error(`zipAlignApk failed. Original error: ${e.stderr || e.message}`);
225 | }
226 | }
227 |
228 | /**
229 | * Check if the app is already signed with the default Appium certificate.
230 | *
231 | * @this {import('../adb.js').ADB}
232 | * @param {string} appPath - The full path to the local .apk(s) file.
233 | * @param {string} pkg - The name of application package.
234 | * @param {import('./types').CertCheckOptions} [opts={}] - Certificate checking options
235 | * @return {Promise} True if given application is already signed.
236 | */
237 | export async function checkApkCert (appPath, pkg, opts = {}) {
238 | log.debug(`Checking app cert for ${appPath}`);
239 | if (!await fs.exists(appPath)) {
240 | log.debug(`'${appPath}' does not exist`);
241 | return false;
242 | }
243 |
244 | if (path.extname(appPath) === APKS_EXTENSION) {
245 | appPath = await this.extractBaseApk(appPath);
246 | }
247 |
248 | const hashMatches = (apksignerOutput, expectedHashes) => {
249 | for (const [name, value] of _.toPairs(expectedHashes)) {
250 | if (new RegExp(`digest:\\s+${value}\\b`, 'i').test(apksignerOutput)) {
251 | log.debug(`${name} hash did match for '${path.basename(appPath)}'`);
252 | return true;
253 | }
254 | }
255 | return false;
256 | };
257 |
258 | const {
259 | requireDefaultCert = true,
260 | } = opts;
261 |
262 | const appHash = await fs.hash(appPath);
263 | if (SIGNED_APPS_CACHE.has(appHash)) {
264 | log.debug(`Using the previously cached signature entry for '${path.basename(appPath)}'`);
265 | const {keystorePath, output, expected} = /** @type {import('./types').SignedAppCacheValue} */ (
266 | SIGNED_APPS_CACHE.get(appHash)
267 | );
268 | if (this.useKeystore && this.keystorePath === keystorePath || !this.useKeystore) {
269 | return (!this.useKeystore && !requireDefaultCert) || hashMatches(output, expected);
270 | }
271 | }
272 |
273 | const expected = this.useKeystore ? await this.getKeystoreHash() : DEFAULT_CERT_HASH;
274 | try {
275 | await getApksignerForOs.bind(this)();
276 | const output = await this.executeApksigner(['verify', '--print-certs', appPath]);
277 | const hasMatch = hashMatches(output, expected);
278 | if (hasMatch) {
279 | log.info(`'${appPath}' is signed with the ` +
280 | `${this.useKeystore ? 'keystore' : 'default'} certificate`);
281 | } else {
282 | log.info(`'${appPath}' is signed with a ` +
283 | `non-${this.useKeystore ? 'keystore' : 'default'} certificate`);
284 | }
285 | const isSigned = (!this.useKeystore && !requireDefaultCert) || hasMatch;
286 | if (isSigned) {
287 | SIGNED_APPS_CACHE.set(appHash, {
288 | output,
289 | expected,
290 | keystorePath: /** @type {string} */ (this.keystorePath),
291 | });
292 | }
293 | return isSigned;
294 | } catch (err) {
295 | // check if there is no signature
296 | if (_.includes(err.stderr, APKSIGNER_VERIFY_FAIL)) {
297 | log.info(`'${appPath}' is not signed`);
298 | return false;
299 | }
300 | const errMsg = err.stderr || err.stdout || err.message;
301 | if (_.includes(errMsg, JAVA_PROPS_INIT_ERROR)) {
302 | // This error pops up randomly and we are not quite sure why.
303 | // My guess - a race condition in java vm initialization.
304 | // Nevertheless, lets make Appium to believe the file is already signed,
305 | // because it would be true for 99% of UIAutomator2-based
306 | // tests, where we presign server binaries while publishing their NPM module.
307 | // If these are not signed, e.g. in case of Espresso, then the next step(s)
308 | // would anyway fail.
309 | // See https://github.com/appium/appium/issues/14724 for more details.
310 | log.warn(errMsg);
311 | log.warn(`Assuming '${appPath}' is already signed and continuing anyway`);
312 | return true;
313 | }
314 | throw new Error(`Cannot verify the signature of '${appPath}'. ` +
315 | `Original error: ${errMsg}`);
316 | }
317 | }
318 |
319 | /**
320 | * Retrieve the the hash of the given keystore.
321 | *
322 | * @this {import('../adb.js').ADB}
323 | * @return {Promise}
324 | * @throws {Error} If getting keystore hash fails.
325 | */
326 | export async function getKeystoreHash () {
327 | log.debug(`Getting hash of the '${this.keystorePath}' keystore`);
328 | const keytool = path.resolve(await getJavaHome(), 'bin',
329 | `keytool${system.isWindows() ? '.exe' : ''}`);
330 | if (!await fs.exists(keytool)) {
331 | throw new Error(`The keytool utility cannot be found at '${keytool}'`);
332 | }
333 | /** @type {string[]} */
334 | const args = [
335 | '-v', '-list',
336 | '-alias', /** @type {string} */ (this.keyAlias),
337 | '-keystore', /** @type {string} */ (this.keystorePath),
338 | '-storepass', /** @type {string} */ (this.keystorePassword),
339 | ];
340 | log.info(`Running '${keytool}' with arguments: ${util.quote(args)}`);
341 | try {
342 | const {stdout} = await exec(keytool, args, {
343 | // @ts-ignore This property is ok
344 | windowsVerbatimArguments: system.isWindows(),
345 | });
346 | const result = {};
347 | for (const hashName of [SHA512, SHA256, SHA1, MD5]) {
348 | const hashRe = new RegExp(`^\\s*${hashName}:\\s*([a-f0-9:]+)`, 'mi');
349 | const match = hashRe.exec(stdout);
350 | if (!match) {
351 | continue;
352 | }
353 | result[hashName] = match[1].replace(/:/g, '').toLowerCase();
354 | }
355 | if (_.isEmpty(result)) {
356 | log.debug(stdout);
357 | throw new Error('Cannot parse the hash value from the keytool output');
358 | }
359 | log.debug(`Keystore hash: ${JSON.stringify(result)}`);
360 | return result;
361 | } catch (e) {
362 | throw new Error(`Cannot get the hash of '${this.keystorePath}' keystore. ` +
363 | `Original error: ${e.stderr || e.message}`);
364 | }
365 | }
366 |
367 | // #region Private functions
368 |
369 | /**
370 | * Get the absolute path to apksigner tool
371 | *
372 | * @this {import('../adb').ADB}
373 | * @returns {Promise} An absolute path to apksigner tool.
374 | * @throws {Error} If the tool is not present on the local file system.
375 | */
376 | export async function getApksignerForOs () {
377 | return await this.getBinaryFromSdkRoot('apksigner.jar');
378 | }
379 |
380 | /**
381 | * Unsigns the given apk by removing the
382 | * META-INF folder recursively from the archive.
383 | * !!! The function overwrites the given apk after successful unsigning !!!
384 | *
385 | * @param {string} apkPath The path to the apk
386 | * @returns {Promise} `true` if the apk has been successfully
387 | * unsigned and overwritten
388 | * @throws {Error} if there was an error during the unsign operation
389 | */
390 | export async function unsignApk (apkPath) {
391 | const tmpRoot = await tempDir.openDir();
392 | const metaInfFolderName = 'META-INF';
393 | try {
394 | let hasMetaInf = false;
395 | await zip.readEntries(apkPath, ({entry}) => {
396 | hasMetaInf = entry.fileName.startsWith(`${metaInfFolderName}/`);
397 | // entries iteration stops after `false` is returned
398 | return !hasMetaInf;
399 | });
400 | if (!hasMetaInf) {
401 | return false;
402 | }
403 | const tmpZipRoot = path.resolve(tmpRoot, 'apk');
404 | await zip.extractAllTo(apkPath, tmpZipRoot);
405 | await fs.rimraf(path.resolve(tmpZipRoot, metaInfFolderName));
406 | const tmpResultPath = path.resolve(tmpRoot, path.basename(apkPath));
407 | await zip.toArchive(tmpResultPath, {
408 | cwd: tmpZipRoot,
409 | });
410 | await fs.unlink(apkPath);
411 | await fs.mv(tmpResultPath, apkPath);
412 | return true;
413 | } finally {
414 | await fs.rimraf(tmpRoot);
415 | }
416 | }
417 |
418 | // #endregion
419 |
--------------------------------------------------------------------------------
/lib/tools/apks-utils.js:
--------------------------------------------------------------------------------
1 | import { exec } from 'teen_process';
2 | import { log } from '../logger.js';
3 | import path from 'path';
4 | import _ from 'lodash';
5 | import { fs, tempDir, util } from '@appium/support';
6 | import { LRUCache } from 'lru-cache';
7 | import {
8 | getJavaForOs, unzipFile, buildInstallArgs, APK_INSTALL_TIMEOUT
9 | } from '../helpers.js';
10 | import AsyncLock from 'async-lock';
11 | import B from 'bluebird';
12 |
13 | const BASE_APK = 'base-master.apk';
14 | const LANGUAGE_APK = (lang) => `base-${lang}.apk`;
15 | /** @type {LRUCache} */
16 | const APKS_CACHE = new LRUCache({
17 | max: 10,
18 | dispose: (extractedFilesRoot) => fs.rimraf(/** @type {string} */(extractedFilesRoot)),
19 | });
20 | const APKS_CACHE_GUARD = new AsyncLock();
21 | const BUNDLETOOL_TIMEOUT_MS = 4 * 60 * 1000;
22 | const APKS_INSTALL_TIMEOUT = APK_INSTALL_TIMEOUT * 2;
23 |
24 | process.on('exit', () => {
25 | if (!APKS_CACHE.size) {
26 | return;
27 | }
28 |
29 | const paths = /** @type {string[]} */ ([...APKS_CACHE.values()]);
30 | log.debug(`Performing cleanup of ${paths.length} cached .apks ` +
31 | util.pluralize('package', paths.length));
32 | for (const appPath of paths) {
33 | try {
34 | // Asynchronous calls are not supported in onExit handler
35 | fs.rimrafSync(appPath);
36 | } catch (e) {
37 | log.warn(e.message);
38 | }
39 | }
40 | });
41 |
42 | /**
43 | * Extracts the particular apks package into a temporary folder,
44 | * finds and returns the full path to the file contained in this apk.
45 | * The resulting temporary path, where the .apks file has been extracted,
46 | * will be stored into the internal LRU cache for better performance.
47 | *
48 | * @param {string} apks - The full path to the .apks file
49 | * @param {string|string[]} dstPath - The relative path to the destination file,
50 | * which is going to be extracted, where each path component is an array item
51 | * @returns {Promise} Full path to the extracted file
52 | * @throws {Error} If the requested item does not exist in the extracted archive or the provides
53 | * apks file is not a valid bundle
54 | */
55 | async function extractFromApks (apks, dstPath) {
56 | if (!_.isArray(dstPath)) {
57 | dstPath = [dstPath];
58 | }
59 |
60 | return await APKS_CACHE_GUARD.acquire(apks, async () => {
61 | // It might be that the original file has been replaced,
62 | // so we need to keep the hash sums instead of the actual file paths
63 | // as caching keys
64 | const apksHash = await fs.hash(apks);
65 | log.debug(`Calculated '${apks}' hash: ${apksHash}`);
66 |
67 | if (APKS_CACHE.has(apksHash)) {
68 | const resultPath = path.resolve(/** @type {string} */(APKS_CACHE.get(apksHash)), ...dstPath);
69 | if (await fs.exists(resultPath)) {
70 | return resultPath;
71 | }
72 | APKS_CACHE.delete(apksHash);
73 | }
74 |
75 | const tmpRoot = await tempDir.openDir();
76 | log.debug(`Unpacking application bundle at '${apks}' to '${tmpRoot}'`);
77 | await unzipFile(apks, tmpRoot);
78 | const resultPath = path.resolve(tmpRoot, ...(_.isArray(dstPath) ? dstPath : [dstPath]));
79 | if (!await fs.exists(resultPath)) {
80 | throw new Error(
81 | `${_.isArray(dstPath) ? dstPath.join(path.sep) : dstPath} cannot be found in '${apks}' bundle. ` +
82 | `Does the archive contain a valid application bundle?`
83 | );
84 | }
85 | APKS_CACHE.set(apksHash, tmpRoot);
86 | return resultPath;
87 | });
88 | }
89 |
90 | /**
91 | * Executes bundletool utility with given arguments and returns the actual stdout
92 | *
93 | * @this {import('../adb.js').ADB}
94 | * @param {Array} args - the list of bundletool arguments
95 | * @param {string} errorMsg - The customized error message string
96 | * @returns {Promise} the actual command stdout
97 | * @throws {Error} If bundletool jar does not exist in PATH or there was an error while
98 | * executing it
99 | */
100 | export async function execBundletool (args, errorMsg) {
101 | await this.initBundletool();
102 | args = [
103 | '-jar', (/** @type {import('./types').StringRecord} */ (this.binaries)).bundletool,
104 | ...args
105 | ];
106 | const env = process.env;
107 | if (this.adbPort) {
108 | env.ANDROID_ADB_SERVER_PORT = `${this.adbPort}`;
109 | }
110 | if (this.adbHost) {
111 | env.ANDROID_ADB_SERVER_HOST = this.adbHost;
112 | }
113 | log.debug(`Executing bundletool with arguments: ${JSON.stringify(args)}`);
114 | let stdout;
115 | try {
116 | ({stdout} = await exec(await getJavaForOs(), args, {
117 | env,
118 | timeout: BUNDLETOOL_TIMEOUT_MS,
119 | }));
120 | log.debug(`Command stdout: ${_.truncate(stdout, {length: 300})}`);
121 | return stdout;
122 | } catch (e) {
123 | if (e.stdout) {
124 | log.debug(`Command stdout: ${e.stdout}`);
125 | }
126 | if (e.stderr) {
127 | log.debug(`Command stderr: ${e.stderr}`);
128 | }
129 | throw new Error(`${errorMsg}. Original error: ${e.message}`);
130 | }
131 | }
132 |
133 | /**
134 | *
135 | * @this {import('../adb.js').ADB}
136 | * @param {string} specLocation - The full path to the generated device spec location
137 | * @returns {Promise} The same `specLocation` value
138 | * @throws {Error} If it is not possible to retrieve the spec for the current device
139 | */
140 | export async function getDeviceSpec (specLocation) {
141 | /** @type {string[]} */
142 | const args = [
143 | 'get-device-spec',
144 | '--adb', this.executable.path,
145 | '--device-id', /** @type {string} */ (this.curDeviceId),
146 | '--output', specLocation,
147 | ];
148 | log.debug(`Getting the spec for the device '${this.curDeviceId}'`);
149 | await this.execBundletool(args, 'Cannot retrieve the device spec');
150 | return specLocation;
151 | }
152 |
153 | /**
154 | * Installs the given apks into the device under test
155 | *
156 | * @this {import('../adb.js').ADB}
157 | * @param {Array} apkPathsToInstall - The full paths to install apks
158 | * @param {import('./types').InstallMultipleApksOptions} [options={}] - Installation options
159 | */
160 | export async function installMultipleApks (apkPathsToInstall, options = {}) {
161 | const installArgs = buildInstallArgs(await this.getApiLevel(), options);
162 | return await this.adbExec(['install-multiple', ...installArgs, ...apkPathsToInstall], {
163 | // @ts-ignore This validation works
164 | timeout: isNaN(options.timeout) ? undefined : options.timeout,
165 | timeoutCapName: options.timeoutCapName,
166 | });
167 | }
168 |
169 | /**
170 | * Installs the given .apks package into the device under test
171 | *
172 | * @this {import('../adb.js').ADB}
173 | * @param {string} apks - The full path to the .apks file
174 | * @param {import('./types').InstallApksOptions} [options={}] - Installation options
175 | * @throws {Error} If the .apks bundle cannot be installed
176 | */
177 | export async function installApks (apks, options = {}) {
178 | const {
179 | grantPermissions,
180 | allowTestPackages,
181 | timeout,
182 | } = options;
183 |
184 | /** @type {string[]} */
185 | const args = [
186 | 'install-apks',
187 | '--adb', this.executable.path,
188 | '--apks', apks,
189 | '--timeout-millis', `${timeout || APKS_INSTALL_TIMEOUT}`,
190 | '--device-id', /** @type {string} */ (this.curDeviceId),
191 | ];
192 | if (allowTestPackages) {
193 | args.push('--allow-test-only');
194 | }
195 | /** @type {Promise[]} */
196 | const tasks = [
197 | this.execBundletool(args, `Cannot install '${path.basename(apks)}' to the device ${this.curDeviceId}`)
198 | ];
199 | if (grantPermissions) {
200 | tasks.push(this.getApkInfo(apks));
201 | }
202 | const [, apkInfo] = await B.all(tasks);
203 | if (grantPermissions && apkInfo) {
204 | // TODO: Simplify it after https://github.com/google/bundletool/issues/246 is implemented
205 | await this.grantAllPermissions(apkInfo.name);
206 | }
207 | }
208 |
209 | /**
210 | * Extracts and returns the full path to the master .apk file inside the bundle.
211 | *
212 | * @this {import('../adb.js').ADB}
213 | * @param {string} apks - The full path to the .apks file
214 | * @returns {Promise} The full path to the master bundle .apk
215 | * @throws {Error} If there was an error while extracting/finding the file
216 | */
217 | export async function extractBaseApk (apks) {
218 | return await extractFromApks(apks, ['splits', BASE_APK]);
219 | }
220 |
221 | /**
222 | * Extracts and returns the full path to the .apk, which contains the corresponding
223 | * resources for the given language in the .apks bundle.
224 | *
225 | * @this {import('../adb.js').ADB}
226 | * @param {string} apks - The full path to the .apks file
227 | * @param {?string} [language=null] - The language abbreviation. The default language is
228 | * going to be selected if it is not set.
229 | * @returns {Promise} The full path to the corresponding language .apk or the master .apk
230 | * if language split is not enabled for the bundle.
231 | * @throws {Error} If there was an error while extracting/finding the file
232 | */
233 | export async function extractLanguageApk (apks, language = null) {
234 | if (language) {
235 | try {
236 | return await extractFromApks(apks, ['splits', LANGUAGE_APK(language)]);
237 | } catch (e) {
238 | log.debug(e.message);
239 | log.info(`Assuming that splitting by language is not enabled for the '${apks}' bundle ` +
240 | `and returning the main apk instead`);
241 | return await this.extractBaseApk(apks);
242 | }
243 | }
244 |
245 | const defaultLanguages = ['en', 'en_us'];
246 | for (const lang of defaultLanguages) {
247 | try {
248 | return await extractFromApks(apks, ['splits', LANGUAGE_APK(lang)]);
249 | } catch {}
250 | }
251 |
252 | log.info(`Cannot find any split apk for the default languages ${JSON.stringify(defaultLanguages)}. ` +
253 | `Returning the main apk instead.`);
254 | return await this.extractBaseApk(apks);
255 | }
256 |
257 | /**
258 | *
259 | * @param {string} output
260 | * @returns {boolean}
261 | */
262 | export function isTestPackageOnlyError (output) {
263 | return /\[INSTALL_FAILED_TEST_ONLY\]/.test(output);
264 | }
265 |
--------------------------------------------------------------------------------
/lib/tools/emu-constants.ts:
--------------------------------------------------------------------------------
1 |
2 | export const POWER_AC_STATES = {
3 | POWER_AC_ON: 'on',
4 | POWER_AC_OFF: 'off'
5 | } as const;
6 | export const GSM_CALL_ACTIONS = {
7 | GSM_CALL: 'call',
8 | GSM_ACCEPT: 'accept',
9 | GSM_CANCEL: 'cancel',
10 | GSM_HOLD: 'hold'
11 | } as const;
12 | export const GSM_VOICE_STATES = {
13 | GSM_VOICE_UNREGISTERED: 'unregistered',
14 | GSM_VOICE_HOME: 'home',
15 | GSM_VOICE_ROAMING: 'roaming',
16 | GSM_VOICE_SEARCHING: 'searching',
17 | GSM_VOICE_DENIED: 'denied',
18 | GSM_VOICE_OFF: 'off',
19 | GSM_VOICE_ON: 'on'
20 | } as const;
21 | export const GSM_SIGNAL_STRENGTHS = [0, 1, 2, 3, 4] as const;
22 | export const NETWORK_SPEED = {
23 | GSM: 'gsm', // GSM/CSD (up: 14.4, down: 14.4).
24 | SCSD: 'scsd', // HSCSD (up: 14.4, down: 57.6).
25 | GPRS: 'gprs', // GPRS (up: 28.8, down: 57.6).
26 | EDGE: 'edge', // EDGE/EGPRS (up: 473.6, down: 473.6).
27 | UMTS: 'umts', // UMTS/3G (up: 384.0, down: 384.0).
28 | HSDPA: 'hsdpa', // HSDPA (up: 5760.0, down: 13,980.0).
29 | LTE: 'lte', // LTE (up: 58,000, down: 173,000).
30 | EVDO: 'evdo', // EVDO (up: 75,000, down: 280,000).
31 | FULL: 'full' // No limit, the default (up: 0.0, down: 0.0).
32 | } as const;
33 | export const SENSORS = {
34 | ACCELERATION: 'acceleration',
35 | GYROSCOPE: 'gyroscope',
36 | MAGNETIC_FIELD: 'magnetic-field',
37 | ORIENTATION: 'orientation',
38 | TEMPERATURE: 'temperature',
39 | PROXIMITY: 'proximity',
40 | LIGHT: 'light',
41 | PRESSURE: 'pressure',
42 | HUMIDITY: 'humidity',
43 | MAGNETIC_FIELD_UNCALIBRATED: 'magnetic-field-uncalibrated',
44 | GYROSCOPE_UNCALIBRATED: 'gyroscope-uncalibrated',
45 | HINGE_ANGLE0: 'hinge-angle0',
46 | HINGE_ANGLE1: 'hinge-angle1',
47 | HINGE_ANGLE2: 'hinge-angle2',
48 | HEART_RATE: 'heart-rate',
49 | RGBC_LIGHT: 'rgbc-light',
50 | } as const;
51 |
--------------------------------------------------------------------------------
/lib/tools/fs-commands.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import path from 'path';
3 |
4 | /**
5 | * Verify whether a remote path exists on the device under test.
6 | *
7 | * @this {import('../adb.js').ADB}
8 | * @param {string} remotePath - The remote path to verify.
9 | * @return {Promise} True if the given path exists on the device.
10 | */
11 | export async function fileExists (remotePath) {
12 | const passFlag = '__PASS__';
13 | const checkCmd = `[ -e '${remotePath.replace(/'/g, `\\'`)}' ] && echo ${passFlag}`;
14 | try {
15 | return _.includes(await this.shell([checkCmd]), passFlag);
16 | } catch {
17 | return false;
18 | }
19 | }
20 |
21 | /**
22 | * Get the output of _ls_ command on the device under test.
23 | *
24 | * @this {import('../adb.js').ADB}
25 | * @param {string} remotePath - The remote path (the first argument to the _ls_ command).
26 | * @param {string[]} [opts] - Additional _ls_ options.
27 | * @return {Promise} The _ls_ output as an array of split lines.
28 | * An empty array is returned of the given _remotePath_
29 | * does not exist.
30 | */
31 | export async function ls (remotePath, opts = []) {
32 | try {
33 | let args = ['ls', ...opts, remotePath];
34 | let stdout = await this.shell(args);
35 | let lines = stdout.split('\n');
36 | return lines.map((l) => l.trim())
37 | .filter(Boolean)
38 | .filter((l) => l.indexOf('No such file') === -1);
39 | } catch (err) {
40 | if (err.message.indexOf('No such file or directory') === -1) {
41 | throw err;
42 | }
43 | return [];
44 | }
45 | }
46 |
47 | /**
48 | * Get the size of the particular file located on the device under test.
49 | *
50 | * @this {import('../adb.js').ADB}
51 | * @param {string} remotePath - The remote path to the file.
52 | * @return {Promise} File size in bytes.
53 | * @throws {Error} If there was an error while getting the size of the given file.
54 | */
55 | export async function fileSize (remotePath) {
56 | try {
57 | const files = await this.ls(remotePath, ['-la']);
58 | if (files.length !== 1) {
59 | throw new Error(`Remote path is not a file`);
60 | }
61 | // https://regex101.com/r/fOs4P4/8
62 | const match = /[rwxsStT\-+]{10}[\s\d]*\s[^\s]+\s+[^\s]+\s+(\d+)/.exec(files[0]);
63 | if (!match || _.isNaN(parseInt(match[1], 10))) {
64 | throw new Error(`Unable to parse size from list output: '${files[0]}'`);
65 | }
66 | return parseInt(match[1], 10);
67 | } catch (err) {
68 | throw new Error(`Unable to get file size for '${remotePath}': ${err.message}`);
69 | }
70 | }
71 |
72 | /**
73 | * Forcefully recursively remove a path on the device under test.
74 | * Be careful while calling this method.
75 | *
76 | * @this {import('../adb.js').ADB}
77 | * @param {string} path - The path to be removed recursively.
78 | */
79 | export async function rimraf (path) {
80 | await this.shell(['rm', '-rf', path]);
81 | }
82 |
83 | /**
84 | * Send a file to the device under test.
85 | *
86 | * @this {import('../adb.js').ADB}
87 | * @param {string} localPath - The path to the file on the local file system.
88 | * @param {string} remotePath - The destination path on the remote device.
89 | * @param {object} [opts] - Additional options mapping. See
90 | * https://github.com/appium/node-teen_process,
91 | * _exec_ method options, for more information about available
92 | * options.
93 | */
94 | export async function push (localPath, remotePath, opts) {
95 | await this.mkdir(path.posix.dirname(remotePath));
96 | await this.adbExec(['push', localPath, remotePath], opts);
97 | }
98 |
99 | /**
100 | * Receive a file from the device under test.
101 | *
102 | * @this {import('../adb.js').ADB}
103 | * @param {string} remotePath - The source path on the remote device.
104 | * @param {string} localPath - The destination path to the file on the local file system.
105 | * @param {import('teen_process').TeenProcessExecOptions} [opts={}] - Additional options mapping. See
106 | * https://github.com/appium/node-teen_process,
107 | * _exec_ method options, for more information about available
108 | * options.
109 | */
110 | export async function pull (remotePath, localPath, opts = {}) {
111 | // pull folder can take more time, increasing time out to 60 secs
112 | await this.adbExec(['pull', remotePath, localPath], {...opts, timeout: opts.timeout ?? 60000});
113 | }
114 |
115 | /**
116 | * Recursively create a new folder on the device under test.
117 | *
118 | * @this {import('../adb.js').ADB}
119 | * @param {string} remotePath - The new path to be created.
120 | * @return {Promise} mkdir command output.
121 | */
122 | export async function mkdir (remotePath) {
123 | return await this.shell(['mkdir', '-p', remotePath]);
124 | }
125 |
--------------------------------------------------------------------------------
/lib/tools/general-commands.js:
--------------------------------------------------------------------------------
1 | import { log } from '../logger.js';
2 | import _ from 'lodash';
3 | import { fs, util } from '@appium/support';
4 | import { SubProcess, exec } from 'teen_process';
5 |
6 |
7 | /**
8 | * Get the path to adb executable amd assign it
9 | * to this.executable.path and this.binaries.adb properties.
10 | *
11 | * @this {import('../adb.js').ADB}
12 | * @return {Promise} ADB instance.
13 | */
14 | export async function getAdbWithCorrectAdbPath () {
15 | this.executable.path = await this.getSdkBinaryPath('adb');
16 | return this;
17 | }
18 |
19 | /**
20 | * Get the full path to aapt tool and assign it to
21 | * this.binaries.aapt property
22 | * @this {import('../adb.js').ADB}
23 | */
24 | export async function initAapt () {
25 | await this.getSdkBinaryPath('aapt');
26 | }
27 |
28 | /**
29 | * Get the full path to aapt2 tool and assign it to
30 | * this.binaries.aapt2 property
31 | * @this {import('../adb.js').ADB}
32 | */
33 | export async function initAapt2 () {
34 | await this.getSdkBinaryPath('aapt2');
35 | }
36 |
37 | /**
38 | * Get the full path to zipalign tool and assign it to
39 | * this.binaries.zipalign property
40 | * @this {import('../adb.js').ADB}
41 | */
42 | export async function initZipAlign () {
43 | await this.getSdkBinaryPath('zipalign');
44 | }
45 |
46 | /**
47 | * Get the full path to bundletool binary and assign it to
48 | * this.binaries.bundletool property
49 | * @this {import('../adb.js').ADB}
50 | */
51 | export async function initBundletool () {
52 | try {
53 | (/** @type {import('./types').StringRecord} */(this.binaries)).bundletool =
54 | await fs.which('bundletool.jar');
55 | } catch {
56 | throw new Error('bundletool.jar binary is expected to be present in PATH. ' +
57 | 'Visit https://github.com/google/bundletool for more details.');
58 | }
59 | }
60 |
61 | /**
62 | * Retrieve the API level of the device under test.
63 | *
64 | * @this {import('../adb.js').ADB}
65 | * @return {Promise} The API level as integer number, for example 21 for
66 | * Android Lollipop. The result of this method is cached, so all the further
67 | * calls return the same value as the first one.
68 | */
69 | export async function getApiLevel () {
70 | if (!_.isInteger(this._apiLevel)) {
71 | try {
72 | const strOutput = await this.getDeviceProperty('ro.build.version.sdk');
73 | let apiLevel = parseInt(strOutput.trim(), 10);
74 |
75 | // Workaround for preview/beta platform API level
76 | const charCodeQ = 'q'.charCodeAt(0);
77 | // 28 is the first API Level, where Android SDK started returning letters in response to getPlatformVersion
78 | const apiLevelDiff = apiLevel - 28;
79 | const codename = String.fromCharCode(charCodeQ + apiLevelDiff);
80 | if (apiLevelDiff >= 0 && (await this.getPlatformVersion()).toLowerCase() === codename) {
81 | log.debug(`Release version is ${codename.toUpperCase()} but found API Level ${apiLevel}. Setting API Level to ${apiLevel + 1}`);
82 | apiLevel++;
83 | }
84 |
85 | this._apiLevel = apiLevel;
86 | log.debug(`Device API level: ${this._apiLevel}`);
87 | if (isNaN(this._apiLevel)) {
88 | throw new Error(`The actual output '${strOutput}' cannot be converted to an integer`);
89 | }
90 | } catch (e) {
91 | throw new Error(
92 | `Error getting device API level. Original error: ${(/** @type {Error} */(e)).message}`
93 | );
94 | }
95 | }
96 | return /** @type {number} */(this._apiLevel);
97 | }
98 |
99 | /**
100 | * Verify whether a device is connected.
101 | *
102 | * @this {import('../adb.js').ADB}
103 | * @return {Promise} True if at least one device is visible to adb.
104 | */
105 | export async function isDeviceConnected () {
106 | let devices = await this.getConnectedDevices();
107 | return devices.length > 0;
108 | }
109 |
110 | /**
111 | * Clear the active text field on the device under test by sending
112 | * special keyevents to it.
113 | *
114 | * @this {import('../adb.js').ADB}
115 | * @param {number} [length=100] - The maximum length of the text in the field to be cleared.
116 | */
117 | export async function clearTextField (length = 100) {
118 | // assumes that the EditText field already has focus
119 | log.debug(`Clearing up to ${length} characters`);
120 | if (length === 0) {
121 | return;
122 | }
123 | let args = ['input', 'keyevent'];
124 | for (let i = 0; i < length; i++) {
125 | // we cannot know where the cursor is in the text field, so delete both before
126 | // and after so that we get rid of everything
127 | // https://developer.android.com/reference/android/view/KeyEvent.html#KEYCODE_DEL
128 | // https://developer.android.com/reference/android/view/KeyEvent.html#KEYCODE_FORWARD_DEL
129 | args.push('67', '112');
130 | }
131 | await this.shell(args);
132 | }
133 |
134 | /**
135 | * Send the special keycode to the device under test in order to emulate
136 | * Back button tap.
137 | * @this {import('../adb.js').ADB}
138 | */
139 | export async function back () {
140 | log.debug('Pressing the BACK button');
141 | await this.keyevent(4);
142 | }
143 |
144 | /**
145 | * Send the special keycode to the device under test in order to emulate
146 | * Home button tap.
147 | * @this {import('../adb.js').ADB}
148 | */
149 | export async function goToHome () {
150 | log.debug('Pressing the HOME button');
151 | await this.keyevent(3);
152 | }
153 |
154 | /**
155 | * @this {import('../adb.js').ADB}
156 | * @return {string} the actual path to adb executable.
157 | */
158 | export function getAdbPath () {
159 | return this.executable.path;
160 | }
161 |
162 | /**
163 | * Restart the device under test using adb commands.
164 | *
165 | * @this {import('../adb.js').ADB}
166 | * @throws {Error} If start fails.
167 | */
168 | export async function restart () {
169 | try {
170 | await this.stopLogcat();
171 | await this.restartAdb();
172 | await this.waitForDevice(60);
173 | await this.startLogcat(this._logcatStartupParams);
174 | } catch (e) {
175 | const err = /** @type {Error} */ (e);
176 | throw new Error(`Restart failed. Original error: ${err.message}`);
177 | }
178 | }
179 |
180 | /**
181 | * Retrieve the `adb bugreport` command output. This
182 | * operation may take up to several minutes.
183 | *
184 | * @this {import('../adb.js').ADB}
185 | * @param {number} [timeout=120000] - Command timeout in milliseconds
186 | * @returns {Promise} Command stdout
187 | */
188 | export async function bugreport (timeout = 120000) {
189 | return await this.adbExec(['bugreport'], {timeout});
190 | }
191 |
192 | /**
193 | * Initiate screenrecord utility on the device
194 | *
195 | * @this {import('../adb.js').ADB}
196 | * @param {string} destination - Full path to the writable media file destination
197 | * on the device file system.
198 | * @param {import('./types').ScreenrecordOptions} [options={}]
199 | * @returns {SubProcess} screenrecord process, which can be then controlled by the client code
200 | */
201 | export function screenrecord (destination, options = {}) {
202 | const cmd = ['screenrecord'];
203 | const {
204 | videoSize,
205 | bitRate,
206 | timeLimit,
207 | bugReport,
208 | } = options;
209 | if (util.hasValue(videoSize)) {
210 | cmd.push('--size', videoSize);
211 | }
212 | if (util.hasValue(timeLimit)) {
213 | cmd.push('--time-limit', `${timeLimit}`);
214 | }
215 | if (util.hasValue(bitRate)) {
216 | cmd.push('--bit-rate', `${bitRate}`);
217 | }
218 | if (bugReport) {
219 | cmd.push('--bugreport');
220 | }
221 | cmd.push(destination);
222 |
223 | const fullCmd = [
224 | ...this.executable.defaultArgs,
225 | 'shell',
226 | ...cmd
227 | ];
228 | log.debug(`Building screenrecord process with the command line: adb ${util.quote(fullCmd)}`);
229 | return new SubProcess(this.executable.path, fullCmd);
230 | }
231 |
232 | /**
233 | * Retrieves the list of features supported by the device under test
234 | *
235 | * @this {import('../adb.js').ADB}
236 | * @returns {Promise} the list of supported feature names or an empty list.
237 | * An example adb command output:
238 | * ```
239 | * cmd
240 | * ls_v2
241 | * fixed_push_mkdir
242 | * shell_v2
243 | * abb
244 | * stat_v2
245 | * apex
246 | * abb_exec
247 | * remount_shell
248 | * fixed_push_symlink_timestamp
249 | * ```
250 | * @throws {Error} if there was an error while retrieving the list
251 | */
252 | export async function listFeatures () {
253 | this._memoizedFeatures = this._memoizedFeatures
254 | || _.memoize(async () => await this.adbExec(['features']), () => this.curDeviceId);
255 | try {
256 | return (await this._memoizedFeatures())
257 | .split(/\s+/)
258 | .map((x) => x.trim())
259 | .filter(Boolean);
260 | } catch (e) {
261 | const err = /** @type {import('teen_process').ExecError} */ (e);
262 | if (_.includes(err.stderr, 'unknown command')) {
263 | return [];
264 | }
265 | throw err;
266 | }
267 | }
268 |
269 | /**
270 | * Checks the state of streamed install feature.
271 | * This feature allows to speed up apk installation
272 | * since it does not require the original apk to be pushed to
273 | * the device under test first, which also saves space.
274 | * Although, it is required that both the device under test
275 | * and the adb server have the mentioned functionality.
276 | * See https://github.com/aosp-mirror/platform_system_core/blob/master/adb/client/adb_install.cpp
277 | * for more details
278 | *
279 | * @this {import('../adb.js').ADB}
280 | * @returns {Promise} `true` if the feature is supported by both adb and the
281 | * device under test
282 | */
283 | export async function isStreamedInstallSupported () {
284 | const proto = Object.getPrototypeOf(this);
285 | proto._helpOutput = proto._helpOutput || await this.adbExec(['help']);
286 | return proto._helpOutput.includes('--streaming')
287 | && (await this.listFeatures()).includes('cmd');
288 | }
289 |
290 | /**
291 | * Checks whether incremental install feature is supported by ADB.
292 | * Read https://developer.android.com/preview/features#incremental
293 | * for more details on it.
294 | *
295 | * @this {import('../adb.js').ADB}
296 | * @returns {Promise} `true` if the feature is supported by both adb and the
297 | * device under test
298 | */
299 | export async function isIncrementalInstallSupported () {
300 | const {binary} = await this.getVersion();
301 | if (!binary) {
302 | return false;
303 | }
304 | return util.compareVersions(`${binary.version}`, '>=', '30.0.1')
305 | && (await this.listFeatures()).includes('abb_exec');
306 | }
307 |
308 | /**
309 | * Takes a screenshot of the given display or the default display.
310 | *
311 | * @this {import('../adb.js').ADB}
312 | * @param {number|string?} displayId A valid display identifier. If
313 | * no identifier is provided then the screenshot of the default display is returned.
314 | * Note that only recent Android APIs provide multi-screen support.
315 | * @returns {Promise} PNG screenshot payload
316 | */
317 | export async function takeScreenshot (displayId) {
318 | const args = [...this.executable.defaultArgs, 'exec-out', 'screencap', '-p'];
319 | // @ts-ignore This validation works as expected
320 | const displayIdStr = isNaN(displayId) ? null : `${displayId}`;
321 | if (displayIdStr) {
322 | args.push('-d', displayIdStr);
323 | }
324 | const displayDescr = displayIdStr ? 'default display' : `display #${displayIdStr}`;
325 | let stdout;
326 | try {
327 | ({stdout} = await exec(
328 | this.executable.path, args, {encoding: 'binary', isBuffer: true}
329 | ));
330 | } catch (e) {
331 | const err = /** @type {import('teen_process').ExecError} */ (e);
332 | throw new Error(
333 | `Screenshot of the ${displayDescr} failed. ` +
334 | // @ts-ignore The output is a buffer
335 | `Code: '${err.code}', output: '${(err.stderr.length ? err.stderr : err.stdout).toString('utf-8')}'`
336 | );
337 | }
338 | if (stdout.length === 0) {
339 | throw new Error(`Screenshot of the ${displayDescr} returned no data`);
340 | }
341 | return stdout;
342 | }
343 |
--------------------------------------------------------------------------------
/lib/tools/keyboard-commands.js:
--------------------------------------------------------------------------------
1 | import { log } from '../logger.js';
2 | import _ from 'lodash';
3 | import { waitForCondition } from 'asyncbox';
4 | import B from 'bluebird';
5 |
6 | const KEYCODE_ESC = 111;
7 | const KEYCODE_BACK = 4;
8 |
9 | /**
10 | * Hides software keyboard if it is visible.
11 | * Noop if the keyboard is already hidden.
12 | *
13 | * @this {import('../adb.js').ADB}
14 | * @param {number} [timeoutMs=1000] For how long to wait (in milliseconds)
15 | * until the keyboard is actually hidden.
16 | * @returns {Promise} `false` if the keyboard was already hidden
17 | * @throws {Error} If the keyboard cannot be hidden.
18 | */
19 | export async function hideKeyboard (timeoutMs = 1000) {
20 | let {isKeyboardShown, canCloseKeyboard} = await this.isSoftKeyboardPresent();
21 | if (!isKeyboardShown) {
22 | log.info('Keyboard has no UI; no closing necessary');
23 | return false;
24 | }
25 | // Try ESC then BACK if the first one fails
26 | for (const keyCode of [KEYCODE_ESC, KEYCODE_BACK]) {
27 | if (canCloseKeyboard) {
28 | await this.keyevent(keyCode);
29 | }
30 | try {
31 | return await waitForCondition(async () => {
32 | ({isKeyboardShown} = await this.isSoftKeyboardPresent());
33 | return !isKeyboardShown;
34 | }, {waitMs: timeoutMs, intervalMs: 500});
35 | } catch {}
36 | }
37 | throw new Error(`The software keyboard cannot be hidden`);
38 | }
39 |
40 | /**
41 | * Retrieve the state of the software keyboard on the device under test.
42 | *
43 | * @this {import('../adb.js').ADB}
44 | * @return {Promise} The keyboard state.
45 | */
46 | export async function isSoftKeyboardPresent () {
47 | try {
48 | const stdout = await this.shell(['dumpsys', 'input_method']);
49 | const inputShownMatch = /mInputShown=(\w+)/.exec(stdout);
50 | const inputViewShownMatch = /mIsInputViewShown=(\w+)/.exec(stdout);
51 | return {
52 | isKeyboardShown: !!(inputShownMatch && inputShownMatch[1] === 'true'),
53 | canCloseKeyboard: !!(inputViewShownMatch && inputViewShownMatch[1] === 'true'),
54 | };
55 | } catch (e) {
56 | throw new Error(`Error finding softkeyboard. Original error: ${e.message}`);
57 | }
58 | }
59 |
60 | /**
61 | * Send the particular keycode to the device under test.
62 | *
63 | * @this {import('../adb.js').ADB}
64 | * @param {string|number} keycode - The actual key code to be sent.
65 | */
66 | export async function keyevent (keycode) {
67 | // keycode must be an int.
68 | const code = parseInt(`${keycode}`, 10);
69 | await this.shell(['input', 'keyevent', `${code}`]);
70 | }
71 |
72 |
73 | /**
74 | * Retrieve the list of available input methods (IMEs) for the device under test.
75 | *
76 | * @this {import('../adb.js').ADB}
77 | * @return {Promise} The list of IME names or an empty list.
78 | */
79 | export async function availableIMEs () {
80 | try {
81 | return getIMEListFromOutput(await this.shell(['ime', 'list', '-a']));
82 | } catch (e) {
83 | const err = /** @type {Error} */ (e);
84 | throw new Error(`Error getting available IME's. Original error: ${err.message}`);
85 | }
86 | }
87 |
88 | /**
89 | * Retrieve the list of enabled input methods (IMEs) for the device under test.
90 | *
91 | * @this {import('../adb.js').ADB}
92 | * @return {Promise} The list of enabled IME names or an empty list.
93 | */
94 | export async function enabledIMEs () {
95 | try {
96 | return getIMEListFromOutput(await this.shell(['ime', 'list']));
97 | } catch (e) {
98 | const err = /** @type {Error} */ (e);
99 | throw new Error(`Error getting enabled IME's. Original error: ${err.message}`);
100 | }
101 | }
102 |
103 | /**
104 | * Enable the particular input method on the device under test.
105 | *
106 | * @this {import('../adb.js').ADB}
107 | * @param {string} imeId - One of existing IME ids.
108 | */
109 | export async function enableIME (imeId) {
110 | await this.shell(['ime', 'enable', imeId]);
111 | }
112 |
113 | /**
114 | * Disable the particular input method on the device under test.
115 | *
116 | * @this {import('../adb.js').ADB}
117 | * @param {string} imeId - One of existing IME ids.
118 | */
119 | export async function disableIME (imeId) {
120 | await this.shell(['ime', 'disable', imeId]);
121 | }
122 |
123 | /**
124 | * Set the particular input method on the device under test.
125 | *
126 | * @this {import('../adb.js').ADB}
127 | * @param {string} imeId - One of existing IME ids.
128 | */
129 | export async function setIME (imeId) {
130 | await this.shell(['ime', 'set', imeId]);
131 | }
132 |
133 | /**
134 | * Get the default input method on the device under test.
135 | *
136 | * @this {import('../adb.js').ADB}
137 | * @return {Promise} The name of the default input method
138 | */
139 | export async function defaultIME () {
140 | try {
141 | let engine = await this.getSetting('secure', 'default_input_method');
142 | if (engine === 'null') {
143 | return null;
144 | }
145 | return engine.trim();
146 | } catch (e) {
147 | const err = /** @type {Error} */ (e);
148 | throw new Error(`Error getting default IME. Original error: ${err.message}`);
149 | }
150 | }
151 |
152 | /**
153 | * Send the particular text or a number to the device under test.
154 | * The text gets properly escaped before being passed to ADB.
155 | * Noop if the text is empty.
156 | *
157 | * @this {import('../adb.js').ADB}
158 | * @param {string|number} text - The actual text to be sent.
159 | * @throws {Error} If it is impossible to escape the given string
160 | */
161 | export async function inputText (text) {
162 | if (text === '') {
163 | return;
164 | }
165 |
166 | const originalStr = `${text}`;
167 | const escapedText = originalStr.replace(/\$/g, '\\$').replace(/ /g, '%s');
168 | let args = ['input', 'text', originalStr];
169 | // https://stackoverflow.com/questions/25791423/adb-shell-input-text-does-not-take-ampersand-character/25791498
170 | const adbInputEscapePattern = /[()<>|;&*\\~^"']/g;
171 | if (escapedText !== originalStr || adbInputEscapePattern.test(originalStr)) {
172 | if (_.every(['"', `'`], (c) => originalStr.includes(c))) {
173 | throw new Error(
174 | `Did not know how to escape a string that contains both types of quotes (" and ')`
175 | );
176 | }
177 | const q = originalStr.includes('"') ? `'` : '"';
178 | args = [`input text ${q}${escapedText}${q}`];
179 | }
180 | await this.shell(args);
181 | }
182 |
183 | /**
184 | * Executes the given function with the given input method context
185 | * and then restores the IME to the original value
186 | *
187 | * @this {import('../adb.js').ADB}
188 | * @param {string} ime - Valid IME identifier
189 | * @param {Function} fn - Function to execute
190 | * @returns {Promise} The result of the given function
191 | */
192 | export async function runInImeContext (ime, fn) {
193 | const originalIme = await this.defaultIME();
194 | if (originalIme === ime) {
195 | log.debug(`The original IME is the same as '${ime}'. There is no need to reset it`);
196 | } else {
197 | await this.enableIME(ime);
198 | await this.setIME(ime);
199 | // https://github.com/appium/appium/issues/15943
200 | await B.delay(500);
201 | }
202 | try {
203 | return await fn();
204 | } finally {
205 | if (originalIme && originalIme !== ime) {
206 | await this.setIME(originalIme);
207 | }
208 | }
209 | }
210 |
211 | // #region Private function
212 |
213 | /**
214 | * @param {string} stdout
215 | * @returns {string[]}
216 | */
217 | function getIMEListFromOutput (stdout) {
218 | /** @type {string[]} */
219 | const engines = [];
220 | for (const line of stdout.split('\n')) {
221 | if (line.length > 0 && line[0] !== ' ') {
222 | // remove newline and trailing colon, and add to the list
223 | engines.push(line.trim().replace(/:$/, ''));
224 | }
225 | }
226 | return engines;
227 | }
228 |
229 | // #endregion
230 |
--------------------------------------------------------------------------------
/lib/tools/lockmgmt.js:
--------------------------------------------------------------------------------
1 | import { log } from '../logger.js';
2 | import _ from 'lodash';
3 | import B from 'bluebird';
4 | import { waitForCondition } from 'asyncbox';
5 |
6 | const CREDENTIAL_CANNOT_BE_NULL_OR_EMPTY_ERROR = `Credential can't be null or empty`;
7 | const CREDENTIAL_DID_NOT_MATCH_ERROR = `didn't match`;
8 | const SUPPORTED_LOCK_CREDENTIAL_TYPES = ['password', 'pin', 'pattern'];
9 | const KEYCODE_POWER = 26;
10 | const KEYCODE_WAKEUP = 224; // works over API Level 20
11 | const HIDE_KEYBOARD_WAIT_TIME = 100;
12 |
13 | /**
14 | * @param {string} verb
15 | * @param {string?} [oldCredential=null]
16 | * @param {...string} args
17 | */
18 | function buildCommand (verb, oldCredential = null, ...args) {
19 | const cmd = ['locksettings', verb];
20 | if (!_.isEmpty(oldCredential)) {
21 | cmd.push('--old', /** @type {string} */ (oldCredential));
22 | }
23 | if (!_.isEmpty(args)) {
24 | cmd.push(...args);
25 | }
26 | return cmd;
27 | }
28 |
29 | /**
30 | * Performs swipe up gesture on the screen
31 | *
32 | * @this {import('../adb.js').ADB}
33 | * @param {string} windowDumpsys The output of `adb shell dumpsys window` command
34 | * @throws {Error} If the display size cannot be retrieved
35 | */
36 | async function swipeUp (windowDumpsys) {
37 | const dimensionsMatch = /init=(\d+)x(\d+)/.exec(windowDumpsys);
38 | if (!dimensionsMatch) {
39 | throw new Error('Cannot retrieve the display size');
40 | }
41 | const displayWidth = parseInt(dimensionsMatch[1], 10);
42 | const displayHeight = parseInt(dimensionsMatch[2], 10);
43 | const x0 = displayWidth / 2;
44 | const y0 = displayHeight / 5 * 4;
45 | const x1 = x0;
46 | const y1 = displayHeight / 5;
47 | await this.shell([
48 | 'input', 'touchscreen', 'swipe',
49 | ...([x0, y0, x1, y1].map((c) => `${Math.trunc(c)}`))
50 | ]);
51 | }
52 |
53 | /**
54 | * Check whether the device supports lock settings management with `locksettings`
55 | * command line tool. This tool has been added to Android toolset since API 27 Oreo
56 | *
57 | * @this {import('../adb.js').ADB}
58 | * @return {Promise} True if the management is supported. The result is cached per ADB instance
59 | */
60 | export async function isLockManagementSupported () {
61 | if (!_.isBoolean(this._isLockManagementSupported)) {
62 | const passFlag = '__PASS__';
63 | let output = '';
64 | try {
65 | output = await this.shell([`locksettings help && echo ${passFlag}`]);
66 | } catch {}
67 | this._isLockManagementSupported = _.includes(output, passFlag);
68 | log.debug(`Extended lock settings management is ` +
69 | `${this._isLockManagementSupported ? '' : 'not '}supported`);
70 | }
71 | return this._isLockManagementSupported;
72 | }
73 |
74 | /**
75 | * Check whether the given credential is matches to the currently set one.
76 | *
77 | * @this {import('../adb.js').ADB}
78 | * @param {string?} [credential=null] The credential value. It could be either
79 | * pin, password or a pattern. A pattern is specified by a non-separated list
80 | * of numbers that index the cell on the pattern in a 1-based manner in left
81 | * to right and top to bottom order, i.e. the top-left cell is indexed with 1,
82 | * whereas the bottom-right cell is indexed with 9. Example: 1234.
83 | * null/empty value assumes the device has no lock currently set.
84 | * @return {Promise} True if the given credential matches to the device's one
85 | * @throws {Error} If the verification faces an unexpected error
86 | */
87 | export async function verifyLockCredential (credential = null) {
88 | try {
89 | const {stdout, stderr} = await this.shell(buildCommand('verify', credential), {
90 | outputFormat: this.EXEC_OUTPUT_FORMAT.FULL
91 | });
92 | if (_.includes(stdout, 'verified successfully')) {
93 | return true;
94 | }
95 | if ([`didn't match`, CREDENTIAL_CANNOT_BE_NULL_OR_EMPTY_ERROR]
96 | .some((x) => _.includes(stderr || stdout, x))) {
97 | return false;
98 | }
99 | throw new Error(stderr || stdout);
100 | } catch (e) {
101 | throw new Error(`Device lock credential verification failed. ` +
102 | `Original error: ${e.stderr || e.stdout || e.message}`);
103 | }
104 | }
105 |
106 | /**
107 | * Clears current lock credentials. Usually it takes several seconds for a device to
108 | * sync the credential state after this method returns.
109 | *
110 | * @this {import('../adb.js').ADB}
111 | * @param {string?} [credential=null] The credential value. It could be either
112 | * pin, password or a pattern. A pattern is specified by a non-separated list
113 | * of numbers that index the cell on the pattern in a 1-based manner in left
114 | * to right and top to bottom order, i.e. the top-left cell is indexed with 1,
115 | * whereas the bottom-right cell is indexed with 9. Example: 1234.
116 | * null/empty value assumes the device has no lock currently set.
117 | * @throws {Error} If operation faces an unexpected error
118 | */
119 | export async function clearLockCredential (credential = null) {
120 | try {
121 | const {stdout, stderr} = await this.shell(buildCommand('clear', credential), {
122 | outputFormat: this.EXEC_OUTPUT_FORMAT.FULL
123 | });
124 | if (!['user has no password', 'Lock credential cleared']
125 | .some((x) => _.includes(stderr || stdout, x))) {
126 | throw new Error(stderr || stdout);
127 | }
128 | } catch (e) {
129 | throw new Error(`Cannot clear device lock credential. ` +
130 | `Original error: ${e.stderr || e.stdout || e.message}`);
131 | }
132 | }
133 |
134 | /**
135 | * Checks whether the device is locked with a credential (either pin or a password
136 | * or a pattern).
137 | *
138 | * @this {import('../adb.js').ADB}
139 | * @returns {Promise} `true` if the device is locked
140 | * @throws {Error} If operation faces an unexpected error
141 | */
142 | export async function isLockEnabled () {
143 | try {
144 | const {stdout, stderr} = await this.shell(buildCommand('get-disabled'), {
145 | outputFormat: this.EXEC_OUTPUT_FORMAT.FULL
146 | });
147 | if (/\bfalse\b/.test(stdout)
148 | || [CREDENTIAL_DID_NOT_MATCH_ERROR, CREDENTIAL_CANNOT_BE_NULL_OR_EMPTY_ERROR].some(
149 | (x) => _.includes(stderr || stdout, x))) {
150 | return true;
151 | }
152 | if (/\btrue\b/.test(stdout)) {
153 | return false;
154 | }
155 | throw new Error(stderr || stdout);
156 | } catch (e) {
157 | throw new Error(`Cannot check if device lock is enabled. Original error: ${e.message}`);
158 | }
159 | }
160 |
161 | /**
162 | * Sets the device lock.
163 | *
164 | * @this {import('../adb.js').ADB}
165 | * @param {string} credentialType One of: password, pin, pattern.
166 | * @param {string} credential A non-empty credential value to be set.
167 | * Make sure your new credential matches to the actual system security requirements,
168 | * e.g. a minimum password length. A pattern is specified by a non-separated list
169 | * of numbers that index the cell on the pattern in a 1-based manner in left
170 | * to right and top to bottom order, i.e. the top-left cell is indexed with 1,
171 | * whereas the bottom-right cell is indexed with 9. Example: 1234.
172 | * @param {string?} [oldCredential=null] An old credential string.
173 | * It is only required to be set in case you need to change the current
174 | * credential rather than to set a new one. Setting it to a wrong value will
175 | * make this method to fail and throw an exception.
176 | * @throws {Error} If there was a failure while verifying input arguments or setting
177 | * the credential
178 | */
179 | export async function setLockCredential (
180 | credentialType, credential, oldCredential = null) {
181 | if (!SUPPORTED_LOCK_CREDENTIAL_TYPES.includes(credentialType)) {
182 | throw new Error(`Device lock credential type '${credentialType}' is unknown. ` +
183 | `Only the following credential types are supported: ${SUPPORTED_LOCK_CREDENTIAL_TYPES}`);
184 | }
185 | if (_.isEmpty(credential) && !_.isInteger(credential)) {
186 | throw new Error('Device lock credential cannot be empty');
187 | }
188 | const cmd = buildCommand(`set-${credentialType}`, oldCredential, credential);
189 | try {
190 | const {stdout, stderr} = await this.shell(cmd, {
191 | outputFormat: this.EXEC_OUTPUT_FORMAT.FULL
192 | });
193 | if (!_.includes(stdout, 'set to')) {
194 | throw new Error(stderr || stdout);
195 | }
196 | } catch (e) {
197 | throw new Error(`Setting of device lock ${credentialType} credential failed. ` +
198 | `Original error: ${e.stderr || e.stdout || e.message}`);
199 | }
200 | }
201 |
202 | /**
203 | * Retrieve the screen lock state of the device under test.
204 | *
205 | * @this {import('../adb.js').ADB}
206 | * @return {Promise} True if the device is locked.
207 | */
208 | export async function isScreenLocked () {
209 | const [windowOutput, powerOutput] = await B.all([
210 | this.shell(['dumpsys', 'window']),
211 | this.shell(['dumpsys', 'power']),
212 | ]);
213 | return isShowingLockscreen(windowOutput)
214 | || isCurrentFocusOnKeyguard(windowOutput)
215 | || !isScreenOnFully(windowOutput)
216 | || isInDozingMode(powerOutput)
217 | || isScreenStateOff(windowOutput);
218 | }
219 |
220 | /**
221 | * Dismisses keyguard overlay.
222 | * @this {import('../adb.js').ADB}
223 | */
224 | export async function dismissKeyguard () {
225 | log.info('Waking up the device to dismiss the keyguard');
226 | // Screen off once to force pre-inputted text field clean after wake-up
227 | // Just screen on if the screen defaults off
228 | await this.cycleWakeUp();
229 |
230 | if (await this.getApiLevel() > 21) {
231 | await this.shell(['wm', 'dismiss-keyguard']);
232 | return;
233 | }
234 |
235 | const stdout = await this.shell(['dumpsys', 'window']);
236 | if (!isCurrentFocusOnKeyguard(stdout)) {
237 | log.debug('The keyguard seems to be inactive');
238 | return;
239 | }
240 |
241 | log.debug('Swiping up to dismiss the keyguard');
242 | if (await this.hideKeyboard()) {
243 | await B.delay(HIDE_KEYBOARD_WAIT_TIME);
244 | }
245 | log.debug('Dismissing notifications from the unlock view');
246 | await this.shell(['service', 'call', 'notification', '1']);
247 | await this.back();
248 | await swipeUp.bind(this)(stdout);
249 | }
250 |
251 | /**
252 | * Presses the corresponding key combination to make sure the device's screen
253 | * is not turned off and is locked if the latter is enabled.
254 | * @this {import('../adb.js').ADB}
255 | */
256 | export async function cycleWakeUp () {
257 | await this.keyevent(KEYCODE_POWER);
258 | await this.keyevent(KEYCODE_WAKEUP);
259 | }
260 |
261 | /**
262 | * Send the special keycode to the device under test in order to lock it.
263 | * @this {import('../adb.js').ADB}
264 | */
265 | export async function lock () {
266 | if (await this.isScreenLocked()) {
267 | log.debug('Screen is already locked. Doing nothing.');
268 | return;
269 | }
270 | log.debug('Pressing the KEYCODE_POWER button to lock screen');
271 | await this.keyevent(26);
272 |
273 | const timeoutMs = 5000;
274 | try {
275 | await waitForCondition(async () => await this.isScreenLocked(), {
276 | waitMs: timeoutMs,
277 | intervalMs: 500,
278 | });
279 | } catch {
280 | throw new Error(`The device screen is still not locked after ${timeoutMs}ms timeout`);
281 | }
282 | }
283 |
284 | // #region Private functions
285 |
286 | /**
287 | * Checks mScreenOnFully in dumpsys output to determine if screen is showing
288 | * Default is true.
289 | * Note: this key
290 | *
291 | * @param {string} dumpsys
292 | * @returns {boolean}
293 | */
294 | function isScreenOnFully (dumpsys) {
295 | const m = /mScreenOnFully=\w+/gi.exec(dumpsys);
296 | return !m || // if information is missing we assume screen is fully on
297 | (m && m.length > 0 && m[0].split('=')[1] === 'true') || false;
298 | }
299 |
300 | /**
301 | * Checks mCurrentFocus in dumpsys output to determine if Keyguard is activated
302 | *
303 | * @param {string} dumpsys
304 | * @returns {boolean}
305 | */
306 | function isCurrentFocusOnKeyguard (dumpsys) {
307 | const m = /mCurrentFocus.+Keyguard/gi.exec(dumpsys);
308 | return Boolean(m?.length && m[0]);
309 | }
310 |
311 | /**
312 | * Check the current device power state to determine if it is locked
313 | *
314 | * @param {string} dumpsys The `adb shell dumpsys power` output
315 | * @returns {boolean} True if lock screen is shown
316 | */
317 | function isInDozingMode(dumpsys) {
318 | // On some phones/tablets we were observing mWakefulness=Dozing
319 | // while on others it was getWakefulnessLocked()=Dozing
320 | return /^[\s\w]+wakefulness[^=]*=Dozing$/im.test(dumpsys);
321 | }
322 |
323 | /**
324 | * Checks mShowingLockscreen or mDreamingLockscreen in dumpsys output to determine
325 | * if lock screen is showing
326 | *
327 | * A note: `adb shell dumpsys trust` performs better while detecting the locked screen state
328 | * in comparison to `adb dumpsys window` output parsing.
329 | * But the trust command does not work for `Swipe` unlock pattern.
330 | *
331 | * In some Android devices (Probably around Android 10+), `mShowingLockscreen` and `mDreamingLockscreen`
332 | * do not work to detect lock status. Instead, keyguard preferences helps to detect the lock condition.
333 | * Some devices such as Android TV do not have keyguard, so we should keep
334 | * screen condition as this primary method.
335 | *
336 | * @param {string} dumpsys - The output of dumpsys window command.
337 | * @return {boolean} True if lock screen is showing.
338 | */
339 | export function isShowingLockscreen (dumpsys) {
340 | return _.some(['mShowingLockscreen=true', 'mDreamingLockscreen=true'], (x) => dumpsys.includes(x))
341 | // `mIsShowing` and `mInputRestricted` are `true` in lock condition. `false` is unlock condition.
342 | || _.every([/KeyguardStateMonitor[\n\s]+mIsShowing=true/, /\s+mInputRestricted=true/], (x) => x.test(dumpsys));
343 | }
344 |
345 | /**
346 | * Checks screenState has SCREEN_STATE_OFF in dumpsys output to determine
347 | * possible lock screen.
348 | *
349 | * @param {string} dumpsys - The output of dumpsys window command.
350 | * @return {boolean} True if lock screen is showing.
351 | */
352 | export function isScreenStateOff(dumpsys) {
353 | return /\s+screenState=SCREEN_STATE_OFF/i.test(dumpsys);
354 | }
355 |
356 | // #endregion
357 |
--------------------------------------------------------------------------------
/lib/tools/logcat-commands.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { Logcat } from '../logcat';
3 |
4 | /**
5 | * Start the logcat process to gather logs.
6 | *
7 | * @this {import('../adb.js').ADB}
8 | * @param {import('./types').LogcatOpts} [opts={}]
9 | * @throws {Error} If restart fails.
10 | */
11 | export async function startLogcat (opts = {}) {
12 | if (!_.isEmpty(this.logcat)) {
13 | throw new Error("Trying to start logcat capture but it's already started!");
14 | }
15 |
16 | this.logcat = new Logcat({
17 | adb: this.executable,
18 | debug: false,
19 | debugTrace: false,
20 | clearDeviceLogsOnStart: !!this.clearDeviceLogsOnStart,
21 | });
22 | await this.logcat.startCapture(opts);
23 | this._logcatStartupParams = opts;
24 | }
25 |
26 | /**
27 | * Stop the active logcat process which gathers logs.
28 | * The call will be ignored if no logcat process is running.
29 | * @this {import('../adb.js').ADB}
30 | */
31 | export async function stopLogcat () {
32 | if (_.isEmpty(this.logcat)) {
33 | return;
34 | }
35 | try {
36 | await this.logcat.stopCapture();
37 | } finally {
38 | this.logcat = undefined;
39 | }
40 | }
41 |
42 | /**
43 | * Retrieve the output from the currently running logcat process.
44 | * The logcat process should be executed by {2link #startLogcat} method.
45 | *
46 | * @this {import('../adb.js').ADB}
47 | * @return {import('./types').LogEntry[]} The collected logcat output.
48 | * @throws {Error} If logcat process is not running.
49 | */
50 | export function getLogcatLogs () {
51 | if (_.isEmpty(this.logcat)) {
52 | throw new Error(`Can't get logcat logs since logcat hasn't started`);
53 | }
54 | return this.logcat.getLogs();
55 | }
56 |
57 | /**
58 | * Set the callback for the logcat output event.
59 | *
60 | * @this {import('../adb.js').ADB}
61 | * @param {import('./types').LogcatListener} listener - Listener function
62 | * @throws {Error} If logcat process is not running.
63 | */
64 | export function setLogcatListener (listener) {
65 | if (_.isEmpty(this.logcat)) {
66 | throw new Error("Logcat process hasn't been started");
67 | }
68 | this.logcat.on('output', listener);
69 | }
70 |
71 | /**
72 | * Removes the previously set callback for the logcat output event.
73 | *
74 | * @this {import('../adb.js').ADB}
75 | * @param {import('./types').LogcatListener} listener
76 | * The listener function, which has been previously
77 | * passed to `setLogcatListener`
78 | * @throws {Error} If logcat process is not running.
79 | */
80 | export function removeLogcatListener (listener) {
81 | if (_.isEmpty(this.logcat)) {
82 | throw new Error("Logcat process hasn't been started");
83 | }
84 | this.logcat.removeListener('output', listener);
85 | }
86 |
--------------------------------------------------------------------------------
/lib/tools/network-commands.js:
--------------------------------------------------------------------------------
1 |
2 | import { EOL } from 'os';
3 | import _ from 'lodash';
4 | import { log } from '../logger.js';
5 |
6 | /**
7 | * Get TCP port forwarding with adb on the device under test.
8 | *
9 | * @this {import('../adb.js').ADB}
10 | * @return {Promise} The output of the corresponding adb command.
11 | * An array contains each forwarding line of output
12 | */
13 | export async function getForwardList () {
14 | log.debug(`List forwarding ports`);
15 | const connections = await this.adbExec(['forward', '--list']);
16 | return connections.split(EOL).filter((line) => Boolean(line.trim()));
17 | }
18 |
19 | /**
20 | * Setup TCP port forwarding with adb on the device under test.
21 | *
22 | * @this {import('../adb.js').ADB}
23 | * @param {string|number} systemPort - The number of the local system port.
24 | * @param {string|number} devicePort - The number of the remote device port.
25 | */
26 | export async function forwardPort (systemPort, devicePort) {
27 | log.debug(`Forwarding system: ${systemPort} to device: ${devicePort}`);
28 | await this.adbExec(['forward', `tcp:${systemPort}`, `tcp:${devicePort}`]);
29 | }
30 |
31 | /**
32 | * Remove TCP port forwarding with adb on the device under test. The forwarding
33 | * for the given port should be setup with {@link #forwardPort} first.
34 | *
35 | * @this {import('../adb.js').ADB}
36 | * @param {string|number} systemPort - The number of the local system port
37 | * to remove forwarding on.
38 | */
39 | export async function removePortForward (systemPort) {
40 | log.debug(`Removing forwarded port socket connection: ${systemPort} `);
41 | await this.adbExec(['forward', `--remove`, `tcp:${systemPort}`]);
42 | }
43 |
44 | /**
45 | * Get TCP port forwarding with adb on the device under test.
46 | *
47 | * @this {import('../adb.js').ADB}
48 | * @return {Promise} The output of the corresponding adb command.
49 | * An array contains each forwarding line of output
50 | */
51 | export async function getReverseList () {
52 | log.debug(`List reverse forwarding ports`);
53 | const connections = await this.adbExec(['reverse', '--list']);
54 | return connections.split(EOL).filter((line) => Boolean(line.trim()));
55 | }
56 |
57 | /**
58 | * Setup TCP port forwarding with adb on the device under test.
59 | * Only available for API 21+.
60 | *
61 | * @this {import('../adb.js').ADB}
62 | * @param {string|number} devicePort - The number of the remote device port.
63 | * @param {string|number} systemPort - The number of the local system port.
64 | */
65 | export async function reversePort (devicePort, systemPort) {
66 | log.debug(`Forwarding device: ${devicePort} to system: ${systemPort}`);
67 | await this.adbExec(['reverse', `tcp:${devicePort}`, `tcp:${systemPort}`]);
68 | }
69 |
70 | /**
71 | * Remove TCP port forwarding with adb on the device under test. The forwarding
72 | * for the given port should be setup with {@link #forwardPort} first.
73 | *
74 | * @this {import('../adb.js').ADB}
75 | * @param {string|number} devicePort - The number of the remote device port
76 | * to remove forwarding on.
77 | */
78 | export async function removePortReverse (devicePort) {
79 | log.debug(`Removing reverse forwarded port socket connection: ${devicePort} `);
80 | await this.adbExec(['reverse', `--remove`, `tcp:${devicePort}`]);
81 | }
82 |
83 | /**
84 | * Setup TCP port forwarding with adb on the device under test. The difference
85 | * between {@link #forwardPort} is that this method does setup for an abstract
86 | * local port.
87 | *
88 | * @this {import('../adb.js').ADB}
89 | * @param {string|number} systemPort - The number of the local system port.
90 | * @param {string|number} devicePort - The number of the remote device port.
91 | */
92 | export async function forwardAbstractPort (systemPort, devicePort) {
93 | log.debug(`Forwarding system: ${systemPort} to abstract device: ${devicePort}`);
94 | await this.adbExec(['forward', `tcp:${systemPort}`, `localabstract:${devicePort}`]);
95 | }
96 |
97 | /**
98 | * Execute ping shell command on the device under test.
99 | *
100 | * @this {import('../adb.js').ADB}
101 | * @return {Promise} True if the command output contains 'ping' substring.
102 | * @throws {Error} If there was an error while executing 'ping' command on the
103 | * device under test.
104 | */
105 | export async function ping () {
106 | let stdout = await this.shell(['echo', 'ping']);
107 | if (stdout.indexOf('ping') === 0) {
108 | return true;
109 | }
110 | throw new Error(`ADB ping failed, returned ${stdout}`);
111 | }
112 |
113 | /**
114 | * Returns the list of TCP port states of the given family.
115 | * Could be empty if no ports are opened.
116 | *
117 | * @this {import('../adb.js').ADB}
118 | * @param {import('./types').PortFamily} [family='4']
119 | * @returns {Promise}
120 | */
121 | export async function listPorts(family = '4') {
122 | const sourceProcName = `/proc/net/tcp${family === '6' ? '6' : ''}`;
123 | const output = await this.shell(['cat', sourceProcName]);
124 | const lines = output.split('\n');
125 | if (_.isEmpty(lines)) {
126 | log.debug(output);
127 | throw new Error(`Cannot parse the payload of ${sourceProcName}`);
128 | }
129 | // sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
130 | const colHeaders = lines[0].split(/\s+/).filter(Boolean);
131 | const localAddressCol = colHeaders.findIndex((x) => x === 'local_address');
132 | const stateCol = colHeaders.findIndex((x) => x === 'st');
133 | if (localAddressCol < 0 || stateCol < 0) {
134 | log.debug(lines[0]);
135 | throw new Error(`Cannot parse the header row of ${sourceProcName} payload`);
136 | }
137 | /** @type {import('./types').PortInfo[]} */
138 | const result = [];
139 | // 2: 1002000A:D036 24CE3AD8:01BB 08 00000000:00000000 00:00000000 00000000 10132 0 49104 1 0000000000000000 21 4 20 10 -1
140 | for (const line of lines.slice(1)) {
141 | const values = line.split(/\s+/).filter(Boolean);
142 | const portStr = values[localAddressCol]?.split(':')?.[1];
143 | const stateStr = values[stateCol];
144 | if (!portStr || !stateStr) {
145 | continue;
146 | }
147 | result.push({
148 | port: parseInt(portStr, 16),
149 | family,
150 | state: parseInt(stateStr, 16),
151 | });
152 | };
153 | return result;
154 | }
155 |
--------------------------------------------------------------------------------
/lib/types.ts:
--------------------------------------------------------------------------------
1 | import type {Logcat} from './logcat';
2 | import type {StringRecord} from './tools/types';
3 |
4 | export interface ADBOptions {
5 | sdkRoot?: string;
6 | udid?: string;
7 | appDeviceReadyTimeout?: number;
8 | useKeystore?: boolean;
9 | keystorePath?: string;
10 | keystorePassword?: string;
11 | keyAlias?: string;
12 | keyPassword?: string;
13 | executable?: ADBExecutable;
14 | tmpDir?: string;
15 | curDeviceId?: string;
16 | emulatorPort?: number;
17 | logcat?: Logcat;
18 | binaries?: StringRecord;
19 | suppressKillServer?: boolean;
20 | adbPort?: number;
21 | adbHost?: string;
22 | adbExecTimeout?: number;
23 | remoteAppsCacheLimit?: number;
24 | buildToolsVersion?: string;
25 | allowOfflineDevices?: boolean;
26 | allowDelayAdb?: boolean;
27 | remoteAdbHost?: string;
28 | remoteAdbPort?: number;
29 | clearDeviceLogsOnStart?: boolean;
30 | }
31 |
32 | export interface ADBExecutable {
33 | path: string;
34 | defaultArgs: string[];
35 | }
36 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "appium-adb",
3 | "version": "12.12.5",
4 | "description": "Android Debug Bridge interface",
5 | "main": "./build/index.js",
6 | "scripts": {
7 | "build": "tsc -b",
8 | "clean": "npm run build -- --clean",
9 | "dev": "npm run build -- --watch",
10 | "lint": "eslint .",
11 | "lint:fix": "npm run lint -- --fix",
12 | "prepare": "npm run rebuild",
13 | "rebuild": "npm run clean && npm run build",
14 | "format": "prettier -w ./lib",
15 | "test": "mocha --exit --timeout 1m \"./test/unit/**/*-specs.js\"",
16 | "e2e-test": "mocha --exit --timeout 5m \"./test/functional/**/*-specs.js\""
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/appium/appium-adb.git"
21 | },
22 | "author": "Appium Contributors",
23 | "license": "Apache-2.0",
24 | "bugs": {
25 | "url": "https://github.com/appium/appium-adb/issues"
26 | },
27 | "engines": {
28 | "node": ">=14",
29 | "npm": ">=8"
30 | },
31 | "bin": {},
32 | "directories": {
33 | "lib": "lib"
34 | },
35 | "prettier": {
36 | "bracketSpacing": false,
37 | "printWidth": 100,
38 | "singleQuote": true
39 | },
40 | "files": [
41 | "index.ts",
42 | "keys",
43 | "lib",
44 | "build/index.*",
45 | "build/lib",
46 | "CHANGELOG.md"
47 | ],
48 | "homepage": "https://github.com/appium/appium-adb",
49 | "dependencies": {
50 | "@appium/support": "^6.0.0",
51 | "async-lock": "^1.0.0",
52 | "asyncbox": "^3.0.0",
53 | "bluebird": "^3.4.7",
54 | "ini": "^5.0.0",
55 | "lodash": "^4.0.0",
56 | "lru-cache": "^10.0.0",
57 | "semver": "^7.0.0",
58 | "source-map-support": "^0.x",
59 | "teen_process": "^2.2.0"
60 | },
61 | "devDependencies": {
62 | "@appium/eslint-config-appium-ts": "^1.x",
63 | "@appium/test-support": "^3.0.1",
64 | "@semantic-release/changelog": "^6.0.1",
65 | "@semantic-release/git": "^10.0.1",
66 | "@types/async-lock": "^1.4.0",
67 | "@types/bluebird": "^3.5.38",
68 | "@types/ini": "^4.1.0",
69 | "@types/lodash": "^4.14.195",
70 | "@types/mocha": "^10.0.1",
71 | "@types/node": "^24.0.0",
72 | "@types/semver": "^7.5.0",
73 | "@types/source-map-support": "^0.5.6",
74 | "@types/teen_process": "^2.0.0",
75 | "chai": "^5.1.1",
76 | "chai-as-promised": "^8.0.0",
77 | "conventional-changelog-conventionalcommits": "^9.0.0",
78 | "mocha": "^11.0.1",
79 | "prettier": "^3.0.0",
80 | "rimraf": "^5.0.0",
81 | "semantic-release": "^24.0.0",
82 | "ts-node": "^10.9.1",
83 | "typescript": "^5.4.2"
84 | },
85 | "types": "./build/index.d.ts"
86 | }
87 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "func-names": 0
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/test/fixtures/ApiDemos-debug.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/ApiDemos-debug.apk
--------------------------------------------------------------------------------
/test/fixtures/ContactManager-old.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/ContactManager-old.apk
--------------------------------------------------------------------------------
/test/fixtures/ContactManager-selendroid.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/ContactManager-selendroid.apk
--------------------------------------------------------------------------------
/test/fixtures/ContactManager.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/ContactManager.apk
--------------------------------------------------------------------------------
/test/fixtures/Fingerprint.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/Fingerprint.apk
--------------------------------------------------------------------------------
/test/fixtures/TestZip.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/TestZip.zip
--------------------------------------------------------------------------------
/test/fixtures/appiumtest.keystore:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/appiumtest.keystore
--------------------------------------------------------------------------------
/test/fixtures/selendroid-test-app.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/selendroid-test-app.apk
--------------------------------------------------------------------------------
/test/fixtures/selendroid/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/test/fixtures/selendroid/selendroid.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/appium/appium-adb/2d8c8221dbfa08d73c7bf206f971edcb6bc32f8b/test/fixtures/selendroid/selendroid.apk
--------------------------------------------------------------------------------
/test/functional/adb-commands-e2e-specs.js:
--------------------------------------------------------------------------------
1 | import {ADB} from '../../lib/adb';
2 | import path from 'path';
3 | import { apiLevel, platformVersion, MOCHA_TIMEOUT } from './setup';
4 | import { fs, tempDir } from '@appium/support';
5 | import _ from 'lodash';
6 | import { waitForCondition } from 'asyncbox';
7 |
8 | const DEFAULT_IMES = [
9 | 'com.android.inputmethod.latin/.LatinIME',
10 | 'com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME',
11 | 'io.appium.android.ime/.UnicodeIME',
12 | ];
13 | const CONTACT_MANAGER_PATH = apiLevel < 23
14 | ? path.resolve(__dirname, '..', 'fixtures', 'ContactManager-old.apk')
15 | : path.resolve(__dirname, '..', 'fixtures', 'ContactManager.apk');
16 | const CONTACT_MANAGER_PKG = apiLevel < 23
17 | ? 'com.example.android.contactmanager'
18 | : 'com.saucelabs.ContactManager';
19 | const CONTACT_MANAGER_ACTIVITY = apiLevel < 23
20 | ? 'ContactManager'
21 | : 'com.saucelabs.ContactManager.ContactManager';
22 |
23 |
24 | describe('adb commands', function () {
25 | this.timeout(MOCHA_TIMEOUT);
26 |
27 | let adb;
28 | let chai;
29 | let expect;
30 | const androidInstallTimeout = 90000;
31 | before(async function () {
32 | chai = await import('chai');
33 | const chaiAsPromised = await import('chai-as-promised');
34 |
35 | chai.should();
36 | chai.use(chaiAsPromised.default);
37 | expect = chai.expect;
38 |
39 | adb = await ADB.createADB({ adbExecTimeout: 60000 });
40 | });
41 | it('getApiLevel should get correct api level', async function () {
42 | (await adb.getApiLevel()).should.equal(apiLevel);
43 | });
44 | it('getPlatformVersion should get correct platform version', async function () {
45 | const actualPlatformVersion = await adb.getPlatformVersion();
46 | parseFloat(platformVersion).should.equal(parseFloat(actualPlatformVersion));
47 | });
48 | it('availableIMEs should get list of available IMEs', async function () {
49 | (await adb.availableIMEs()).should.have.length.above(0);
50 | });
51 | it('enabledIMEs should get list of enabled IMEs', async function () {
52 | (await adb.enabledIMEs()).should.have.length.above(0);
53 | });
54 | it('defaultIME should get default IME', async function () {
55 | const defaultIME = await adb.defaultIME();
56 | if (defaultIME) {
57 | DEFAULT_IMES.should.include(defaultIME);
58 | }
59 | });
60 | it('enableIME and disableIME should enable and disable IME', async function () {
61 | const imes = await adb.availableIMEs();
62 | if (imes.length < 2) {
63 | return this.skip();
64 | }
65 |
66 | const ime = _.last(imes);
67 | await adb.disableIME(ime);
68 | (await adb.enabledIMEs()).should.not.include(ime);
69 | await adb.enableIME(ime);
70 | (await adb.enabledIMEs()).should.include(ime);
71 | });
72 | it('processExists should be able to find ui process', async function () {
73 | (await adb.processExists('com.android.systemui')).should.be.true;
74 | });
75 | it('ping should return true', async function () {
76 | (await adb.ping()).should.be.true;
77 | });
78 | it('getPIDsByName should return pids', async function () {
79 | (await adb.getPIDsByName('com.android.phone')).should.have.length.above(0);
80 | });
81 | it('killProcessesByName should kill process', async function () {
82 | if (process.env.CI) {
83 | this.retries(2);
84 | }
85 |
86 | await adb.install(CONTACT_MANAGER_PATH, {
87 | timeout: androidInstallTimeout,
88 | grantPermissions: true,
89 | });
90 | await adb.startApp({pkg: CONTACT_MANAGER_PKG, activity: CONTACT_MANAGER_ACTIVITY});
91 | await adb.killProcessesByName(CONTACT_MANAGER_PKG);
92 | await waitForCondition(async () => (await adb.getPIDsByName(CONTACT_MANAGER_PKG)).length === 0, {
93 | waitMs: 5000,
94 | intervalMs: 500,
95 | });
96 | });
97 | it('killProcessByPID should kill process', async function () {
98 | await adb.install(CONTACT_MANAGER_PATH, {
99 | timeout: androidInstallTimeout,
100 | grantPermissions: true,
101 | });
102 | await adb.startApp({pkg: CONTACT_MANAGER_PKG, activity: CONTACT_MANAGER_ACTIVITY});
103 | let pids = await adb.getPIDsByName(CONTACT_MANAGER_PKG);
104 | pids.should.have.length.above(0);
105 | await adb.killProcessByPID(pids[0]);
106 | await waitForCondition(async () => (await adb.getPIDsByName(CONTACT_MANAGER_PKG)).length === 0, {
107 | waitMs: 5000,
108 | intervalMs: 500,
109 | });
110 | });
111 | it('should get device language and country', async function () {
112 | if (parseInt(apiLevel, 10) >= 23 || process.env.CI) {
113 | return this.skip();
114 | }
115 |
116 | ['en', 'fr'].should.contain(await adb.getDeviceSysLanguage());
117 | ['US', 'EN_US', 'EN', 'FR'].should.contain(await adb.getDeviceSysCountry());
118 | });
119 | it('should forward the port', async function () {
120 | await adb.forwardPort(4724, 4724);
121 | });
122 | it('should remove forwarded port', async function () {
123 | await adb.forwardPort(8200, 6790);
124 | (await adb.adbExec([`forward`, `--list`])).should.contain('tcp:8200');
125 | await adb.removePortForward(8200);
126 | (await adb.adbExec([`forward`, `--list`])).should.not.contain('tcp:8200');
127 |
128 | });
129 | it('should reverse forward the port', async function () {
130 | await adb.reversePort(4724, 4724);
131 | });
132 | it('should remove reverse forwarded port', async function () {
133 | await adb.reversePort(6790, 8200);
134 | (await adb.adbExec([`reverse`, `--list`])).should.contain('tcp:6790');
135 | await adb.removePortReverse(6790);
136 | (await adb.adbExec([`reverse`, `--list`])).should.not.contain('tcp:6790');
137 |
138 | });
139 | it('should start logcat from adb', async function () {
140 | await adb.startLogcat();
141 | let logs = adb.logcat.getLogs();
142 | logs.should.have.length.above(0);
143 | await adb.stopLogcat();
144 | });
145 | it('should get model', async function () {
146 | (await adb.getModel()).should.not.be.null;
147 | });
148 | it('should get manufacturer', async function () {
149 | (await adb.getManufacturer()).should.not.be.null;
150 | });
151 | it('should get screen size', async function () {
152 | (await adb.getScreenSize()).should.not.be.null;
153 | });
154 | it('should get screen density', async function () {
155 | (await adb.getScreenDensity()).should.not.be.null;
156 | });
157 | it('should be able to toggle gps location provider', async function () {
158 | await adb.toggleGPSLocationProvider(true);
159 | (await adb.getLocationProviders()).should.include('gps');
160 | await adb.toggleGPSLocationProvider(false);
161 | (await adb.getLocationProviders()).should.not.include('gps');
162 |
163 | // To avoid side effects for other tests, especially on Android 16+
164 | await adb.toggleGPSLocationProvider(true);
165 | });
166 | it('should be able to toggle airplane mode', async function () {
167 | await adb.setAirplaneMode(true);
168 | (await adb.isAirplaneModeOn()).should.be.true;
169 | await adb.setAirplaneMode(false);
170 | (await adb.isAirplaneModeOn()).should.be.false;
171 | });
172 | describe('app permissions', function () {
173 | before(async function () {
174 | let deviceApiLevel = await adb.getApiLevel();
175 | if (deviceApiLevel < 23) {
176 | //test should skip if the device API < 23
177 | return this.skip();
178 | }
179 | let isInstalled = await adb.isAppInstalled('io.appium.android.apis');
180 | if (isInstalled) {
181 | await adb.uninstallApk('io.appium.android.apis');
182 | }
183 | });
184 | it('should install and grant all permission', async function () {
185 | let apiDemos = path.resolve(__dirname, '..', 'fixtures', 'ApiDemos-debug.apk');
186 | await adb.install(apiDemos, {timeout: androidInstallTimeout});
187 | (await adb.isAppInstalled('io.appium.android.apis')).should.be.true;
188 | await adb.grantAllPermissions('io.appium.android.apis');
189 | let requestedPermissions = await adb.getReqPermissions('io.appium.android.apis');
190 | expect(await adb.getGrantedPermissions('io.appium.android.apis')).to.have.members(requestedPermissions);
191 | });
192 | it('should revoke permission', async function () {
193 | await adb.revokePermission('io.appium.android.apis', 'android.permission.RECEIVE_SMS');
194 | expect(await adb.getGrantedPermissions('io.appium.android.apis')).to.not.have.members(['android.permission.RECEIVE_SMS']);
195 | });
196 | it('should grant permission', async function () {
197 | await adb.grantPermission('io.appium.android.apis', 'android.permission.RECEIVE_SMS');
198 | expect(await adb.getGrantedPermissions('io.appium.android.apis')).to.include.members(['android.permission.RECEIVE_SMS']);
199 | });
200 | });
201 |
202 | describe('push file', function () {
203 | function getRandomDir () {
204 | return `/data/local/tmp/test${Math.random()}`;
205 | }
206 |
207 | let localFile;
208 | let tempFile;
209 | let tempRoot;
210 | const stringData = `random string data ${Math.random()}`;
211 | before(async function () {
212 | tempRoot = await tempDir.openDir();
213 | localFile = path.join(tempRoot, 'local.tmp');
214 | tempFile = path.join(tempRoot, 'temp.tmp');
215 |
216 | await fs.writeFile(localFile, stringData);
217 | });
218 | after(async function () {
219 | if (tempRoot) {
220 | await fs.rimraf(tempRoot);
221 | }
222 | });
223 | afterEach(async function () {
224 | if (await fs.exists(tempFile)) {
225 | await fs.unlink(tempFile);
226 | }
227 | });
228 | it('should push file to a valid location', async function () {
229 | let remoteFile = `${getRandomDir()}/remote.txt`;
230 |
231 | await adb.push(localFile, remoteFile);
232 |
233 | // get the file and its contents, to check
234 | await adb.pull(remoteFile, tempFile);
235 | let remoteData = await fs.readFile(tempFile);
236 | remoteData.toString().should.equal(stringData);
237 | });
238 | it('should throw error if it cannot write to the remote file', async function () {
239 | await adb.push(localFile, '/foo/bar/remote.txt').should.be.rejectedWith(/\/foo/);
240 | });
241 | });
242 |
243 | describe('bugreport', function () {
244 | it('should return the report as a raw string', async function () {
245 | if (process.env.CI) {
246 | // skip the test on CI, since it takes a lot of time
247 | return this.skip;
248 | }
249 | const BUG_REPORT_TIMEOUT = 2 * 60 * 1000; // 2 minutes
250 | this.timeout(BUG_REPORT_TIMEOUT);
251 | (await adb.bugreport()).should.be.a('string');
252 | });
253 | });
254 |
255 | describe('features', function () {
256 | it('should return the features as a list', async function () {
257 | _.isArray(await adb.listFeatures()).should.be.true;
258 | });
259 | });
260 |
261 | describe('launchable activity', function () {
262 | it('should resolve the name of the launchable activity', async function () {
263 | (await adb.resolveLaunchableActivity(CONTACT_MANAGER_PKG)).should.not.be.empty;
264 | });
265 | });
266 |
267 | describe('isStreamedInstallSupported', function () {
268 | it('should return boolean value', async function () {
269 | _.isBoolean(await adb.isStreamedInstallSupported()).should.be.true;
270 | });
271 | });
272 |
273 | describe('isIncrementalInstallSupported', function () {
274 | it('should return boolean value', async function () {
275 | _.isBoolean(await adb.isIncrementalInstallSupported()).should.be.true;
276 | });
277 | });
278 |
279 | describe('addToDeviceIdleWhitelist', function () {
280 | it('should add package to the whitelist', async function () {
281 | await adb.install(CONTACT_MANAGER_PATH, {
282 | timeout: androidInstallTimeout,
283 | grantPermissions: true,
284 | });
285 | if (await adb.addToDeviceIdleWhitelist(CONTACT_MANAGER_PKG)) {
286 | const pkgList = await adb.getDeviceIdleWhitelist();
287 | pkgList.some((item) => item.includes(CONTACT_MANAGER_PKG)).should.be.true;
288 | }
289 | });
290 | });
291 |
292 | describe('takeScreenshot', function () {
293 | it('should return screenshot', async function () {
294 | _.isEmpty(await adb.takeScreenshot()).should.be.false;
295 | });
296 | });
297 |
298 | describe('listPorts', function () {
299 | it('should list opened ports', async function () {
300 | (_.isEmpty(await adb.listPorts()) && _.isEmpty(await adb.listPorts('6'))).should.be.false;
301 | });
302 | });
303 | });
304 |
--------------------------------------------------------------------------------
/test/functional/adb-e2e-specs.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import {ADB} from '../../lib/adb';
3 | import { fs } from '@appium/support';
4 | import path from 'path';
5 |
6 | describe('ADB', function () {
7 | let chai;
8 | let should;
9 |
10 | before(async function () {
11 | chai = await import('chai');
12 | const chaiAsPromised = await import('chai-as-promised');
13 |
14 | should = chai.should();
15 | chai.use(chaiAsPromised.default);
16 | });
17 |
18 | it('should correctly return adb if present', async function () {
19 | let adb = await ADB.createADB();
20 | should.exist(adb.executable.path);
21 | });
22 | it('should throw when ANDROID_HOME is ivalid', async function () {
23 | let opts = {sdkRoot: '/aasdasdds'};
24 | await ADB.createADB(opts).should.eventually.be.rejected;
25 | });
26 | it.skip('should error out if binary not persent', async function () {
27 | // TODO write a negative test
28 | });
29 | it('should initialize aapt', async function () {
30 | let adb = new ADB();
31 | await adb.initAapt();
32 | adb.binaries.aapt.should.contain('aapt');
33 | });
34 | it('should initialize aapt using the enforced build tools path', async function () {
35 | const buildToolsRoot = path.resolve(process.env.ANDROID_HOME, 'build-tools');
36 | const buildToolsVersion = _.first(await fs.readdir(buildToolsRoot));
37 | const adb = new ADB({buildToolsVersion});
38 | await adb.initAapt();
39 | adb.binaries.aapt.should.contain('aapt');
40 | });
41 | it('should initialize zipAlign', async function () {
42 | let adb = new ADB();
43 | await adb.initZipAlign();
44 | adb.binaries.zipalign.should.contain('zipalign');
45 | });
46 | it('should correctly initialize adb from parent', async function () {
47 | let adb = await ADB.createADB();
48 | should.exist(adb.executable.path);
49 | let clone = adb.clone();
50 | should.exist(clone.executable.path);
51 | adb.executable.path.should.equal(clone.executable.path);
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/test/functional/adb-emu-commands-e2e-specs.js:
--------------------------------------------------------------------------------
1 | import {ADB} from '../../lib/adb';
2 |
3 | describe('adb emu commands', function () {
4 | let adb;
5 | let chai;
6 |
7 | before(async function () {
8 | chai = await import('chai');
9 | const chaiAsPromised = await import('chai-as-promised');
10 |
11 | chai.should();
12 | chai.use(chaiAsPromised.default);
13 |
14 | if (process.env.REAL_DEVICE) {
15 | return this.skip();
16 | }
17 |
18 | adb = await ADB.createADB();
19 | const devices = await adb.getConnectedEmulators();
20 | adb.setDevice(devices[0]);
21 | });
22 |
23 | describe('execEmuConsoleCommand', function () {
24 | it('should print name', async function () {
25 | const name = await adb.execEmuConsoleCommand(['avd', 'name']);
26 | name.should.not.be.empty;
27 | });
28 |
29 | it('should fail if the command is unknown', async function () {
30 | await adb.execEmuConsoleCommand(['avd', 'namer']).should.eventually
31 | .be.rejected;
32 | });
33 | });
34 |
35 | describe('getEmuVersionInfo', function () {
36 | it('should get version info', async function () {
37 | const {revision, buildId} = await adb.getEmuVersionInfo();
38 | revision.should.not.be.empty;
39 | (buildId > 0).should.be.true;
40 | });
41 | });
42 |
43 | describe('getEmuImageProperties', function () {
44 | it('should get emulator image properties', async function () {
45 | if (process.env.CI) {
46 | return this.skip();
47 | }
48 |
49 | const name = await adb.execEmuConsoleCommand(['avd', 'name']);
50 | const {target} = await adb.getEmuImageProperties(name);
51 | const apiMatch = /\d+/.exec(target);
52 | (parseInt(apiMatch[0], 10) > 0).should.be.true;
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/test/functional/android-manifest-e2e-specs.js:
--------------------------------------------------------------------------------
1 | import {ADB} from '../../lib/adb';
2 | import path from 'path';
3 | import { fs } from '@appium/support';
4 | import { apiLevel } from './setup';
5 |
6 |
7 | // All paths below assume tests run under /build/test/ so paths are relative from
8 | // that directory.
9 | const contactManagerPath = apiLevel < 23
10 | ? path.resolve(__dirname, '..', 'fixtures', 'ContactManager-old.apk')
11 | : path.resolve(__dirname, '..', 'fixtures', 'ContactManager.apk');
12 | const contactMangerSelendroidPath = path.resolve(__dirname, '..', 'fixtures', 'ContactManager-selendroid.apk');
13 | const tmpDir = path.resolve(__dirname, '..', 'temp');
14 | const srcManifest = path.resolve(__dirname, '..', 'fixtures', 'selendroid', 'AndroidManifest.xml');
15 | const serverPath = path.resolve(__dirname, '..', 'fixtures', 'selendroid', 'selendroid.apk');
16 | const CONTACT_MANAGER_PKG = apiLevel < 23
17 | ? 'com.example.android.contactmanager'
18 | : 'com.saucelabs.ContactManager';
19 |
20 | describe('Android-manifest', function () {
21 | let adb;
22 | let chai;
23 |
24 | before(async function () {
25 | chai = await import('chai');
26 | const chaiAsPromised = await import('chai-as-promised');
27 |
28 | chai.should();
29 | chai.use(chaiAsPromised.default);
30 |
31 | adb = await ADB.createADB();
32 | });
33 | it('packageAndLaunchActivityFromManifest should parse package and Activity', async function () {
34 | let {apkPackage, apkActivity} = await adb.packageAndLaunchActivityFromManifest(contactManagerPath);
35 | apkPackage.should.equal(CONTACT_MANAGER_PKG);
36 | apkActivity.endsWith('.ContactManager').should.be.true;
37 | });
38 | it('hasInternetPermissionFromManifest should be true', async function () {
39 | let flag = await adb.hasInternetPermissionFromManifest(contactMangerSelendroidPath);
40 | flag.should.be.true;
41 | });
42 | it('hasInternetPermissionFromManifest should be false', async function () {
43 | let flag = await adb.hasInternetPermissionFromManifest(contactManagerPath);
44 | flag.should.be.false;
45 | });
46 | // TODO fix this test
47 | it.skip('should compile and insert manifest', async function () {
48 | let appPackage = CONTACT_MANAGER_PKG,
49 | newServerPath = path.resolve(tmpDir, `selendroid.${appPackage}.apk`),
50 | newPackage = 'com.example.android.contactmanager.selendroid',
51 | dstDir = path.resolve(tmpDir, appPackage),
52 | dstManifest = path.resolve(dstDir, 'AndroidManifest.xml');
53 | // deleting temp directory if present
54 | try {
55 | await fs.rimraf(tmpDir);
56 | } catch (e) {
57 | console.log(`Unable to delete temp directory. It might not be present. ${e.message}`); // eslint-disable-line no-console
58 | }
59 | await fs.mkdir(tmpDir);
60 | await fs.mkdir(dstDir);
61 | await fs.writeFile(dstManifest, await fs.readFile(srcManifest, 'utf8'), 'utf8');
62 | await adb.compileManifest(dstManifest, newPackage, appPackage);
63 | (await fs.fileExists(dstManifest)).should.be.true;
64 | await adb.insertManifest(dstManifest, serverPath, newServerPath);
65 | (await fs.fileExists(newServerPath)).should.be.true;
66 | // deleting temp directory
67 | try {
68 | await fs.rimraf(tmpDir);
69 | } catch (e) {
70 | console.log(`Unable to delete temp directory. It might not be present. ${e.message}`); // eslint-disable-line no-console
71 | }
72 | });
73 | });
74 |
--------------------------------------------------------------------------------
/test/functional/apk-signing-e2e-specs.js:
--------------------------------------------------------------------------------
1 | import {ADB} from '../../lib/adb';
2 | import path from 'path';
3 | import os from 'os';
4 | import { unsignApk } from '../../lib/tools/apk-signing';
5 | import { apiLevel } from './setup';
6 |
7 | const fixturesRoot = path.resolve(__dirname, '..', 'fixtures');
8 | const selendroidTestApp = path.resolve(fixturesRoot, 'selendroid-test-app.apk');
9 | const contactManagerPath = apiLevel < 23
10 | ? path.resolve(fixturesRoot, 'ContactManager-old.apk')
11 | : path.resolve(fixturesRoot, 'ContactManager.apk');
12 | const tmp = os.tmpdir();
13 | const keystorePath = path.resolve(fixturesRoot, 'appiumtest.keystore');
14 | const keyAlias = 'appiumtest';
15 | const CONTACT_MANAGER_APP_ID = apiLevel < 23
16 | ? 'com.example.android.contactmanager'
17 | : 'com.saucelabs.ContactManager';
18 |
19 | describe('Apk-signing', function () {
20 | let adb;
21 | let chai;
22 |
23 | before(async function () {
24 | chai = await import('chai');
25 | const chaiAsPromised = await import('chai-as-promised');
26 |
27 | chai.should();
28 | chai.use(chaiAsPromised.default);
29 |
30 | adb = await ADB.createADB();
31 | });
32 | it('checkApkCert should return false for unsigned apk', async function () {
33 | await unsignApk(selendroidTestApp);
34 | (await adb.checkApkCert(selendroidTestApp, 'io.selendroid.testapp')).should.be.false;
35 | });
36 | it('checkApkCert should return true for signed apk', async function () {
37 | (await adb.checkApkCert(contactManagerPath, CONTACT_MANAGER_APP_ID)).should.be.true;
38 | });
39 | it('signWithDefaultCert should sign apk', async function () {
40 | await unsignApk(selendroidTestApp);
41 | (await adb.signWithDefaultCert(selendroidTestApp));
42 | (await adb.checkApkCert(selendroidTestApp, 'io.selendroid.testapp')).should.be.true;
43 | });
44 | it('signWithCustomCert should sign apk with custom certificate', async function () {
45 | await unsignApk(selendroidTestApp);
46 | adb.keystorePath = keystorePath;
47 | adb.keyAlias = keyAlias;
48 | adb.useKeystore = true;
49 | adb.keystorePassword = 'android';
50 | adb.keyPassword = 'android';
51 | adb.tmpDir = tmp;
52 | (await adb.signWithCustomCert(selendroidTestApp));
53 | (await adb.checkApkCert(selendroidTestApp, 'io.selendroid.testapp')).should.be.true;
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/test/functional/apk-utils-e2e-specs.js:
--------------------------------------------------------------------------------
1 | import {ADB} from '../../lib/adb';
2 | import path from 'path';
3 | import { retryInterval } from 'asyncbox';
4 | import { MOCHA_TIMEOUT, MOCHA_LONG_TIMEOUT, apiLevel } from './setup';
5 |
6 | const START_APP_WAIT_DURATION = 60000;
7 | const START_APP_WAIT_DURATION_FAIL = process.env.CI ? 20000 : 10000;
8 | const CONTACT_MANAGER_APP_ID = apiLevel < 23
9 | ? 'com.example.android.contactmanager'
10 | : 'com.saucelabs.ContactManager';
11 | const CONTACT_MANAGER_ACTIVITY = apiLevel < 23
12 | ? 'ContactManager'
13 | : 'com.saucelabs.ContactManager.ContactManager';
14 |
15 | describe('apk utils', function () {
16 | this.timeout(MOCHA_TIMEOUT);
17 |
18 | let adb;
19 | let chai;
20 |
21 | const contactManagerPath = apiLevel < 23
22 | ? path.resolve(__dirname, '..', 'fixtures', 'ContactManager-old.apk')
23 | : path.resolve(__dirname, '..', 'fixtures', 'ContactManager.apk');
24 | const apiDemosPath = path.resolve(__dirname, '..', 'fixtures', 'ApiDemos-debug.apk');
25 | const deviceTempPath = '/data/local/tmp/';
26 | const assertPackageAndActivity = async () => {
27 | let {appPackage, appActivity} = await adb.getFocusedPackageAndActivity();
28 | appPackage.should.equal(CONTACT_MANAGER_APP_ID);
29 | appActivity.should.equal('.ContactManager');
30 | };
31 |
32 | before(async function () {
33 | chai = await import('chai');
34 | const chaiAsPromised = await import('chai-as-promised');
35 |
36 | chai.should();
37 | chai.use(chaiAsPromised.default);
38 |
39 | adb = await ADB.createADB({
40 | adbExecTimeout: process.env.CI ? 60000 : 40000,
41 | });
42 | });
43 | it('should be able to check status of third party app', async function () {
44 | (await adb.isAppInstalled('com.android.phone')).should.be.true;
45 | });
46 | it('should be able to install/remove app and detect its status', async function () {
47 | const apkNameOnDevice = apiLevel < 23
48 | ? 'ContactManager-old.apk'
49 | : 'ContactManager.apk';
50 | (await adb.isAppInstalled('foo')).should.be.false;
51 | await adb.install(contactManagerPath, {
52 | grantPermissions: true
53 | });
54 | (await adb.isAppInstalled(CONTACT_MANAGER_APP_ID)).should.be.true;
55 | (await adb.uninstallApk(CONTACT_MANAGER_APP_ID)).should.be.true;
56 | (await adb.isAppInstalled(CONTACT_MANAGER_APP_ID)).should.be.false;
57 | (await adb.uninstallApk(CONTACT_MANAGER_APP_ID)).should.be.false;
58 | await adb.rimraf(deviceTempPath + apkNameOnDevice);
59 | await adb.push(contactManagerPath, deviceTempPath);
60 | await adb.installFromDevicePath(deviceTempPath + apkNameOnDevice);
61 |
62 | // to ensure that the app is installed with grantPermissions.
63 | await adb.grantAllPermissions(CONTACT_MANAGER_APP_ID);
64 | });
65 | describe('startUri', function () {
66 | it('should be able to start a uri', async function () {
67 | if (apiLevel < 23 || apiLevel > 28) {
68 | return this.skip();
69 | }
70 | await adb.goToHome();
71 | let res = await adb.getFocusedPackageAndActivity();
72 | res.appPackage.should.not.equal('com.android.contacts');
73 | await adb.install(contactManagerPath, {
74 | grantPermissions: true,
75 | });
76 | await adb.startUri('content://contacts/people', 'com.android.contacts');
77 | await retryInterval(10, 500, async () => {
78 | res = await adb.dumpWindows();
79 | // depending on apilevel, app might show up as active in one of these
80 | // two dumpsys output formats
81 | let focusRe1 = '(mCurrentFocus.+\\.PeopleActivity)';
82 | let focusRe2 = '(mFocusedApp.+\\.PeopleActivity)';
83 | res.should.match(new RegExp(`${focusRe1}|${focusRe2}`));
84 | });
85 | await adb.goToHome();
86 | });
87 | });
88 | describe('startApp', function () {
89 | it('should be able to start with normal package and activity', async function () {
90 | await adb.install(contactManagerPath, {
91 | grantPermissions: true
92 | });
93 | await adb.startApp({
94 | pkg: CONTACT_MANAGER_APP_ID,
95 | activity: CONTACT_MANAGER_ACTIVITY,
96 | waitDuration: START_APP_WAIT_DURATION,
97 | });
98 | await retryInterval(10, 500, async () => {
99 | // It might be too fast to check the package and activity
100 | // because the started app could take a bit time
101 | // to come to the foreground in machine time.
102 | await assertPackageAndActivity();
103 | });
104 |
105 |
106 | });
107 | it('should be able to start with an intent and no activity', async function () {
108 | this.timeout(MOCHA_LONG_TIMEOUT);
109 | await adb.install(contactManagerPath, {
110 | grantPermissions: true
111 | });
112 | await adb.startApp({
113 | action: 'android.intent.action.WEB_SEARCH',
114 | pkg: 'com.google.android.googlequicksearchbox',
115 | optionalIntentArguments: '-e query foo',
116 | waitDuration: START_APP_WAIT_DURATION,
117 | stopApp: false
118 | });
119 | let {appPackage} = await adb.getFocusedPackageAndActivity();
120 | const expectedPkgPossibilities = [
121 | 'com.android.browser',
122 | 'org.chromium.webview_shell',
123 | 'com.google.android.googlequicksearchbox'
124 | ];
125 | expectedPkgPossibilities.should.include(appPackage);
126 | });
127 | it('should throw an error for unknown activity for intent', async function () {
128 | this.timeout(MOCHA_LONG_TIMEOUT);
129 | await adb.install(contactManagerPath, {
130 | grantPermissions: true
131 | });
132 | await adb.startApp({
133 | action: 'android.intent.action.DEFAULT',
134 | pkg: 'com.google.android.telephony',
135 | optionalIntentArguments: '-d tel:555-5555',
136 | waitDuration: START_APP_WAIT_DURATION,
137 | stopApp: false
138 | }).should.eventually.be.rejectedWith(/Cannot start the .* application/);
139 | });
140 | it('should throw error for wrong activity', async function () {
141 | await adb.install(contactManagerPath, {
142 | grantPermissions: true
143 | });
144 | await adb.startApp({
145 | pkg: CONTACT_MANAGER_APP_ID,
146 | activity: 'ContactManage',
147 | waitDuration: START_APP_WAIT_DURATION_FAIL,
148 | }).should.eventually.be.rejectedWith('Activity');
149 | });
150 | it('should throw error for wrong wait activity', async function () {
151 | await adb.install(contactManagerPath, {
152 | grantPermissions: true
153 | });
154 | await adb.startApp({
155 | pkg: CONTACT_MANAGER_APP_ID,
156 | activity: CONTACT_MANAGER_ACTIVITY,
157 | waitActivity: 'foo',
158 | waitDuration: START_APP_WAIT_DURATION_FAIL,
159 | }).should.eventually.be.rejectedWith('foo');
160 | });
161 | it('should start activity with wait activity', async function () {
162 | await adb.install(contactManagerPath, {
163 | grantPermissions: true
164 | });
165 | await adb.startApp({
166 | pkg: CONTACT_MANAGER_APP_ID,
167 | activity: CONTACT_MANAGER_ACTIVITY,
168 | waitActivity: '.ContactManager',
169 | waitDuration: START_APP_WAIT_DURATION,
170 | });
171 | await assertPackageAndActivity();
172 | });
173 | it('should start activity when wait activity is a wildcard', async function () {
174 | await adb.install(contactManagerPath, {
175 | grantPermissions: true
176 | });
177 | await adb.startApp({
178 | pkg: CONTACT_MANAGER_APP_ID,
179 | activity: CONTACT_MANAGER_ACTIVITY,
180 | waitActivity: '*',
181 | waitDuration: START_APP_WAIT_DURATION,
182 | });
183 | await assertPackageAndActivity();
184 | });
185 | it('should start activity when wait activity contains a wildcard', async function () {
186 | await adb.install(contactManagerPath, {
187 | grantPermissions: true
188 | });
189 | await adb.startApp({
190 | pkg: CONTACT_MANAGER_APP_ID,
191 | activity: CONTACT_MANAGER_ACTIVITY,
192 | waitActivity: '*.ContactManager',
193 | waitDuration: START_APP_WAIT_DURATION,
194 | });
195 | await assertPackageAndActivity();
196 | });
197 | it('should throw error for wrong activity when wait activity contains a wildcard', async function () {
198 | await adb.install(contactManagerPath, {
199 | grantPermissions: true
200 | });
201 | await adb.startApp({
202 | pkg: CONTACT_MANAGER_APP_ID,
203 | activity: 'SuperManager',
204 | waitActivity: '*.ContactManager',
205 | waitDuration: START_APP_WAIT_DURATION_FAIL,
206 | }).should.eventually.be.rejectedWith('Activity');
207 | });
208 | it('should throw error for wrong wait activity which contains wildcard', async function () {
209 | await adb.install(contactManagerPath, {
210 | grantPermissions: true
211 | });
212 | await adb.startApp({
213 | pkg: CONTACT_MANAGER_APP_ID,
214 | activity: CONTACT_MANAGER_ACTIVITY,
215 | waitActivity: '*.SuperManager',
216 | waitDuration: START_APP_WAIT_DURATION_FAIL,
217 | }).should.eventually.be.rejectedWith('SuperManager');
218 | });
219 | it('should start activity with comma separated wait packages list', async function () {
220 | await adb.install(contactManagerPath, {
221 | grantPermissions: true
222 | });
223 | await adb.startApp({
224 | pkg: CONTACT_MANAGER_APP_ID,
225 | waitPkg: `com.android.settings, ${CONTACT_MANAGER_APP_ID}`,
226 | activity: CONTACT_MANAGER_ACTIVITY,
227 | waitActivity: '.ContactManager',
228 | waitDuration: START_APP_WAIT_DURATION,
229 | });
230 | await assertPackageAndActivity();
231 | });
232 | it('should throw error for wrong activity when packages provided as comma separated list', async function () {
233 | await adb.install(contactManagerPath, {
234 | grantPermissions: true
235 | });
236 | await adb.startApp({
237 | pkg: CONTACT_MANAGER_APP_ID,
238 | waitPkg: 'com.android.settings, com.example.somethingelse',
239 | activity: 'SuperManager',
240 | waitActivity: '*.ContactManager',
241 | waitDuration: START_APP_WAIT_DURATION_FAIL,
242 | }).should.eventually.be.rejectedWith('Activity');
243 | });
244 | });
245 | it('should start activity when start activity is an inner class', async function () {
246 | await adb.install(contactManagerPath, {
247 | grantPermissions: true
248 | });
249 | await adb.startApp({
250 | pkg: 'com.android.settings',
251 | activity: '.Settings$NotificationAppListActivity',
252 | waitDuration: START_APP_WAIT_DURATION,
253 | });
254 | let {appPackage, appActivity} = await adb.getFocusedPackageAndActivity();
255 | appPackage.should.equal('com.android.settings');
256 |
257 | // The appActivity is different depending on the API level.
258 | if (await adb.getApiLevel() > 35) {
259 | appActivity.should.equal('.spa.SpaActivity');
260 | } else {
261 | appActivity.should.equal('.Settings$NotificationAppListActivity');
262 | };
263 | });
264 | it('getFocusedPackageAndActivity should be able get package and activity', async function () {
265 | await adb.install(contactManagerPath, {
266 | grantPermissions: true
267 | });
268 | await adb.startApp({
269 | pkg: CONTACT_MANAGER_APP_ID,
270 | activity: CONTACT_MANAGER_ACTIVITY,
271 | waitActivity: '.ContactManager',
272 | waitDuration: START_APP_WAIT_DURATION,
273 | });
274 | await assertPackageAndActivity();
275 | });
276 | it('extractStringsFromApk should get strings for default language', async function () {
277 | let {apkStrings} = await adb.extractStringsFromApk(contactManagerPath, null, '/tmp');
278 | apkStrings.save.should.equal('Save');
279 | });
280 | it('extractStringsFromApk should get strings for non-default language', async function () {
281 | let {apkStrings} = await adb.extractStringsFromApk(apiDemosPath, 'fr', '/tmp');
282 | apkStrings.linear_layout_8_horizontal.should.equal('Horizontal');
283 | });
284 | it('extractStringsFromApk should get strings for en language', async function () {
285 | let {apkStrings} = await adb.extractStringsFromApk(apiDemosPath, 'en', '/tmp');
286 | apkStrings.linear_layout_8_horizontal.should.equal('Horizontal');
287 | });
288 | describe('activateApp', function () {
289 | it('should be able to activate with normal package and activity', async function () {
290 | if (await adb.getApiLevel() < 23) {
291 | return this.skip();
292 | }
293 |
294 | await adb.install(contactManagerPath, {
295 | grantPermissions: true
296 | });
297 | await adb.startApp({
298 | pkg: CONTACT_MANAGER_APP_ID,
299 | activity: CONTACT_MANAGER_ACTIVITY,
300 | waitDuration: START_APP_WAIT_DURATION,
301 | });
302 | await retryInterval(10, 500, async () => {
303 | await adb.goToHome();
304 | const {appPackage} = await adb.getFocusedPackageAndActivity();
305 | appPackage.should.not.eql(CONTACT_MANAGER_APP_ID);
306 | });
307 | await retryInterval(10, 500, async () => {
308 | await adb.activateApp(CONTACT_MANAGER_APP_ID);
309 | const {appPackage} = await adb.getFocusedPackageAndActivity();
310 | appPackage.should.eql(CONTACT_MANAGER_APP_ID);
311 | });
312 | });
313 | });
314 | });
315 |
--------------------------------------------------------------------------------
/test/functional/helpers-specs-e2e-specs.js:
--------------------------------------------------------------------------------
1 | import {
2 | requireSdkRoot,
3 | readPackageManifest,
4 | } from '../../lib/helpers.js';
5 | import {ADB} from '../../lib/adb';
6 | import path from 'node:path';
7 | import { getAndroidPlatformAndPath } from '../../lib/tools/android-manifest';
8 |
9 | describe('Helpers', function () {
10 | let chai;
11 |
12 | before(async function () {
13 | chai = await import('chai');
14 | const chaiAsPromised = await import('chai-as-promised');
15 |
16 | chai.should();
17 | chai.use(chaiAsPromised.default);
18 | });
19 |
20 | it('getAndroidPlatformAndPath should return platform and path for android', async function () {
21 | const sdkRoot = await requireSdkRoot();
22 | const {platform, platformPath} = await getAndroidPlatformAndPath(sdkRoot);
23 | platform.should.exist;
24 | platformPath.should.exist;
25 | });
26 |
27 | it('should read package manifest', async function () {
28 | const expected = {
29 | name: 'io.appium.android.apis',
30 | versionCode: 24,
31 | minSdkVersion: 17,
32 | compileSdkVersion: 31,
33 | usesPermissions: [
34 | 'android.permission.READ_CONTACTS',
35 | 'android.permission.WRITE_CONTACTS',
36 | 'android.permission.VIBRATE',
37 | 'android.permission.ACCESS_COARSE_LOCATION',
38 | 'android.permission.INTERNET',
39 | 'android.permission.SET_WALLPAPER',
40 | 'android.permission.WRITE_EXTERNAL_STORAGE',
41 | 'android.permission.SEND_SMS',
42 | 'android.permission.RECEIVE_SMS',
43 | 'android.permission.NFC',
44 | 'android.permission.RECORD_AUDIO',
45 | 'android.permission.CAMERA',
46 | 'android.permission.READ_EXTERNAL_STORAGE'
47 | ],
48 | launchableActivity: {
49 | 'name': 'io.appium.android.apis.ApiDemos',
50 | },
51 | architectures: [],
52 | locales: [
53 | '--_--',
54 | 'af',
55 | 'am',
56 | 'ar',
57 | 'as',
58 | 'az',
59 | 'be',
60 | 'bg',
61 | 'bn',
62 | 'bs',
63 | 'ca',
64 | 'cs',
65 | 'da',
66 | 'de',
67 | 'el',
68 | 'en-AU',
69 | 'en-CA',
70 | 'en-GB',
71 | 'en-IN',
72 | 'en-XC',
73 | 'es',
74 | 'es-US',
75 | 'et',
76 | 'eu',
77 | 'fa',
78 | 'fi',
79 | 'fr',
80 | 'fr-CA',
81 | 'gl',
82 | 'gu',
83 | 'hi',
84 | 'hr',
85 | 'hu',
86 | 'hy',
87 | 'in',
88 | 'is',
89 | 'it',
90 | 'iw',
91 | 'ja',
92 | 'ka',
93 | 'kk',
94 | 'km',
95 | 'kn',
96 | 'ko',
97 | 'ky',
98 | 'lo',
99 | 'lt',
100 | 'lv',
101 | 'mk',
102 | 'ml',
103 | 'mn',
104 | 'mr',
105 | 'ms',
106 | 'my',
107 | 'nb',
108 | 'ne',
109 | 'nl',
110 | 'or',
111 | 'pa',
112 | 'pl',
113 | 'pt',
114 | 'pt-BR',
115 | 'pt-PT',
116 | 'ro',
117 | 'ru',
118 | 'si',
119 | 'sk',
120 | 'sl',
121 | 'sq',
122 | 'sr',
123 | 'sr-Latn',
124 | 'sv',
125 | 'sw',
126 | 'ta',
127 | 'te',
128 | 'th',
129 | 'tl',
130 | 'tr',
131 | 'uk',
132 | 'ur',
133 | 'uz',
134 | 'vi',
135 | 'zh-CN',
136 | 'zh-HK',
137 | 'zh-TW',
138 | 'zu'
139 | ],
140 | densities: [
141 | 120,
142 | 160,
143 | 240,
144 | 320,
145 | 480,
146 | 640,
147 | 65535
148 | ],
149 | versionName: '4.1.1',
150 | platformBuildVersionName: '12',
151 | platformBuildVersionCode: 31,
152 | compileSdkVersionCodename: '12',
153 | targetSdkVersion: 31,
154 | };
155 |
156 | const adb = await ADB.createADB();
157 | const apiDemosPath = path.resolve(__dirname, '..', 'fixtures', 'ApiDemos-debug.apk');
158 | const manifest = await readPackageManifest.bind(adb)(apiDemosPath);
159 | expected.should.eql(manifest);
160 | });
161 | });
162 |
--------------------------------------------------------------------------------
/test/functional/lock-mgmt-e2e-specs.js:
--------------------------------------------------------------------------------
1 | import {ADB} from '../../lib/adb';
2 |
3 | describe('Lock Management', function () {
4 | let adb;
5 | let chai;
6 |
7 | before(async function () {
8 | chai = await import('chai');
9 | const chaiAsPromised = await import('chai-as-promised');
10 |
11 | chai.should();
12 | chai.use(chaiAsPromised.default);
13 |
14 | adb = await ADB.createADB();
15 | if (!await adb.isLockManagementSupported()) {
16 | return this.skip();
17 | }
18 | });
19 |
20 | it('lock credential cleanup should work', async function () {
21 | await adb.clearLockCredential();
22 | await adb.verifyLockCredential().should.eventually.be.true;
23 | await adb.isLockEnabled().should.eventually.be.false;
24 | });
25 |
26 | describe('Lock and unlock life cycle', function () {
27 | const password = '1234';
28 |
29 | before(function () {
30 | if (process.env.CI) {
31 | // We don't want to lock the device for all other tests if this test fails
32 | return this.skip();
33 | }
34 | });
35 | afterEach(async function () {
36 | await adb.clearLockCredential(password);
37 | });
38 |
39 | it('device lock and unlock scenario should work', async function () {
40 | await adb.setLockCredential('password', password);
41 | await adb.keyevent(26);
42 | await adb.isLockEnabled().should.eventually.be.true;
43 | await adb.isScreenLocked().should.eventually.be.true;
44 | await adb.clearLockCredential(password);
45 | await adb.cycleWakeUp();
46 | await adb.dismissKeyguard();
47 | await adb.isLockEnabled().should.eventually.be.false;
48 | await adb.isScreenLocked().should.eventually.be.false;
49 | });
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/test/functional/logcat-e2e-specs.js:
--------------------------------------------------------------------------------
1 | import {ADB} from '../../lib/adb';
2 | import { Logcat } from '../../lib/logcat';
3 | import { MOCHA_TIMEOUT } from './setup';
4 |
5 | describe('logcat', function () {
6 | this.timeout(MOCHA_TIMEOUT);
7 |
8 | async function runClearDeviceLogTest (adb, logcat, clear = true) {
9 | let logs = await adb.adbExec(['logcat', '-d']);
10 | await logcat.startCapture();
11 | await logcat.stopCapture();
12 | let newLogs = await adb.adbExec(['logcat', '-d']);
13 | if (clear) {
14 | newLogs.should.not.include(logs);
15 | } else {
16 | newLogs.should.include(logs);
17 | }
18 | }
19 |
20 | let adb;
21 | let logcat;
22 | let chai;
23 |
24 | before(async function () {
25 | chai = await import('chai');
26 | const chaiAsPromised = await import('chai-as-promised');
27 |
28 | chai.should();
29 | chai.use(chaiAsPromised.default);
30 |
31 | adb = await ADB.createADB();
32 | });
33 | afterEach(async function () {
34 | if (logcat) {
35 | await logcat.stopCapture();
36 | }
37 | });
38 | describe('clearDeviceLogsOnStart = false', function () {
39 | before(function () {
40 | logcat = new Logcat({
41 | adb: adb.executable,
42 | debug: false,
43 | debugTrace: false,
44 | });
45 | });
46 | it('getLogs should return logs', async function () {
47 | await logcat.startCapture();
48 | let logs = logcat.getLogs();
49 | logs.should.have.length.above(0);
50 | });
51 | it('getAllLogs should return all logs', async function () {
52 | await logcat.startCapture();
53 | let logs = logcat.getAllLogs();
54 | logs.should.have.length.above(0);
55 | });
56 | it('should not affect device logs', async function () {
57 | if (process.env.CI) {
58 | return this.skip();
59 | }
60 | await runClearDeviceLogTest(adb, logcat, false);
61 | });
62 | });
63 | describe('clearDeviceLogsOnStart = true', function () {
64 | before(function () {
65 | logcat = new Logcat({
66 | adb: adb.executable,
67 | debug: false,
68 | debugTrace: false,
69 | clearDeviceLogsOnStart: true,
70 | });
71 | });
72 | it('should clear the logs before starting capture', async function () {
73 | await runClearDeviceLogTest(adb, logcat, true);
74 | });
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/test/functional/setup.js:
--------------------------------------------------------------------------------
1 | // https://developer.android.com/guide/topics/manifest/uses-sdk-element.html
2 | const API_LEVEL_MAP = {
3 | 4.1: '16',
4 | 4.2: '17',
5 | 4.3: '18',
6 | 4.4: '19',
7 | 5: '21',
8 | 5.1: '22',
9 | 6: '23',
10 | 7: '24',
11 | 7.1: '25',
12 | 8.0: '26',
13 | 8.1: '27',
14 | 9: '28',
15 | 10: '29',
16 | 11: '30',
17 | 12: '32', // and 31
18 | 13: '33',
19 | 14: '34',
20 | 15: '35',
21 | 16: '36',
22 | };
23 |
24 | const avdName = process.env.ANDROID_AVD || 'NEXUS_S_18_X86';
25 | const platformVersion = process.env.PLATFORM_VERSION || 4.3;
26 |
27 | const apiLevel = parseInt(process.env.ANDROID_SDK_VERSION
28 | || process.env.API_LEVEL
29 | || API_LEVEL_MAP[platformVersion], 10);
30 |
31 | const MOCHA_TIMEOUT = process.env.CI ? 240000 : 60000;
32 | const MOCHA_LONG_TIMEOUT = MOCHA_TIMEOUT * 10;
33 |
34 | export { apiLevel, platformVersion, avdName, MOCHA_TIMEOUT, MOCHA_LONG_TIMEOUT };
35 |
--------------------------------------------------------------------------------
/test/functional/syscalls-e2e-specs.js:
--------------------------------------------------------------------------------
1 | import {ADB} from '../../lib/adb';
2 | import { apiLevel, avdName, MOCHA_TIMEOUT, MOCHA_LONG_TIMEOUT } from './setup';
3 | import path from 'path';
4 | import { getResourcePath } from '../../lib/helpers.js';
5 | import { fs } from '@appium/support';
6 | import _ from 'lodash';
7 |
8 | const DEFAULT_CERTIFICATE = path.join('keys', 'testkey.x509.pem');
9 |
10 | describe('System calls', function () {
11 | this.timeout(MOCHA_TIMEOUT);
12 |
13 | let adb;
14 | let chai;
15 |
16 | before(async function () {
17 | chai = await import('chai');
18 | const chaiAsPromised = await import('chai-as-promised');
19 |
20 | chai.should();
21 | chai.use(chaiAsPromised.default);
22 |
23 | adb = await ADB.createADB();
24 | });
25 | it('getConnectedDevices should get devices', async function () {
26 | let devices = await adb.getConnectedDevices();
27 | devices.should.have.length.above(0);
28 | });
29 | it('getDevicesWithRetry should get devices', async function () {
30 | let devices = await adb.getDevicesWithRetry();
31 | devices.should.have.length.above(0);
32 | });
33 | it('adbExec should get devices when with devices', async function () {
34 | (await adb.adbExec('devices')).should.contain('List of devices attached');
35 | });
36 | it('isDeviceConnected should be true', async function () {
37 | (await adb.isDeviceConnected()).should.be.true;
38 | });
39 | it('shell should execute command in adb shell ', async function () {
40 | (await adb.shell(['getprop', 'ro.build.version.sdk'])).should.equal(`${apiLevel}`);
41 | });
42 | it('shell should return stderr from adb with full output', async function () {
43 | const minStderrApiLevel = 24;
44 | let fullShellOutput = await adb.shell(['content', 'read', '--uri', 'content://doesnotexist'], {outputFormat: adb.EXEC_OUTPUT_FORMAT.FULL});
45 | let outputWithError = apiLevel < minStderrApiLevel ? fullShellOutput.stdout : fullShellOutput.stderr;
46 | outputWithError.should.contain('Error while accessing provider');
47 | });
48 | it('shell should return stdout from adb shell with full output', async function () {
49 | let fullShellOutput = await adb.shell(['getprop', 'ro.build.version.sdk'], {outputFormat: adb.EXEC_OUTPUT_FORMAT.FULL});
50 | fullShellOutput.stderr.should.equal('');
51 | fullShellOutput.stdout.should.equal(`${apiLevel}`);
52 | });
53 | it('getConnectedEmulators should get all connected emulators', async function () {
54 | (await adb.getConnectedEmulators()).length.should.be.above(0);
55 | });
56 | it('getRunningAVD should get all connected avd', async function () {
57 | (await adb.getRunningAVD(avdName)).should.not.be.null;
58 | });
59 | it('getRunningAVDWithRetry should get all connected avds', async function () {
60 | (await adb.getRunningAVDWithRetry(avdName)).should.not.be.null;
61 | });
62 | // Skipping for now. Will unskip depending on how it behaves on CI
63 | it.skip('launchAVD should get all connected avds', async function () {
64 | this.timeout(MOCHA_LONG_TIMEOUT);
65 | let proc = await adb.launchAVD(avdName);
66 | (await adb.getConnectedEmulators()).length.should.be.above(0);
67 | proc.stop();
68 | });
69 | it('waitForDevice should get all connected avds', async function () {
70 | await adb.waitForDevice(2);
71 | });
72 | it('reboot should reboot the device', async function () {
73 | if (process.env.CI) {
74 | // The test makes CI unstable
75 | return this.skip();
76 | }
77 | this.timeout(MOCHA_LONG_TIMEOUT);
78 | try {
79 | await adb.reboot();
80 | await adb.ping();
81 | } catch (e) {
82 | e.message.should.include('must be root');
83 | }
84 | });
85 | it('fileExists should detect when files do and do not exist', async function () {
86 | (await adb.fileExists('/foo/bar/baz.zip')).should.be.false;
87 | (await adb.fileExists('/data/local/tmp')).should.be.true;
88 | });
89 | it('ls should list files', async function () {
90 | (await adb.ls('/foo/bar')).should.eql([]);
91 | await adb.shell(['touch', '/data/local/tmp/test']);
92 | (await adb.ls('/data/local/tmp')).should.contain('test');
93 | });
94 | it('should check if the given certificate is already installed', async function () {
95 | const certBuffer = await fs.readFile(await getResourcePath(DEFAULT_CERTIFICATE));
96 | (await adb.isMitmCertificateInstalled(certBuffer)).should.be.false;
97 | });
98 | it('should return version', async function () {
99 | const {binary, bridge} = await adb.getVersion();
100 | if (binary) {
101 | _.has(binary, 'version').should.be.true;
102 | _.has(binary, 'build').should.be.true;
103 | }
104 | _.has(bridge, 'version').should.be.true;
105 | });
106 | });
107 |
--------------------------------------------------------------------------------
/test/unit/adb-emu-commands-specs.js:
--------------------------------------------------------------------------------
1 | import {ADB} from '../../lib/adb';
2 | import { withMocks } from '@appium/test-support';
3 | import _ from 'lodash';
4 |
5 | const emulators = [
6 | { udid: 'emulator-5554', state: 'device', port: 5554 },
7 | { udid: 'emulator-5556', state: 'device', port: 5556 },
8 | ];
9 | const fingerprintId = 1111;
10 |
11 | const adb = new ADB();
12 |
13 | describe('adb emulator commands', withMocks({adb}, function (mocks) {
14 | let chai;
15 |
16 | before(async function () {
17 | chai = await import('chai');
18 | const chaiAsPromised = await import('chai-as-promised');
19 |
20 | chai.should();
21 | chai.use(chaiAsPromised.default);
22 | });
23 |
24 | afterEach(function () {
25 | mocks.verify();
26 | });
27 |
28 | describe('emu', function () {
29 | describe('isEmulatorConnected', function () {
30 | it('should verify emulators state', async function () {
31 | mocks.adb.expects('getConnectedEmulators')
32 | .atLeast(3)
33 | .returns(emulators);
34 | adb.curDeviceId = 'emulator-5554';
35 | (await adb.isEmulatorConnected()).should.equal(true);
36 | adb.curDeviceId = 'emulator-5556';
37 | (await adb.isEmulatorConnected()).should.equal(true);
38 | adb.curDeviceId = 'emulator-5558';
39 | (await adb.isEmulatorConnected()).should.equal(false);
40 | });
41 | });
42 | describe('verifyEmulatorConnected', function () {
43 | it('should throw an exception on emulator not connected', async function () {
44 | adb.curDeviceId = 'emulator-5558';
45 | mocks.adb.expects('isEmulatorConnected')
46 | .once()
47 | .returns(false);
48 | await adb.verifyEmulatorConnected().should.eventually.be.rejected;
49 | });
50 | });
51 | describe('fingerprint', function () {
52 | it('should throw exception on undefined fingerprintId', async function () {
53 | await adb.fingerprint().should.eventually.be.rejected;
54 | });
55 | it('should throw exception on apiLevel lower than 23', async function () {
56 | mocks.adb.expects('getApiLevel')
57 | .once().withExactArgs()
58 | .returns(21);
59 | await adb.fingerprint(fingerprintId).should.eventually.be.rejected;
60 | });
61 | it('should call adbExec with the correct args', async function () {
62 | mocks.adb.expects('getApiLevel')
63 | .once().withExactArgs()
64 | .returns(23);
65 | mocks.adb.expects('isEmulatorConnected')
66 | .once().withExactArgs()
67 | .returns(true);
68 | mocks.adb.expects('resetTelnetAuthToken')
69 | .once().withExactArgs()
70 | .returns();
71 | mocks.adb.expects('adbExec')
72 | .once().withExactArgs(['emu', 'finger', 'touch', fingerprintId])
73 | .returns();
74 | await adb.fingerprint(fingerprintId);
75 | });
76 | });
77 | describe('rotate', function () {
78 | it('should call adbExec with the correct args', async function () {
79 | mocks.adb.expects('isEmulatorConnected')
80 | .once().withExactArgs()
81 | .returns(true);
82 | mocks.adb.expects('resetTelnetAuthToken')
83 | .once().withExactArgs()
84 | .returns();
85 | mocks.adb.expects('adbExec')
86 | .once().withExactArgs(['emu', 'rotate'])
87 | .returns();
88 | await adb.rotate();
89 | });
90 | });
91 | describe('power methods', function () {
92 | it('should throw exception on invalid power ac state', async function () {
93 | await adb.powerAC('dead').should.eventually.be.rejectedWith('Wrong power AC state');
94 | });
95 | it('should set the power ac off', async function () {
96 | mocks.adb.expects('isEmulatorConnected')
97 | .once().withExactArgs()
98 | .returns(true);
99 | mocks.adb.expects('resetTelnetAuthToken')
100 | .once().withExactArgs()
101 | .returns();
102 | mocks.adb.expects('adbExec')
103 | .once().withExactArgs(['emu', 'power', 'ac', adb.POWER_AC_STATES.POWER_AC_OFF])
104 | .returns();
105 | await adb.powerAC('off');
106 | });
107 | it('should set the power ac on', async function () {
108 | mocks.adb.expects('isEmulatorConnected')
109 | .once().withExactArgs()
110 | .returns(true);
111 | mocks.adb.expects('resetTelnetAuthToken')
112 | .once().withExactArgs()
113 | .returns();
114 | mocks.adb.expects('adbExec')
115 | .once().withExactArgs(['emu', 'power', 'ac', adb.POWER_AC_STATES.POWER_AC_ON])
116 | .returns();
117 | await adb.powerAC('on');
118 | });
119 | it('should throw exception on invalid power battery percent', async function () {
120 | await adb.powerCapacity(-1).should.eventually.be.rejectedWith('should be valid integer between 0 and 100');
121 | await adb.powerCapacity('a').should.eventually.be.rejectedWith('should be valid integer between 0 and 100');
122 | await adb.powerCapacity(500).should.eventually.be.rejectedWith('should be valid integer between 0 and 100');
123 | });
124 | it('should set the power capacity', async function () {
125 | mocks.adb.expects('isEmulatorConnected')
126 | .once().withExactArgs()
127 | .returns(true);
128 | mocks.adb.expects('resetTelnetAuthToken')
129 | .once().withExactArgs()
130 | .returns();
131 | mocks.adb.expects('adbExec')
132 | .once().withExactArgs(['emu', 'power', 'capacity', '0'])
133 | .returns();
134 | await adb.powerCapacity(0);
135 | });
136 | it('should call methods to power off the emulator', async function () {
137 | mocks.adb.expects('powerAC')
138 | .once().withExactArgs('off')
139 | .returns();
140 | mocks.adb.expects('powerCapacity')
141 | .once().withExactArgs(0)
142 | .returns();
143 | await adb.powerOFF();
144 | });
145 | });
146 | describe('sendSMS', function () {
147 | it('should throw exception on invalid message', async function () {
148 | await adb.sendSMS('+549341312345678').should.be.rejected;
149 | });
150 | it('should throw exception on invalid phoneNumber', async function () {
151 | await adb.sendSMS('', 'Hello Appium').should.be.rejected;
152 | });
153 | it('should call adbExec with the correct args', async function () {
154 | let phoneNumber = 4509;
155 | let message = ' Hello Appium ';
156 | mocks.adb.expects('isEmulatorConnected')
157 | .once().withExactArgs()
158 | .returns(true);
159 | mocks.adb.expects('resetTelnetAuthToken')
160 | .once().withExactArgs()
161 | .returns();
162 | mocks.adb.expects('adbExec')
163 | .once().withExactArgs(['emu', 'sms', 'send', `${phoneNumber}`, message])
164 | .returns();
165 | await adb.sendSMS(phoneNumber, message);
166 | });
167 | });
168 | describe('gsm signal method', function () {
169 | it('should throw exception on invalid strength', async function () {
170 | await adb.gsmSignal(5).should.eventually.be.rejectedWith('Invalid signal strength');
171 | });
172 | it('should call adbExecEmu with the correct args', async function () {
173 | let signalStrength = 0;
174 | mocks.adb.expects('isEmulatorConnected')
175 | .once().withExactArgs()
176 | .returns(true);
177 | mocks.adb.expects('resetTelnetAuthToken')
178 | .once().withExactArgs()
179 | .returns();
180 | mocks.adb.expects('adbExec')
181 | .once().withExactArgs(['emu', 'gsm', 'signal-profile', `${signalStrength}`])
182 | .returns();
183 | await adb.gsmSignal(signalStrength);
184 | });
185 | });
186 | describe('gsm call methods', function () {
187 | it('should throw exception on invalid action', async function () {
188 | await adb.gsmCall('+549341312345678').should.be.rejected;
189 | });
190 | it('should throw exception on invalid phoneNumber', async function () {
191 | await adb.gsmCall('', 'call').should.be.rejected;
192 | });
193 | it('should set the correct method for making gsm call', async function () {
194 | let phoneNumber = 4509;
195 | mocks.adb.expects('isEmulatorConnected')
196 | .once().withExactArgs()
197 | .returns(true);
198 | mocks.adb.expects('resetTelnetAuthToken')
199 | .once().withExactArgs()
200 | .returns();
201 | mocks.adb.expects('adbExec')
202 | .once().withExactArgs(['emu', 'gsm', adb.GSM_CALL_ACTIONS.GSM_CALL, `${phoneNumber}`])
203 | .returns();
204 | await adb.gsmCall(phoneNumber, 'call');
205 | });
206 | it('should set the correct method for accepting gsm call', async function () {
207 | let phoneNumber = 4509;
208 | mocks.adb.expects('isEmulatorConnected')
209 | .once().withExactArgs()
210 | .returns(true);
211 | mocks.adb.expects('resetTelnetAuthToken')
212 | .once().withExactArgs()
213 | .returns();
214 | mocks.adb.expects('adbExec')
215 | .once().withExactArgs(['emu', 'gsm', adb.GSM_CALL_ACTIONS.GSM_ACCEPT, `${phoneNumber}`])
216 | .returns();
217 | await adb.gsmCall(phoneNumber, 'accept');
218 | });
219 | it('should set the correct method for refusing gsm call', async function () {
220 | let phoneNumber = 4509;
221 | mocks.adb.expects('isEmulatorConnected')
222 | .once().withExactArgs()
223 | .returns(true);
224 | mocks.adb.expects('resetTelnetAuthToken')
225 | .once().withExactArgs()
226 | .returns();
227 | mocks.adb.expects('adbExec')
228 | .once().withExactArgs(['emu', 'gsm', adb.GSM_CALL_ACTIONS.GSM_CANCEL, `${phoneNumber}`])
229 | .returns();
230 | await adb.gsmCall(phoneNumber, 'cancel');
231 | });
232 | it('should set the correct method for holding gsm call', async function () {
233 | let phoneNumber = 4509;
234 | mocks.adb.expects('isEmulatorConnected')
235 | .once().withExactArgs()
236 | .returns(true);
237 | mocks.adb.expects('resetTelnetAuthToken')
238 | .once().withExactArgs()
239 | .returns();
240 | mocks.adb.expects('adbExec')
241 | .once().withExactArgs(['emu', 'gsm', adb.GSM_CALL_ACTIONS.GSM_HOLD, `${phoneNumber}`])
242 | .returns();
243 | await adb.gsmCall(phoneNumber, 'hold');
244 | });
245 | });
246 | describe('network speed method', function () {
247 | it('should throw exception on invalid speed', async function () {
248 | await adb.networkSpeed('light').should.eventually.be.rejectedWith('Invalid network speed');
249 | });
250 | for (let [key, value] of _.toPairs(adb.NETWORK_SPEED)) {
251 | it(`should set network speed(${key}) correctly`, async function () {
252 | mocks.adb.expects('isEmulatorConnected')
253 | .once().withExactArgs()
254 | .returns(true);
255 | mocks.adb.expects('resetTelnetAuthToken')
256 | .once().withExactArgs()
257 | .returns();
258 | mocks.adb.expects('adbExec')
259 | .once().withExactArgs(['emu', 'network', 'speed', value])
260 | .returns();
261 | await adb.networkSpeed(value);
262 | });
263 | }
264 | });
265 | describe('gsm voice method', function () {
266 | it('should throw exception on invalid strength', async function () {
267 | await adb.gsmVoice('weird').should.eventually.be.rejectedWith('Invalid gsm voice state');
268 | });
269 | it('should set gsm voice to unregistered', async function () {
270 | mocks.adb.expects('isEmulatorConnected')
271 | .once().withExactArgs()
272 | .returns(true);
273 | mocks.adb.expects('resetTelnetAuthToken')
274 | .once().withExactArgs()
275 | .returns();
276 | mocks.adb.expects('adbExec')
277 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_UNREGISTERED])
278 | .returns();
279 | await adb.gsmVoice('unregistered');
280 | });
281 | it('should set gsm voice to home', async function () {
282 | mocks.adb.expects('isEmulatorConnected')
283 | .once().withExactArgs()
284 | .returns(true);
285 | mocks.adb.expects('resetTelnetAuthToken')
286 | .once().withExactArgs()
287 | .returns();
288 | mocks.adb.expects('adbExec')
289 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_HOME])
290 | .returns();
291 | await adb.gsmVoice('home');
292 | });
293 | it('should set gsm voice to roaming', async function () {
294 | mocks.adb.expects('isEmulatorConnected')
295 | .once().withExactArgs()
296 | .returns(true);
297 | mocks.adb.expects('resetTelnetAuthToken')
298 | .once().withExactArgs()
299 | .returns();
300 | mocks.adb.expects('adbExec')
301 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_ROAMING])
302 | .returns();
303 | await adb.gsmVoice('roaming');
304 | });
305 | it('should set gsm voice to searching', async function () {
306 | mocks.adb.expects('isEmulatorConnected')
307 | .once().withExactArgs()
308 | .returns(true);
309 | mocks.adb.expects('resetTelnetAuthToken')
310 | .once().withExactArgs()
311 | .returns();
312 | mocks.adb.expects('adbExec')
313 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_SEARCHING])
314 | .returns();
315 | await adb.gsmVoice('searching');
316 | });
317 | it('should set gsm voice to denied', async function () {
318 | mocks.adb.expects('isEmulatorConnected')
319 | .once().withExactArgs()
320 | .returns(true);
321 | mocks.adb.expects('resetTelnetAuthToken')
322 | .once().withExactArgs()
323 | .returns();
324 | mocks.adb.expects('adbExec')
325 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_DENIED])
326 | .returns();
327 | await adb.gsmVoice('denied');
328 | });
329 | it('should set gsm voice to off', async function () {
330 | mocks.adb.expects('isEmulatorConnected')
331 | .once().withExactArgs()
332 | .returns(true);
333 | mocks.adb.expects('resetTelnetAuthToken')
334 | .once().withExactArgs()
335 | .returns();
336 | mocks.adb.expects('adbExec')
337 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_OFF])
338 | .returns();
339 | await adb.gsmVoice('off');
340 | });
341 | it('should set gsm voice to on', async function () {
342 | mocks.adb.expects('isEmulatorConnected')
343 | .once().withExactArgs()
344 | .returns(true);
345 | mocks.adb.expects('resetTelnetAuthToken')
346 | .once().withExactArgs()
347 | .returns();
348 | mocks.adb.expects('adbExec')
349 | .once().withExactArgs(['emu', 'gsm', 'voice', adb.GSM_VOICE_STATES.GSM_VOICE_ON])
350 | .returns();
351 | await adb.gsmVoice('on');
352 | });
353 | });
354 | describe('sensorSet method', function () {
355 | it('should throw exception on missing sensor name', async function () {
356 | await adb.sensorSet('sensor').should.eventually.be.rejected;
357 | });
358 | it('should throw exception on missing sensor value', async function () {
359 | await adb.sensorSet('light').should.eventually.be.rejected;
360 | });
361 | it('should call adb emu sensor set with the correct values', async function () {
362 | mocks.adb.expects('isEmulatorConnected')
363 | .once().withExactArgs()
364 | .returns(true);
365 | mocks.adb.expects('resetTelnetAuthToken')
366 | .once().withExactArgs()
367 | .returns();
368 | mocks.adb.expects('adbExec')
369 | .once().withExactArgs(['emu', 'sensor', 'set', 'humidity', `${100}`])
370 | .returns();
371 | await adb.sensorSet('humidity', 100);
372 | });
373 | });
374 | });
375 | }));
376 |
--------------------------------------------------------------------------------
/test/unit/adb-specs.js:
--------------------------------------------------------------------------------
1 | import { ADB, DEFAULT_ADB_PORT } from '../../lib/adb';
2 |
3 |
4 | describe('ADB', function () {
5 | let chai;
6 |
7 | before(async function () {
8 | chai = await import('chai');
9 | const chaiAsPromised = await import('chai-as-promised');
10 |
11 | chai.should();
12 | chai.use(chaiAsPromised.default);
13 | });
14 |
15 | describe('clone', function () {
16 | it('should copy all options', function () {
17 | const original = new ADB({
18 | executable: {path: 'var/adb', defaultArgs: ['-a']},
19 | });
20 | const clone = original.clone();
21 |
22 | clone.executable.path.should.equal(original.executable.path);
23 | clone.executable.defaultArgs.should.deep.equal(original.executable.defaultArgs);
24 | });
25 |
26 | it('should replace specified options', function () {
27 | const original = new ADB({
28 | executable: {path: 'adb', defaultArgs: ['-a']},
29 | });
30 | const clone = original.clone({
31 | remoteAdbHost: 'example.com',
32 | });
33 |
34 | clone.executable.path.should.equal(original.executable.path);
35 | clone.executable.defaultArgs.should.deep.equal(['-a', '-H', 'example.com', '-P', String(DEFAULT_ADB_PORT)]);
36 | clone.remoteAdbHost.should.equal('example.com');
37 | clone.adbHost.should.not.equal(original.adbHost);
38 | });
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/test/unit/apk-signing-specs.js:
--------------------------------------------------------------------------------
1 | import {ADB} from '../../lib/adb';
2 | import * as helpers from '../../lib/helpers.js';
3 | import path from 'path';
4 | import * as teen_process from 'teen_process';
5 | import * as appiumSupport from '@appium/support';
6 | import { withMocks } from '@appium/test-support';
7 | import * as apkSigningHelpers from '../../lib/tools/apk-signing';
8 |
9 | const selendroidTestApp = path.resolve(__dirname, '..', 'fixtures', 'selendroid-test-app.apk');
10 | const keystorePath = path.resolve(__dirname, '..', 'fixtures', 'appiumtest.keystore');
11 | const defaultKeyPath = path.resolve(__dirname, '..', '..', 'keys', 'testkey.pk8');
12 | const defaultCertPath = path.resolve(__dirname, '..', '..', 'keys', 'testkey.x509.pem');
13 | const keyAlias = 'appiumtest';
14 | const password = 'android';
15 | const selendroidTestAppPackage = 'io.selendroid.testapp';
16 | const javaDummyPath = 'java_dummy_path';
17 | const javaHome = 'java_home';
18 | const apksignerDummyPath = '/path/to/apksigner';
19 | const tempDir = appiumSupport.tempDir;
20 | const fs = appiumSupport.fs;
21 |
22 | const adb = new ADB();
23 | adb.keystorePath = keystorePath;
24 | adb.keyAlias = keyAlias;
25 | adb.keystorePassword = password;
26 | adb.keyPassword = password;
27 |
28 | describe('signing', withMocks({
29 | teen_process,
30 | helpers,
31 | adb,
32 | appiumSupport,
33 | fs,
34 | tempDir,
35 | apkSigningHelpers,
36 | }, function (mocks) {
37 | let chai;
38 |
39 | before(async function () {
40 | chai = await import('chai');
41 | const chaiAsPromised = await import('chai-as-promised');
42 |
43 | chai.should();
44 | chai.use(chaiAsPromised.default);
45 | });
46 |
47 | afterEach(function () {
48 | mocks.verify();
49 | });
50 |
51 | describe('signWithDefaultCert', function () {
52 | it('should call exec with correct args', async function () {
53 | mocks.apkSigningHelpers.expects('getApksignerForOs')
54 | .returns(apksignerDummyPath);
55 | mocks.adb.expects('executeApksigner')
56 | .once().withExactArgs(['sign',
57 | '--key', defaultKeyPath,
58 | '--cert', defaultCertPath,
59 | selendroidTestApp
60 | ]).returns('');
61 | await adb.signWithDefaultCert(selendroidTestApp);
62 | });
63 |
64 | it('should fail if apksigner fails', async function () {
65 | mocks.apkSigningHelpers.expects('getApksignerForOs')
66 | .returns(apksignerDummyPath);
67 | mocks.adb.expects('executeApksigner')
68 | .once().withExactArgs(['sign',
69 | '--key', defaultKeyPath,
70 | '--cert', defaultCertPath,
71 | selendroidTestApp
72 | ]).throws();
73 | mocks.helpers.expects('getJavaForOs')
74 | .once().returns(javaDummyPath);
75 | await adb.signWithDefaultCert(selendroidTestApp).should.eventually.be.rejected;
76 | });
77 |
78 | it('should throw error for invalid file path', async function () {
79 | let dummyPath = 'dummyPath';
80 | await adb.signWithDefaultCert(dummyPath).should.eventually.be.rejected;
81 | });
82 | });
83 |
84 | describe('signWithCustomCert', function () {
85 | it('should call exec with correct args', async function () {
86 | adb.useKeystore = true;
87 |
88 | mocks.apkSigningHelpers.expects('getApksignerForOs')
89 | .returns(apksignerDummyPath);
90 | mocks.adb.expects('executeApksigner')
91 | .withExactArgs(['sign',
92 | '--ks', keystorePath,
93 | '--ks-key-alias', keyAlias,
94 | '--ks-pass', `pass:${password}`,
95 | '--key-pass', `pass:${password}`,
96 | selendroidTestApp
97 | ]).returns('');
98 | await adb.signWithCustomCert(selendroidTestApp);
99 | });
100 |
101 | it('should fallback to jarsigner if apksigner fails', async function () {
102 | let jarsigner = path.resolve(javaHome, 'bin', 'jarsigner');
103 | if (appiumSupport.system.isWindows()) {
104 | jarsigner = jarsigner + '.exe';
105 | }
106 | adb.useKeystore = true;
107 |
108 | mocks.apkSigningHelpers.expects('getApksignerForOs')
109 | .returns(apksignerDummyPath);
110 | mocks.adb.expects('executeApksigner')
111 | .once().withExactArgs(['sign',
112 | '--ks', keystorePath,
113 | '--ks-key-alias', keyAlias,
114 | '--ks-pass', `pass:${password}`,
115 | '--key-pass', `pass:${password}`,
116 | selendroidTestApp
117 | ]).throws();
118 | mocks.teen_process.expects('exec')
119 | .once().withExactArgs(jarsigner, [
120 | '-sigalg', 'MD5withRSA',
121 | '-digestalg', 'SHA1',
122 | '-keystore', keystorePath,
123 | '-storepass', password,
124 | '-keypass', password,
125 | selendroidTestApp, keyAlias],
126 | { windowsVerbatimArguments: appiumSupport.system.isWindows() })
127 | .returns({});
128 | mocks.helpers.expects('getJavaHome')
129 | .returns(javaHome);
130 | mocks.apkSigningHelpers.expects('unsignApk')
131 | .withExactArgs(selendroidTestApp)
132 | .returns(true);
133 | await adb.signWithCustomCert(selendroidTestApp);
134 | });
135 | });
136 |
137 | // Skipping as unable to mock mkdirp, this case is covered in e2e tests for now.
138 | // TODO: find ways to mock mkdirp
139 | describe.skip('zipAlignApk', function () {
140 | it('should call exec with correct args', async function () {
141 | let alignedApk = 'dummy_path';
142 | mocks.tempDir.expects('path')
143 | .once().withExactArgs({prefix: 'appium', suffix: '.tmp'})
144 | .returns(alignedApk);
145 | mocks.adb.expects('initZipAlign')
146 | .once().withExactArgs()
147 | .returns('');
148 | mocks.appiumSupport.expects('mkdirp')
149 | .once().withExactArgs(path.dirname(alignedApk))
150 | .returns({});
151 | mocks.teen_process.expects('exec')
152 | .once().withExactArgs(adb.binaries.zipalign, ['-f', '4', selendroidTestApp, alignedApk]);
153 | mocks.fs.expects('mv')
154 | .once().withExactArgs(alignedApk, selendroidTestApp, { mkdirp: true })
155 | .returns('');
156 | await adb.zipAlignApk(selendroidTestApp);
157 | });
158 | });
159 |
160 | describe('checkApkCert', function () {
161 | beforeEach(function () {
162 | mocks.fs.expects('hash')
163 | .returns(Math.random().toString(36));
164 | });
165 |
166 | it('should return false for apk not present', async function () {
167 | (await adb.checkApkCert('dummyPath', 'dummyPackage')).should.be.false;
168 | });
169 |
170 | it('should check default signature when not using keystore', async function () {
171 | adb.useKeystore = false;
172 |
173 | mocks.apkSigningHelpers.expects('getApksignerForOs')
174 | .once().returns(apksignerDummyPath);
175 | mocks.adb.expects('executeApksigner')
176 | .once().withExactArgs(['verify', '--print-certs', selendroidTestApp])
177 | .returns(`
178 | Signer #1 certificate DN: EMAILADDRESS=android@android.com, CN=Android, OU=Android, O=Android, L=Mountain View, ST=California, C=US
179 | Signer #1 certificate SHA-256 digest: a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc
180 | Signer #1 certificate SHA-1 digest: 61ed377e85d386a8dfee6b864bd85b0bfaa5af81
181 | Signer #1 certificate MD5 digest: e89b158e4bcf988ebd09eb83f5378e87`);
182 | (await adb.checkApkCert(selendroidTestApp, selendroidTestAppPackage)).should.be.true;
183 | });
184 |
185 | it('should check non default signature when not using keystore', async function () {
186 | adb.useKeystore = false;
187 |
188 | mocks.apkSigningHelpers.expects('getApksignerForOs')
189 | .once().returns(apksignerDummyPath);
190 | mocks.adb.expects('executeApksigner')
191 | .once().withExactArgs(['verify', '--print-certs', selendroidTestApp])
192 | .returns(`
193 | Signer #1 certificate DN: EMAILADDRESS=android@android.com, CN=Android, OU=Android, O=Android, L=Mountain View, ST=California, C=US
194 | Signer #1 certificate SHA-256 digest: a40da80a59d170caa950cf15cccccc4d47a39b26989d8b640ecd745ba71bf5dc
195 | Signer #1 certificate SHA-1 digest: 61ed377e85d386a8dfee6b864bdcccccfaa5af81
196 | Signer #1 certificate MD5 digest: e89b158e4bcf988ebd09eb83f53ccccc`);
197 | (await adb.checkApkCert(selendroidTestApp, selendroidTestAppPackage, {
198 | requireDefaultCert: false,
199 | })).should.be.true;
200 | });
201 |
202 | it('should fail if apksigner is not found', async function () {
203 | adb.useKeystore = false;
204 |
205 | mocks.apkSigningHelpers.expects('getApksignerForOs')
206 | .throws();
207 | mocks.helpers.expects('getJavaForOs')
208 | .returns(javaDummyPath);
209 | await adb.checkApkCert(selendroidTestApp, selendroidTestAppPackage)
210 | .should.eventually.be.rejected;
211 | });
212 |
213 | it('should call getKeystoreHash when using keystore', async function () {
214 | adb.useKeystore = true;
215 |
216 | mocks.adb.expects('getKeystoreHash')
217 | .once().returns({
218 | 'md5': 'e89b158e4bcf988ebd09eb83f53ccccc',
219 | 'sha1': '61ed377e85d386a8dfee6b864bdcccccfaa5af81',
220 | 'sha256': 'a40da80a59d170caa950cf15cccccc4d47a39b26989d8b640ecd745ba71bf5dc',
221 | });
222 | mocks.apkSigningHelpers.expects('getApksignerForOs')
223 | .once().returns(apksignerDummyPath);
224 | mocks.adb.expects('executeApksigner')
225 | .once().withExactArgs(['verify', '--print-certs', selendroidTestApp])
226 | .returns(`
227 | Signer #1 certificate DN: EMAILADDRESS=android@android.com, CN=Android, OU=Android, O=Android, L=Mountain View, ST=California, C=US
228 | Signer #1 certificate SHA-256 digest: a40da80a59d170caa950cf15cccccc4d47a39b26989d8b640ecd745ba71bf5dc
229 | Signer #1 certificate SHA-1 digest: 61ed377e85d386a8dfee6b864bdcccccfaa5af81
230 | Signer #1 certificate MD5 digest: e89b158e4bcf988ebd09eb83f53ccccc`);
231 | await adb.checkApkCert(selendroidTestApp, selendroidTestAppPackage).should.eventually.be.true;
232 | });
233 | });
234 | }));
235 |
--------------------------------------------------------------------------------
/test/unit/logcat-specs.js:
--------------------------------------------------------------------------------
1 | import * as teen_process from 'teen_process';
2 | import events from 'events';
3 | import { Logcat } from '../../lib/logcat';
4 | import { withMocks } from '@appium/test-support';
5 |
6 | describe('logcat', withMocks({teen_process}, function (mocks) {
7 | const adb = {path: 'dummyPath', defaultArgs: []};
8 | const logcat = new Logcat({adb, debug: false, debugTrace: false});
9 | let chai;
10 |
11 | before(async function () {
12 | chai = await import('chai');
13 | const chaiAsPromised = await import('chai-as-promised');
14 |
15 | chai.should();
16 | chai.use(chaiAsPromised.default);
17 | });
18 |
19 | afterEach(function () {
20 | mocks.verify();
21 | });
22 |
23 | describe('startCapture', function () {
24 | it('should correctly call subprocess and should resolve promise', async function () {
25 | let conn = new events.EventEmitter();
26 | conn.start = () => { };
27 | mocks.teen_process.expects('SubProcess')
28 | .withArgs('dummyPath', ['logcat', '-v', 'brief', 'yolo2:d', '*:v'])
29 | .onFirstCall()
30 | .returns(conn);
31 | setTimeout(function () {
32 | conn.emit('line-stdout', '- beginning of system\r');
33 | }, 0);
34 | await logcat.startCapture({
35 | format: 'brief',
36 | filterSpecs: ['yolo2:d', ':k', '-asd:e'],
37 | });
38 | let logs = logcat.getLogs();
39 | logs.should.have.length.above(0);
40 | });
41 | it('should correctly call subprocess and should reject promise', async function () {
42 | let conn = new events.EventEmitter();
43 | conn.start = () => { };
44 | mocks.teen_process.expects('SubProcess')
45 | .withArgs('dummyPath', ['logcat', '-v', 'threadtime'])
46 | .onFirstCall()
47 | .returns(conn);
48 | setTimeout(function () {
49 | conn.emit('line-stderr', 'execvp()');
50 | }, 0);
51 | await logcat.startCapture().should.eventually.be.rejectedWith('Logcat');
52 | });
53 | it('should correctly call subprocess and should resolve promise if it fails on startup', async function () {
54 | let conn = new events.EventEmitter();
55 | conn.start = () => { };
56 | mocks.teen_process.expects('SubProcess')
57 | .withArgs('dummyPath', ['logcat', '-v', 'threadtime'])
58 | .onFirstCall()
59 | .returns(conn);
60 | setTimeout(function () {
61 | conn.emit('line-stderr', 'something');
62 | }, 0);
63 | await logcat.startCapture().should.eventually.not.be.rejectedWith('Logcat');
64 | });
65 | });
66 |
67 | describe('clear', function () {
68 | it('should call logcat clear', async function () {
69 | mocks.teen_process.expects('exec')
70 | .once().withExactArgs(adb.path, adb.defaultArgs.concat(['logcat', '-c']));
71 | await logcat.clear();
72 | });
73 | it('should not fail if logcat clear fails', async function () {
74 | mocks.teen_process.expects('exec')
75 | .once().withExactArgs(adb.path, adb.defaultArgs.concat(['logcat', '-c']))
76 | .throws('Failed to clear');
77 | await logcat.clear().should.eventually.not.be.rejected;
78 | });
79 | });
80 | }));
81 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@appium/tsconfig/tsconfig.json",
4 | "compilerOptions": {
5 | "outDir": "build",
6 | "checkJs": true,
7 | "esModuleInterop": true,
8 | "types": ["node"],
9 | "strict": false
10 | },
11 | "include": ["index.ts", "lib"]
12 | }
13 |
--------------------------------------------------------------------------------