mirror of
https://github.com/appium/appium.git
synced 2026-01-15 14:49:55 -06:00
1179 lines
34 KiB
JavaScript
1179 lines
34 KiB
JavaScript
"use strict";
|
|
|
|
var errors = require('../../server/errors.js')
|
|
, _ = require('underscore')
|
|
, logger = require('../../server/logger.js').get('appium')
|
|
, deviceCommon = require('../common.js')
|
|
, helpers = require('../../helpers.js')
|
|
, status = require('../../server/status.js')
|
|
, NotYetImplementedError = errors.NotYetImplementedError
|
|
, fs = require('fs')
|
|
, temp = require('temp')
|
|
, async = require('async')
|
|
, mkdirp = require('mkdirp')
|
|
, path = require('path')
|
|
, AdmZip = require("adm-zip")
|
|
, helpers = require('../../helpers.js');
|
|
|
|
var androidController = {};
|
|
|
|
androidController.pressKeyCode = function (keycode, metastate, cb) {
|
|
this.proxy(["pressKeyCode", {keycode: keycode, metastate: metastate}], cb);
|
|
};
|
|
|
|
androidController.longPressKeyCode = function (keycode, metastate, cb) {
|
|
this.proxy(["longPressKeyCode", {keycode: keycode, metastate: metastate}], cb);
|
|
};
|
|
|
|
androidController.keyevent = function (keycode, metastate, cb) {
|
|
helpers.logDeprecationWarning('function', 'keyevent', 'pressKeyCode');
|
|
this.pressKeyCode(keycode, metastate, cb);
|
|
};
|
|
|
|
androidController.findElement = function (strategy, selector, cb) {
|
|
this.findUIElementOrElements(strategy, selector, false, "", cb);
|
|
};
|
|
|
|
androidController.findElements = function (strategy, selector, cb) {
|
|
this.findUIElementOrElements(strategy, selector, true, "", cb);
|
|
};
|
|
|
|
androidController.findUIElementOrElements = function (strategy, selector, many, context, cb) {
|
|
if (!deviceCommon.checkValidLocStrat(strategy, false, cb)) {
|
|
return;
|
|
}
|
|
if (strategy === "xpath" && context) {
|
|
return cb(new Error("Cannot use xpath locator strategy from an element. " +
|
|
"It can only be used from the root element"));
|
|
}
|
|
var params = {
|
|
strategy: strategy
|
|
, selector: selector
|
|
, context: context
|
|
, multiple: many
|
|
};
|
|
|
|
var doFind = function (findCb) {
|
|
this.proxy(["find", params], function (err, res) {
|
|
this.handleFindCb(err, res, many, findCb);
|
|
}.bind(this));
|
|
}.bind(this);
|
|
this.implicitWaitForCondition(doFind, cb);
|
|
};
|
|
|
|
androidController.handleFindCb = function (err, res, many, findCb) {
|
|
if (err) {
|
|
findCb(false, err, res);
|
|
} else {
|
|
if (!many && res.status === 0 && res.value !== null) {
|
|
findCb(true, err, res);
|
|
} else if (many && typeof res.value !== 'undefined' && res.value.length > 0) {
|
|
findCb(true, err, res);
|
|
} else {
|
|
findCb(false, err, res);
|
|
}
|
|
}
|
|
};
|
|
|
|
androidController.findElementFromElement = function (element, strategy, selector, cb) {
|
|
this.findUIElementOrElements(strategy, selector, false, element, cb);
|
|
};
|
|
|
|
androidController.findElementsFromElement = function (element, strategy, selector, cb) {
|
|
this.findUIElementOrElements(strategy, selector, true, element, cb);
|
|
};
|
|
|
|
androidController.setValueImmediate = function (elementId, value, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.setValue = function (elementId, value, cb) {
|
|
var params = {
|
|
elementId: elementId,
|
|
text: value,
|
|
replace: false
|
|
};
|
|
if (this.args.unicodeKeyboard) {
|
|
params.unicodeKeyboard = true;
|
|
}
|
|
this.proxy(["element:setText", params], cb);
|
|
};
|
|
|
|
androidController.replaceValue = function (elementId, value, cb) {
|
|
var params = {
|
|
elementId: elementId,
|
|
text: value,
|
|
replace: true
|
|
};
|
|
if (this.args.unicodeKeyboard) {
|
|
params.unicodeKeyboard = true;
|
|
}
|
|
this.proxy(["element:setText", params], cb);
|
|
};
|
|
|
|
androidController.click = function (elementId, cb) {
|
|
this.proxy(["element:click", {elementId: elementId}], cb);
|
|
};
|
|
|
|
androidController.touchLongClick = function (elementId, x, y, duration, cb) {
|
|
var opts = {};
|
|
if (elementId) opts.elementId = elementId;
|
|
if (x) opts.x = x;
|
|
if (y) opts.y = y;
|
|
if (duration) opts.duration = duration;
|
|
this.proxy(["element:touchLongClick", opts], cb);
|
|
};
|
|
|
|
androidController.touchDown = function (elementId, x, y, cb) {
|
|
var opts = {};
|
|
if (elementId) opts.elementId = elementId;
|
|
if (x) opts.x = x;
|
|
if (y) opts.y = y;
|
|
this.proxy(["element:touchDown", opts], cb);
|
|
};
|
|
|
|
androidController.touchUp = function (elementId, x, y, cb) {
|
|
var opts = {};
|
|
if (elementId) opts.elementId = elementId;
|
|
if (x) opts.x = x;
|
|
if (y) opts.y = y;
|
|
this.proxy(["element:touchUp", opts], cb);
|
|
};
|
|
|
|
androidController.touchMove = function (elementId, x, y, cb) {
|
|
var opts = {};
|
|
if (elementId) opts.elementId = elementId;
|
|
if (x) opts.x = x;
|
|
if (y) opts.y = y;
|
|
this.proxy(["element:touchMove", opts], cb);
|
|
};
|
|
|
|
androidController.complexTap = function (tapCount, touchCount, duration, x, y, elementId, cb) {
|
|
this.proxy(["click", {x: x, y: y}], cb);
|
|
};
|
|
|
|
androidController.clear = function (elementId, cb) {
|
|
this.proxy(["element:clear", {elementId: elementId}], cb);
|
|
};
|
|
|
|
androidController.submit = function (elementId, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.getName = function (elementId, cb) {
|
|
var p = {elementId: elementId, attribute: "className"};
|
|
this.proxy(["element:getAttribute", p], cb);
|
|
};
|
|
|
|
androidController.getText = function (elementId, cb) {
|
|
this.proxy(["element:getText", {elementId: elementId}], cb);
|
|
};
|
|
|
|
androidController.getAttribute = function (elementId, attributeName, cb) {
|
|
var p = {elementId: elementId, attribute: attributeName};
|
|
this.proxy(["element:getAttribute", p], cb);
|
|
};
|
|
|
|
androidController.getLocation = function (elementId, cb) {
|
|
this.proxy(["element:getLocation", {elementId: elementId}], cb);
|
|
};
|
|
|
|
androidController.getSize = function (elementId, cb) {
|
|
this.proxy(["element:getSize", {elementId: elementId}], cb);
|
|
};
|
|
|
|
androidController.getWindowSize = function (windowHandle, cb) {
|
|
this.proxy(["getDeviceSize"], cb);
|
|
};
|
|
|
|
androidController.back = function (cb) {
|
|
this.proxy(["pressBack"], cb);
|
|
};
|
|
|
|
androidController.forward = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.refresh = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.getPageIndex = function (elementId, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.keys = function (keys, cb) {
|
|
var params = {
|
|
text: keys,
|
|
replace: false
|
|
};
|
|
if (this.args.unicodeKeyboard) {
|
|
params.unicodeKeyboard = true;
|
|
}
|
|
this.proxy(['setText', params], cb);
|
|
};
|
|
|
|
androidController.frame = function (frame, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.implicitWait = function (ms, cb) {
|
|
this.implicitWaitMs = parseInt(ms, 10);
|
|
logger.debug("Set Android implicit wait to " + ms + "ms");
|
|
cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: null
|
|
});
|
|
};
|
|
|
|
androidController.asyncScriptTimeout = function (ms, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.pageLoadTimeout = function (ms, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.executeAsync = function (script, args, responseUrl, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.elementDisplayed = function (elementId, cb) {
|
|
var p = {elementId: elementId, attribute: "displayed"};
|
|
this.proxy(["element:getAttribute", p], function (err, res) {
|
|
if (err) return cb(err);
|
|
var displayed = res.value === 'true';
|
|
cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: displayed
|
|
});
|
|
});
|
|
};
|
|
|
|
androidController.elementEnabled = function (elementId, cb) {
|
|
var p = {elementId: elementId, attribute: "enabled"};
|
|
this.proxy(["element:getAttribute", p], function (err, res) {
|
|
if (err) return cb(err);
|
|
var enabled = res.value === 'true';
|
|
cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: enabled
|
|
});
|
|
});
|
|
};
|
|
|
|
androidController.elementSelected = function (elementId, cb) {
|
|
var p = {elementId: elementId, attribute: "selected"};
|
|
this.proxy(["element:getAttribute", p], function (err, res) {
|
|
if (err) return cb(err);
|
|
var selected = res.value === 'true';
|
|
cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: selected
|
|
});
|
|
});
|
|
};
|
|
|
|
androidController.getCssProperty = function (elementId, propertyName, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
var _getNodeClass = function (node) {
|
|
var nodeClass = null;
|
|
_.each(node.attributes, function (attr) {
|
|
if (attr.name === "class") {
|
|
nodeClass = attr.value;
|
|
}
|
|
});
|
|
return nodeClass;
|
|
};
|
|
|
|
var _copyNodeAttributes = function (oldNode, newNode) {
|
|
_.each(oldNode.attributes, function (attr) {
|
|
newNode.setAttribute(attr.name, attr.value);
|
|
});
|
|
};
|
|
|
|
// recursively annotate xml nodes. Update tag name to be Android UIElement class name. Add an "instance" identifier which increments for each class separately.
|
|
var _annotateXmlNodes = function (newDom, newParent, oldNode, instances) {
|
|
if (!instances) {
|
|
instances = {};
|
|
}
|
|
var newNode;
|
|
var nodeClass = _getNodeClass(oldNode);
|
|
if (nodeClass) {
|
|
newNode = newDom.createElement(nodeClass);
|
|
_copyNodeAttributes(oldNode, newNode);
|
|
|
|
// we keep track of the number of instances of each className. We use these to create queries on the bootstrap side.
|
|
if (!instances[nodeClass]) {
|
|
instances[nodeClass] = 0;
|
|
}
|
|
newNode.setAttribute('instance', instances[nodeClass]++);
|
|
} else {
|
|
newNode = oldNode.cloneNode(false);
|
|
}
|
|
newParent.appendChild(newNode);
|
|
if (oldNode.hasChildNodes()) {
|
|
_.each(oldNode.childNodes, function (childNode) {
|
|
_annotateXmlNodes(newDom, newNode, childNode, instances);
|
|
});
|
|
}
|
|
};
|
|
|
|
androidController.getPageSource = function (cb) {
|
|
this.proxy(["source", {}], cb);
|
|
};
|
|
|
|
androidController.getAlertText = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.setAlertText = function (text, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.postAcceptAlert = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.postDismissAlert = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.lock = function (secs, cb) {
|
|
this.adb.lock(function (err) {
|
|
if (err) return cb(err);
|
|
cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: null
|
|
});
|
|
});
|
|
};
|
|
|
|
androidController.isLocked = function (cb) {
|
|
this.adb.isScreenLocked(function (err, isLocked) {
|
|
if (err) return cb(err);
|
|
cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: isLocked
|
|
});
|
|
});
|
|
};
|
|
|
|
androidController.pushUnlock = function (cb) {
|
|
logger.debug("Pushing unlock helper app to device...");
|
|
var unlockPath = path.resolve(__dirname, "..", "..", "..", "build",
|
|
"unlock_apk", "unlock_apk-debug.apk");
|
|
fs.stat(unlockPath, function (err) {
|
|
if (err) {
|
|
cb(new Error("Could not find unlock.apk; please run " +
|
|
"'reset.sh --android' to build it."));
|
|
} else {
|
|
this.adb.install(unlockPath, false, cb);
|
|
}
|
|
}.bind(this));
|
|
};
|
|
|
|
androidController.unlock = function (cb) {
|
|
this.adb.isScreenLocked(function (err, isLocked) {
|
|
if (err) return cb(err);
|
|
if (isLocked) {
|
|
logger.info("Unlocking screen");
|
|
var timeoutMs = 10000;
|
|
var start = Date.now();
|
|
var unlockAndCheck = function () {
|
|
logger.debug("Screen is locked, trying to unlock");
|
|
var onStart = function (err) {
|
|
if (err) return cb(err);
|
|
this.adb.isScreenLocked(function (err, isLocked) {
|
|
if (err) return cb(err);
|
|
if (!isLocked) {
|
|
logger.debug("Screen is unlocked, continuing");
|
|
return cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: null
|
|
});
|
|
}
|
|
if ((Date.now() - timeoutMs) > start) {
|
|
return cb(new Error("Screen did not unlock"));
|
|
} else {
|
|
setTimeout(unlockAndCheck, 1000);
|
|
}
|
|
}.bind(this));
|
|
}.bind(this);
|
|
this.adb.startApp({
|
|
pkg: "io.appium.unlock",
|
|
activity: ".Unlock",
|
|
action: "android.intent.action.MAIN",
|
|
category: "android.intent.category.LAUNCHER",
|
|
flags: "0x10200000"
|
|
}, onStart);
|
|
}.bind(this);
|
|
unlockAndCheck();
|
|
} else {
|
|
logger.debug('Screen already unlocked, continuing.');
|
|
return cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: null
|
|
});
|
|
}
|
|
}.bind(this));
|
|
};
|
|
|
|
androidController.equalsWebElement = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.getOrientation = function (cb) {
|
|
this.proxy(["orientation", {}], cb);
|
|
};
|
|
|
|
androidController.setOrientation = function (orientation, cb) {
|
|
this.proxy(["orientation", {orientation: orientation}], cb);
|
|
};
|
|
|
|
androidController.endCoverage = function (intentToBroadcast, ecOnDevicePath, cb) {
|
|
var localfile = temp.path({prefix: 'appium', suffix: '.ec'});
|
|
if (fs.existsSync(localfile)) fs.unlinkSync(localfile);
|
|
var b64data = "";
|
|
|
|
async.series([
|
|
function (cb) {
|
|
// ensure the ec we're pulling is newly created as a result of the intent.
|
|
this.adb.rimraf(ecOnDevicePath, function () { cb(); });
|
|
}.bind(this),
|
|
function (cb) {
|
|
this.adb.broadcastProcessEnd(intentToBroadcast, this.appProcess, cb);
|
|
}.bind(this),
|
|
function (cb) {
|
|
this.adb.pull(ecOnDevicePath, localfile, cb);
|
|
}.bind(this),
|
|
function (cb) {
|
|
fs.readFile(localfile, function (err, data) {
|
|
if (err) return cb(err);
|
|
b64data = new Buffer(data).toString('base64');
|
|
cb();
|
|
});
|
|
}.bind(this),
|
|
],
|
|
function (err) {
|
|
if (fs.existsSync(localfile)) fs.unlinkSync(localfile);
|
|
if (err) return cb(err);
|
|
cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: b64data
|
|
});
|
|
});
|
|
};
|
|
|
|
androidController.pullFile = function (remotePath, cb) {
|
|
var localFile = temp.path({prefix: 'appium', suffix: '.tmp'});
|
|
var b64data = "";
|
|
|
|
async.series([
|
|
function (cb) {
|
|
this.adb.pull(remotePath, localFile, cb);
|
|
}.bind(this),
|
|
function (cb) {
|
|
fs.readFile(localFile, function (err, data) {
|
|
if (err) return cb(err);
|
|
b64data = new Buffer(data).toString('base64');
|
|
cb();
|
|
});
|
|
}.bind(this),
|
|
],
|
|
function (err) {
|
|
if (fs.existsSync(localFile)) fs.unlinkSync(localFile);
|
|
if (err) return cb(err);
|
|
cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: b64data
|
|
});
|
|
});
|
|
};
|
|
|
|
androidController.pushFile = function (base64Data, remotePath, cb) {
|
|
var localFile = temp.path({prefix: 'appium', suffix: '.tmp'});
|
|
mkdirp.sync(path.dirname(localFile));
|
|
|
|
async.series([
|
|
function (cb) {
|
|
var content = new Buffer(base64Data, 'base64');
|
|
var fd = fs.openSync(localFile, 'w');
|
|
fs.writeSync(fd, content, 0, content.length, 0);
|
|
fs.closeSync(fd);
|
|
|
|
// adb push creates folders and overwrites existing files.
|
|
this.adb.push(localFile, remotePath, cb);
|
|
}.bind(this),
|
|
],
|
|
function (err) {
|
|
if (fs.existsSync(localFile)) fs.unlinkSync(localFile);
|
|
if (err) return cb(err);
|
|
cb(null, {
|
|
status: status.codes.Success.code
|
|
});
|
|
});
|
|
};
|
|
|
|
androidController.pullFolder = function (remotePath, cb) {
|
|
var localFolder = temp.path({prefix: 'appium'});
|
|
|
|
var bufferOnSuccess = function (buffer) {
|
|
logger.debug("Converting in-memory zip file to base64 encoded string");
|
|
var data = buffer.toString('base64');
|
|
logger.debug("Returning in-memory zip file as base54 encoded string");
|
|
cb(null, {status: status.codes.Success.code, value: data});
|
|
};
|
|
|
|
var bufferOnFail = function (err) {
|
|
cb(new Error(err));
|
|
};
|
|
|
|
this.adb.pull(remotePath, localFolder, function (err) {
|
|
if (err) return cb(new Error(err));
|
|
var zip = new AdmZip();
|
|
zip.addLocalFolder(localFolder);
|
|
zip.toBuffer(bufferOnSuccess, bufferOnFail);
|
|
});
|
|
};
|
|
|
|
androidController.getScreenshot = function (cb) {
|
|
var localfile = temp.path({prefix: 'appium', suffix: '.png'});
|
|
var b64data = "";
|
|
|
|
async.series([
|
|
function (cb) {
|
|
var png = "/data/local/tmp/screenshot.png";
|
|
var cmd = ['"/system/bin/rm', png + ';', '/system/bin/screencap -p',
|
|
png, '"'].join(' ');
|
|
this.adb.shell(cmd, cb);
|
|
}.bind(this),
|
|
function (cb) {
|
|
if (fs.existsSync(localfile)) fs.unlinkSync(localfile);
|
|
this.adb.pull('/data/local/tmp/screenshot.png', localfile, cb);
|
|
}.bind(this),
|
|
function (cb) {
|
|
fs.readFile(localfile, function (err, data) {
|
|
if (err) return cb(err);
|
|
b64data = new Buffer(data).toString('base64');
|
|
cb();
|
|
});
|
|
},
|
|
function (cb) {
|
|
fs.unlink(localfile, function (err) {
|
|
if (err) return cb(err);
|
|
cb();
|
|
});
|
|
}
|
|
],
|
|
// Top level cb
|
|
function (err) {
|
|
if (err) return cb(err);
|
|
cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: b64data
|
|
});
|
|
});
|
|
};
|
|
|
|
androidController.fakeFlick = function (xSpeed, ySpeed, swipe, cb) {
|
|
this.proxy(["flick", {xSpeed: xSpeed, ySpeed: ySpeed}], cb);
|
|
};
|
|
|
|
androidController.fakeFlickElement = function (elementId, xoffset, yoffset, speed, cb) {
|
|
this.proxy(["element:flick", {xoffset: xoffset, yoffset: yoffset, speed: speed, elementId: elementId}], cb);
|
|
};
|
|
|
|
androidController.swipe = function (startX, startY, endX, endY, duration, touchCount, elId, cb) {
|
|
if (startX === 'null') {
|
|
startX = 0.5;
|
|
}
|
|
if (startY === 'null') {
|
|
startY = 0.5;
|
|
}
|
|
var swipeOpts = {
|
|
startX: startX
|
|
, startY: startY
|
|
, endX: endX
|
|
, endY: endY
|
|
, steps: Math.round(duration * this.swipeStepsPerSec)
|
|
};
|
|
|
|
// going the long way and checking for undefined and null since
|
|
// we can't be assured `elId` is a string and not an int
|
|
if (typeof elId !== "undefined" && elId !== null) {
|
|
swipeOpts.elementId = elId;
|
|
this.proxy(["element:swipe", swipeOpts], cb);
|
|
} else {
|
|
this.proxy(["swipe", swipeOpts], cb);
|
|
}
|
|
};
|
|
|
|
androidController.rotate = function (x, y, radius, rotation, duration, touchCount, elId, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.pinchClose = function (startX, startY, endX, endY, duration, percent, steps, elId, cb) {
|
|
var pinchOpts = {
|
|
direction: 'in'
|
|
, elementId: elId
|
|
, percent: percent
|
|
, steps: steps
|
|
};
|
|
this.proxy(["element:pinch", pinchOpts], cb);
|
|
};
|
|
|
|
androidController.pinchOpen = function (startX, startY, endX, endY, duration, percent, steps, elId, cb) {
|
|
var pinchOpts = {
|
|
direction: 'out'
|
|
, elementId: elId
|
|
, percent: percent
|
|
, steps: steps
|
|
};
|
|
this.proxy(["element:pinch", pinchOpts], cb);
|
|
};
|
|
|
|
androidController.flick = function (startX, startY, endX, endY, touchCount, elId, cb) {
|
|
if (startX === 'null') {
|
|
startX = 0.5;
|
|
}
|
|
if (startY === 'null') {
|
|
startY = 0.5;
|
|
}
|
|
var swipeOpts = {
|
|
startX: startX
|
|
, startY: startY
|
|
, endX: endX
|
|
, endY: endY
|
|
, steps: Math.round(0.2 * this.swipeStepsPerSec)
|
|
};
|
|
if (elId !== null) {
|
|
swipeOpts.elementId = elId;
|
|
this.proxy(["element:swipe", swipeOpts], cb);
|
|
} else {
|
|
this.proxy(["swipe", swipeOpts], cb);
|
|
}
|
|
};
|
|
|
|
androidController.drag = function (startX, startY, endX, endY, duration, touchCount, elementId, destElId, cb) {
|
|
var dragOpts = {
|
|
elementId: elementId
|
|
, destElId: destElId
|
|
, startX: startX
|
|
, startY: startY
|
|
, endX: endX
|
|
, endY: endY
|
|
, steps: Math.round(duration * this.dragStepsPerSec)
|
|
};
|
|
|
|
if (elementId) {
|
|
this.proxy(["element:drag", dragOpts], cb);
|
|
} else {
|
|
this.proxy(["drag", dragOpts], cb);
|
|
}
|
|
};
|
|
|
|
androidController.scrollTo = function (elementId, text, direction, cb) {
|
|
// instead of the elementId as the element to be scrolled too,
|
|
// it's the scrollable view to swipe until the uiobject that has the
|
|
// text is found.
|
|
var opts = {
|
|
text: text
|
|
, direction: direction
|
|
, elementId: elementId
|
|
};
|
|
this.proxy(["element:scrollTo", opts], cb);
|
|
};
|
|
|
|
androidController.scroll = function (direction, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.shake = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.setLocation = function (latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, course, speed, cb) {
|
|
var cmd = "geo fix " + longitude + " " + latitude;
|
|
this.adb.sendTelnetCommand(cmd, function (err, res) {
|
|
if (err) {
|
|
return cb(null, {
|
|
status: status.codes.UnknownError.code
|
|
, value: "Could not set geolocation via telnet to device"
|
|
});
|
|
}
|
|
cb(null, {
|
|
status: status.codes.Success.code
|
|
, value: res
|
|
});
|
|
});
|
|
};
|
|
|
|
androidController.url = function (url, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.active = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.closeWindow = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.clearWebView = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.execute = function (script, args, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.convertElementForAtoms = deviceCommon.convertElementForAtoms;
|
|
|
|
androidController.title = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.moveTo = function (element, xoffset, yoffset, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.clickCurrent = function (button, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.getCookies = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.setCookie = function (cookie, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.deleteCookie = function (cookie, cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.deleteCookies = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
androidController.resetAndStartApp = function (cb) {
|
|
async.series([
|
|
this.resetApp.bind(this),
|
|
this.waitForActivityToStop.bind(this),
|
|
this.startAppUnderTest.bind(this)
|
|
], cb);
|
|
};
|
|
|
|
// controller.js#isAppInstalled expects weird response, hence this hack
|
|
androidController.isAppInstalled = function (appPackage, cb) {
|
|
this.adb.isAppInstalled(appPackage, function (err, installed) {
|
|
if (installed) {
|
|
return cb(null, [true]);
|
|
}
|
|
cb(err, []);
|
|
});
|
|
};
|
|
|
|
androidController.removeApp = function (appPackage, cb) {
|
|
var removeCommand = null;
|
|
if (this.args.udid) {
|
|
removeCommand = 'adb -s ' + this.args.udid + ' uninstall ' + appPackage;
|
|
} else {
|
|
removeCommand = 'adb uninstall ' + appPackage;
|
|
}
|
|
deviceCommon.removeApp(removeCommand, this.args.udid, appPackage, cb);
|
|
};
|
|
|
|
androidController.installApp = function (appPath, cb) {
|
|
var installationCommand = null;
|
|
if (this.args.udid) {
|
|
installationCommand = 'adb -s ' + this.args.udid + ' install ' + appPath;
|
|
} else {
|
|
installationCommand = 'adb install ' + appPath;
|
|
}
|
|
deviceCommon.installApp(installationCommand, this.args.udid, appPath, cb);
|
|
};
|
|
|
|
androidController.unpackApp = function (req, cb) {
|
|
deviceCommon.unpackApp(req, '.apk', cb);
|
|
};
|
|
|
|
androidController.tap = function (elementId, x, y, count, cb) {
|
|
if (typeof x === "undefined" || x === null) x = 0;
|
|
if (typeof y === "undefined" || y === null) y = 0;
|
|
if (typeof count === "undefined" || count === null) count = 1;
|
|
|
|
var i = 0;
|
|
var opts = {};
|
|
var loop = function (err, res) {
|
|
if (err) return cb(err);
|
|
if (i++ >= count) return cb(err, res);
|
|
|
|
this.proxy(opts, loop);
|
|
}.bind(this);
|
|
|
|
if (elementId) {
|
|
// we are either tapping on the default location of the element
|
|
// or an offset from the top left corner
|
|
if (x !== 0 || y !== 0) {
|
|
opts = ["element:click", {elementId: elementId, x: x, y: y}];
|
|
} else {
|
|
opts = ["element:click", {elementId: elementId}];
|
|
}
|
|
loop();
|
|
} else {
|
|
// we have absolute coordinates
|
|
opts = ["click", {x: x, y: y}];
|
|
loop();
|
|
}
|
|
};
|
|
|
|
androidController.doTouchAction = function (action, opts, cb) {
|
|
switch (action) {
|
|
case 'tap':
|
|
return this.tap(opts.element, opts.x, opts.y, opts.count, cb);
|
|
case 'press':
|
|
return this.touchDown(opts.element, opts.x, opts.y, cb);
|
|
case 'release':
|
|
return this.touchUp(opts.element, opts.x, opts.y, cb);
|
|
case 'moveTo':
|
|
return this.touchMove(opts.element, opts.x, opts.y, cb);
|
|
case 'wait':
|
|
return setTimeout(function () {
|
|
cb(null, {"value": true, "status": status.codes.Success.code});
|
|
}, opts.ms);
|
|
case 'longPress':
|
|
if (typeof opts.duration === 'undefined' || !opts.duration) {
|
|
opts.duration = 1000;
|
|
}
|
|
return this.touchLongClick(opts.element, opts.x, opts.y, opts.duration, cb);
|
|
case 'cancel':
|
|
// TODO: clarify behavior of 'cancel' action and fix this
|
|
logger.warn("Cancel action currently has no effect");
|
|
break;
|
|
default:
|
|
return cb("unknown action '" + action + "'");
|
|
}
|
|
};
|
|
|
|
androidController.performTouch = function (gestures, cb) {
|
|
var actions = _.pluck(gestures, "action");
|
|
|
|
// drag is *not* press-move-release, so we need to translate
|
|
// drag works fine for scroll, as well
|
|
var doTouchDrag = function (gestures, cb) {
|
|
var getStartLocation = function (elementId, x, y, ncb) {
|
|
var startX = x || 0
|
|
, startY = y || 0;
|
|
if (elementId) {
|
|
this.getLocation(elementId, function (err, res) {
|
|
if (err) return ncb(err);
|
|
|
|
startX += res.value.x || 0;
|
|
startY += res.value.y || 0;
|
|
|
|
return ncb(null, startX, startY);
|
|
}.bind(this));
|
|
} else {
|
|
return ncb(null, startX, startY);
|
|
}
|
|
}.bind(this);
|
|
var getEndLocation = function (elementId, x, y, ncb) {
|
|
var endX = x || 0
|
|
, endY = y || 0;
|
|
if (elementId) {
|
|
this.getLocation(elementId, function (err, res) {
|
|
if (err) return ncb(err);
|
|
|
|
endX += res.value.x || 0;
|
|
endY += res.value.y || 0;
|
|
|
|
return ncb(null, endX, endY);
|
|
}.bind(this));
|
|
} else {
|
|
return ncb(null, endX, endY);
|
|
}
|
|
}.bind(this);
|
|
|
|
var longPress = gestures[0];
|
|
getStartLocation(longPress.options.element, longPress.options.x, longPress.options.y, function (err, startX, startY) {
|
|
if (err) return cb(err);
|
|
|
|
var moveTo = gestures[1];
|
|
getEndLocation(moveTo.options.element, moveTo.options.x, moveTo.options.y, function (err, endX, endY) {
|
|
this.adb.getApiLevel(function (err, apiLevel) {
|
|
// lollipop takes a little longer to get things rolling
|
|
var duration = apiLevel >= 5 ? 2 : 1;
|
|
// `drag` will take care of whether there is an element or not at that level
|
|
return this.drag(startX, startY, endX, endY, duration, 1, longPress.options.element, moveTo.options.element, cb);
|
|
}.bind(this));
|
|
}.bind(this));
|
|
}.bind(this));
|
|
}.bind(this);
|
|
|
|
|
|
// Fix last release action
|
|
var fixRelease = function (cb) {
|
|
if (actions[actions.length - 1] === 'release') {
|
|
var release = gestures[actions.length - 1];
|
|
// sometimes there are no options
|
|
release.options = release.options || {};
|
|
|
|
// nothing to do if release options are already set
|
|
if (release.options.element || (release.options.x && release.options.y)) return;
|
|
|
|
// without coordinates, `release` uses the center of the screen, which,
|
|
// generally speaking, is not what we want
|
|
// therefore: loop backwards and use the last command with an element and/or
|
|
// offset coordinates
|
|
var ref = _(gestures).chain().initial().filter(function (gesture) {
|
|
var opts = gesture.options;
|
|
return opts.element || (opts.x && opts.y);
|
|
}).last().value();
|
|
if (ref) {
|
|
var opts = ref.options || {};
|
|
if (opts.element) {
|
|
// we retrieve the element location, might be useful in
|
|
// case the element becomes invalid
|
|
return async.parallel([
|
|
this.getLocation.bind(this, opts.element),
|
|
this.getSize.bind(this, opts.element)
|
|
], function (err, res) {
|
|
if (err) return cb(err);
|
|
var loc = res[0].value, size = res[1].value;
|
|
release.options = {
|
|
element: opts.element,
|
|
x: loc.x + size.width / 2,
|
|
y: loc.y + size.height / 2
|
|
};
|
|
cb();
|
|
});
|
|
}
|
|
if (opts.x && opts.y) release.options = _.pick(opts, 'x', 'y');
|
|
}
|
|
}
|
|
cb();
|
|
}.bind(this);
|
|
|
|
// Perform one gesture
|
|
var performGesture = function (gesture, cb) {
|
|
async.waterfall([
|
|
this.doTouchAction.bind(this, gesture.action, gesture.options || {}),
|
|
function (res, cb) {
|
|
// sometime the element is not available when releasing, retry without it
|
|
if (res && res.status === 7 &&
|
|
gesture.action === 'release' && gesture.options.element) {
|
|
delete gesture.options.element;
|
|
logger.debug('retrying release without element opts:', gestures.options, '.');
|
|
return this.doTouchAction(gesture.action, gesture.options || {}, cb);
|
|
}
|
|
// otherwise continue normally
|
|
return cb(null, res);
|
|
}.bind(this)], function (err, res) {
|
|
if (err) return cb(err);
|
|
// check result, we wrap json errors
|
|
if (res.status !== 0) {
|
|
err = new Error();
|
|
err.res = res;
|
|
return cb(err);
|
|
}
|
|
cb(null, res);
|
|
});
|
|
}.bind(this);
|
|
|
|
// wrapping callback
|
|
cb = _.wrap(cb, function (cb, err) {
|
|
if (err) {
|
|
if (err.res) return cb(null, err.res);
|
|
logger.error(err, err.stack);
|
|
return cb(err);
|
|
}
|
|
// success
|
|
cb(null, { value: true, status: 0 });
|
|
});
|
|
|
|
if (actions[0] === 'longPress' && actions[1] === 'moveTo' && actions[2] === 'release') {
|
|
// some things are special
|
|
doTouchDrag(gestures, cb);
|
|
} else {
|
|
// `press` without a wait is too slow and gets interpretted as a `longPress`
|
|
if (actions[actions.length - 2] === 'press' && actions[actions.length - 1] === 'release') {
|
|
actions[actions.length - 2] = 'tap';
|
|
gestures[gestures.length - 2].action = 'tap';
|
|
}
|
|
|
|
// the `longPress` and `tap` methods release on their own
|
|
if ((actions[actions.length - 2] === 'tap' ||
|
|
actions[actions.length - 2] === 'longPress') && actions[actions.length - 1] === 'release') {
|
|
gestures.pop();
|
|
actions.pop();
|
|
}
|
|
|
|
// fix release action then perform all actions
|
|
fixRelease(function (err) {
|
|
if (err) return cb(err);
|
|
this.parseTouch(gestures, false, function (err, fixedGestures) {
|
|
if (err) return cb(err);
|
|
async.eachSeries(fixedGestures, performGesture, cb);
|
|
});
|
|
}.bind(this));
|
|
}
|
|
};
|
|
|
|
androidController.parseTouch = function (gestures, multi, cb) {
|
|
if (multi && _.last(gestures).action === 'release') {
|
|
gestures.pop();
|
|
}
|
|
|
|
var needsPoint = function (action) {
|
|
return _.contains(['press', 'moveTo', 'tap', 'longPress'], action);
|
|
};
|
|
|
|
var touchStateObjects = [];
|
|
async.eachSeries(gestures, function (gesture, done) {
|
|
var options = gesture.options;
|
|
if (needsPoint(gesture.action)) {
|
|
options.offset = false;
|
|
var elementId = gesture.options.element;
|
|
if (elementId) {
|
|
this.getLocation(elementId, function (err, res) {
|
|
if (err) return done(err); // short circuit and quit
|
|
|
|
var pos = { x: res.value.x, y: res.value.y };
|
|
this.getSize(elementId, function (err, res) {
|
|
if (err) return done(err);
|
|
var size = {w: res.value.width, h: res.value.height};
|
|
|
|
if (gesture.options.x || gesture.options.y) {
|
|
options.x = pos.x + (gesture.options.x || 0);
|
|
options.y = pos.y + (gesture.options.y || 0);
|
|
} else {
|
|
options.x = pos.x + (size.w / 2);
|
|
options.y = pos.y + (size.h / 2);
|
|
}
|
|
|
|
var touchStateObject = {
|
|
action: gesture.action,
|
|
options: options,
|
|
timeOffset: 0.005,
|
|
};
|
|
touchStateObjects.push(touchStateObject);
|
|
done();
|
|
});
|
|
}.bind(this));
|
|
} else {
|
|
// expects absolute coordinates, so we need to save these as offsets
|
|
// and then translate when everything is done
|
|
options.offset = true;
|
|
options.x = (gesture.options.x || 0);
|
|
options.y = (gesture.options.y || 0);
|
|
|
|
touchStateObject = {
|
|
action: gesture.action,
|
|
options: options,
|
|
timeOffset: 0.005,
|
|
};
|
|
touchStateObjects.push(touchStateObject);
|
|
done();
|
|
}
|
|
} else {
|
|
var offset = 0.005;
|
|
if (gesture.action === 'wait') {
|
|
options = gesture.options;
|
|
offset = (parseInt(gesture.options.ms) / 1000);
|
|
}
|
|
var touchStateObject = {
|
|
action: gesture.action,
|
|
options: options,
|
|
timeOffset: offset,
|
|
};
|
|
touchStateObjects.push(touchStateObject);
|
|
done();
|
|
}
|
|
}.bind(this), function (err) {
|
|
if (err) return cb(err);
|
|
|
|
// we need to change the time (which is now an offset)
|
|
// and the position (which may be an offset)
|
|
var prevPos = null,
|
|
time = 0;
|
|
_.each(touchStateObjects, function (state) {
|
|
if (typeof state.options.x === 'undefined' && typeof state.options.x === 'undefined') {
|
|
// this happens with wait
|
|
state.options.x = prevPos.x;
|
|
state.options.y = prevPos.y;
|
|
}
|
|
if (state.options.offset && prevPos) {
|
|
// the current position is an offset
|
|
state.options.x += prevPos.x;
|
|
state.options.y += prevPos.y;
|
|
}
|
|
delete state.options.offset;
|
|
prevPos = state.options;
|
|
|
|
if (multi) {
|
|
var timeOffset = state.timeOffset;
|
|
time += timeOffset;
|
|
state.time = helpers.truncateDecimals(time, 3);
|
|
|
|
// multi gestures require 'touch' rather than 'options'
|
|
state.touch = state.options;
|
|
delete state.options;
|
|
}
|
|
|
|
delete state.timeOffset;
|
|
});
|
|
|
|
cb(null, touchStateObjects);
|
|
});
|
|
};
|
|
|
|
androidController.performMultiAction = function (elementId, actions, cb) {
|
|
// Android needs at least two actions to be able to perform a multi pointer gesture
|
|
if (actions.length === 1) {
|
|
return cb(new Error("Multi Pointer Gestures need at least two actions. " +
|
|
"Use Touch Actions for a single action."));
|
|
}
|
|
|
|
var states = [];
|
|
async.eachSeries(actions, function (action, done) {
|
|
this.parseTouch(action, true, function (err, val) {
|
|
if (err) return done(err);
|
|
|
|
states.push(val);
|
|
done();
|
|
}.bind(this));
|
|
}.bind(this), function (err) {
|
|
if (err) return cb(err);
|
|
|
|
var opts;
|
|
if (elementId) {
|
|
opts = {
|
|
elementId: elementId,
|
|
actions: states
|
|
};
|
|
return this.proxy(["element:performMultiPointerGesture", opts], cb);
|
|
} else {
|
|
opts = {
|
|
actions: states
|
|
};
|
|
return this.proxy(["performMultiPointerGesture", opts], cb);
|
|
}
|
|
}.bind(this));
|
|
};
|
|
|
|
androidController.openNotifications = function (cb) {
|
|
this.proxy(["openNotification"], cb);
|
|
};
|
|
|
|
androidController.getUrl = function (cb) {
|
|
cb(new NotYetImplementedError(), null);
|
|
};
|
|
|
|
module.exports = androidController;
|