Files
appium/lib/devices/android/android-controller.js
2015-05-06 12:38:42 -07:00

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;