Skip to content

Commit

Permalink
feat: Add javascript wrappers over Settings App APIs (#131)
Browse files Browse the repository at this point in the history
  • Loading branch information
mykola-mokhnach authored Jan 9, 2024
1 parent a5fddd8 commit bd56d5e
Show file tree
Hide file tree
Showing 21 changed files with 1,127 additions and 12 deletions.
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
coverage
build
4 changes: 1 addition & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ jobs:
- name: Install dependencies
run: npm install
- name: Linter
run: |
npm run js:lint
npm run lint
run: npm run lint
- name: Build
run: npm run build
41 changes: 41 additions & 0 deletions .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Unit Tests

on: [pull_request, push]


jobs:
prepare_matrix:
runs-on: ubuntu-latest
outputs:
versions: ${{ steps.generate-matrix.outputs.versions }}
steps:
- name: Select 3 most recent LTS versions of Node.js
id: generate-matrix
run: echo "versions=$(curl -s https://endoflife.date/api/nodejs.json | jq -c '[[.[] | select(.lts != false)][:3] | .[].cycle | tonumber]')" >> "$GITHUB_OUTPUT"


test:
needs:
- prepare_matrix
strategy:
matrix:
node-version: ${{ fromJSON(needs.prepare_matrix.outputs.versions) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Use Java 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- run: npm install --no-package-lock
name: Install dev dependencies
- run: npm run build
name: Build
- run: npm run js:lint
name: Run linter
- run: npm run js:test
name: Run unit tests
5 changes: 5 additions & 0 deletions .mocharc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
require: ['ts-node/register'],
forbidOnly: Boolean(process.env.CI),
color: true
};
8 changes: 6 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
const path = require('path');
const path = require('node:path');
const { SettingsApp } = require('./build/lib/client');
const constants = require('./build/lib/constants');

module.exports = {
path: path.resolve(__dirname, 'apks', 'settings_apk-debug.apk')
path: path.resolve(__dirname, 'apks', 'settings_apk-debug.apk'),
SettingsApp,
...constants,
};
155 changes: 155 additions & 0 deletions lib/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { log, LOG_PREFIX } from './logger';
import _ from 'lodash';
import { waitForCondition } from 'asyncbox';
import { SETTINGS_HELPER_ID, SETTINGS_HELPER_MAIN_ACTIVITY } from './constants.js';
import { setAnimationState } from './commands/animation';
import { getClipboard } from './commands/clipboard';
import { setGeoLocation, getGeoLocation, refreshGeoLocationCache } from './commands/geolocation';
import { setDeviceSysLocale } from './commands/locale';
import { scanMedia } from './commands/media';
import { setDataState, setWifiState } from './commands/network';
import { getNotifications } from './commands/notifications';
import { getSmsList } from './commands/sms';
import { performEditorAction, typeUnicode } from './commands/typing';

/**
* @typedef {Object} SettingsAppOpts
* @property {import('appium-adb').ADB} adb
*/


export class SettingsApp {
/** @type {import('appium-adb').ADB} */
adb;

/** @type {import('npmlog').Logger} */
log;

/**
* @param {SettingsAppOpts} opts
*/
constructor (opts) {
this.adb = opts.adb;
this.log = log;
}

/**
* @typedef {Object} SettingsAppStartupOptions
* @property {number} [timeout=5000] The maximum number of milliseconds
* to wait until the app has started
* @property {boolean} [shouldRestoreCurrentApp=false] Whether to restore
* the activity which was the current one before Settings startup
*/

/**
* Ensures that Appium Settings helper application is running
* and starts it if necessary
*
* @param {SettingsAppStartupOptions} [opts={}]
* @throws {Error} If Appium Settings has failed to start
* @returns {Promise<SettingsApp>} self instance for chaining
*/
async requireRunning (opts = {}) {
if (await this.adb.processExists(SETTINGS_HELPER_ID)) {
return this;
}

this.log.debug(LOG_PREFIX, 'Starting Appium Settings app');
const {
timeout = 5000,
shouldRestoreCurrentApp = false,
} = opts;
let appPackage;
if (shouldRestoreCurrentApp) {
try {
({appPackage} = await this.adb.getFocusedPackageAndActivity());
} catch (e) {
this.log.warn(LOG_PREFIX, `The current application can not be restored: ${e.message}`);
}
}
await this.adb.startApp({
pkg: SETTINGS_HELPER_ID,
activity: SETTINGS_HELPER_MAIN_ACTIVITY,
action: 'android.intent.action.MAIN',
category: 'android.intent.category.LAUNCHER',
stopApp: false,
waitForLaunch: false,
});
try {
await waitForCondition(async () => await this.adb.processExists(SETTINGS_HELPER_ID), {
waitMs: timeout,
intervalMs: 300,
});
if (shouldRestoreCurrentApp && appPackage) {
try {
await this.adb.activateApp(appPackage);
} catch (e) {
log.warn(`The current application can not be restored: ${e.message}`);
}
}
return this;
} catch (err) {
throw new Error(`Appium Settings app is not running after ${timeout}ms`);
}
}

/**
* Parses the output in JSON format retrieved from
* the corresponding Appium Settings broadcast calls
*
* @param {string} output The actual command output
* @param {string} entityName The name of the entity which is
* going to be parsed
* @returns {Object} The parsed JSON object
* @throws {Error} If the output cannot be parsed
* as a valid JSON
*/
_parseJsonData (output, entityName) {
if (!/\bresult=-1\b/.test(output) || !/\bdata="/.test(output)) {
this.log.debug(LOG_PREFIX, output);
throw new Error(
`Cannot retrieve ${entityName} from the device. ` +
'Check the server log for more details'
);
}
const match = /\bdata="(.+)",?/.exec(output);
if (!match) {
this.log.debug(LOG_PREFIX, output);
throw new Error(
`Cannot parse ${entityName} from the command output. ` +
'Check the server log for more details'
);
}
const jsonStr = _.trim(match[1]);
try {
return JSON.parse(jsonStr);
} catch (e) {
log.debug(jsonStr);
throw new Error(
`Cannot parse ${entityName} from the resulting data string. ` +
'Check the server log for more details'
);
}
}

setAnimationState = setAnimationState;

getClipboard = getClipboard;

setGeoLocation = setGeoLocation;
getGeoLocation = getGeoLocation;
refreshGeoLocationCache = refreshGeoLocationCache;

setDeviceSysLocale = setDeviceSysLocale;

scanMedia = scanMedia;

setDataState = setDataState;
setWifiState = setWifiState;

getNotifications = getNotifications;
getSmsList = getSmsList;

performEditorAction = performEditorAction;
typeUnicode = typeUnicode;
}
24 changes: 24 additions & 0 deletions lib/commands/animation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ANIMATION_SETTING_ACTION, ANIMATION_SETTING_RECEIVER } from '../constants.js';

/**
* Change the state of animation on the device under test.
* Animation on the device is controlled by the following global properties:
* [ANIMATOR_DURATION_SCALE]{@link https://developer.android.com/reference/android/provider/Settings.Global.html#ANIMATOR_DURATION_SCALE},
* [TRANSITION_ANIMATION_SCALE]{@link https://developer.android.com/reference/android/provider/Settings.Global.html#TRANSITION_ANIMATION_SCALE},
* [WINDOW_ANIMATION_SCALE]{@link https://developer.android.com/reference/android/provider/Settings.Global.html#WINDOW_ANIMATION_SCALE}.
* This method sets all this properties to 0.0 to disable (1.0 to enable) animation.
*
* Turning off animation might be useful to improve stability
* and reduce tests execution time.
*
* @this {import('../client').SettingsApp}
* @param {boolean} on - True to enable and false to disable it.
*/
export async function setAnimationState (on) {
await this.adb.shell([
'am', 'broadcast',
'-a', ANIMATION_SETTING_ACTION,
'-n', ANIMATION_SETTING_RECEIVER,
'--es', 'setstatus', on ? 'enable' : 'disable'
]);
};
47 changes: 47 additions & 0 deletions lib/commands/clipboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import _ from 'lodash';
import { LOG_PREFIX } from '../logger';
import {
CLIPBOARD_RECEIVER,
CLIPBOARD_RETRIEVAL_ACTION,
APPIUM_IME
} from '../constants';

/**
* Retrieves the text content of the device's clipboard.
* The method works for Android below and above 29.
* It temorarily enforces the IME setting in order to workaround
* security limitations if needed.
* This method only works if Appium Settings v. 2.15+ is installed
* on the device under test
*
* @this {import('../client').SettingsApp}
* @returns {Promise<string>} The actual content of the main clipboard as
* base64-encoded string or an empty string if the clipboard is empty
* @throws {Error} If there was a problem while getting the
* clipboard contant
*/
export async function getClipboard () {
this.log.debug(LOG_PREFIX, 'Getting the clipboard content');
await this.requireRunning({shouldRestoreCurrentApp: true});
const retrieveClipboard = async () => await this.adb.shell([
'am', 'broadcast',
'-n', CLIPBOARD_RECEIVER,
'-a', CLIPBOARD_RETRIEVAL_ACTION,
]);
let output;
try {
output = (await this.adb.getApiLevel() >= 29)
? (await this.adb.runInImeContext(APPIUM_IME, retrieveClipboard))
: (await retrieveClipboard());
} catch (err) {
throw new Error(`Cannot retrieve the current clipboard content from the device. ` +
`Make sure the Appium Settings application is up to date. ` +
`Original error: ${err.message}`);
}

const match = /data="([^"]*)"/.exec(output);
if (!match) {
throw new Error(`Cannot parse the actual cliboard content from the command output: ${output}`);
}
return _.trim(match[1]);
};
Loading

0 comments on commit bd56d5e

Please sign in to comment.