"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') , jwpSuccess = deviceCommon.jwpSuccess , status = require('../../server/status.js') , NotYetImplementedError = errors.NotYetImplementedError , exec = require('child_process').exec , fs = require('fs') , temp = require('temp') , async = require('async') , mkdirp = require('mkdirp') , path = require('path') , xpath = require("xpath") , XMLDom = require("xmldom") , helpers = require('../../helpers.js') , warnDeprecated = helpers.logDeprecationWarning; var androidController = {}; var NATIVE_WIN = "NATIVE_APP"; var WEBVIEW_WIN = "WEBVIEW"; var WEBVIEW_BASE = WEBVIEW_WIN + "_"; 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) { warnDeprecated('function', 'keyevent', 'pressKeyCode'); this.pressKeyCode(keycode, metastate, cb); }; androidController.defaultContext = function () { return NATIVE_WIN; }; 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 }; if (strategy === "name") { helpers.logDeprecationWarning("Locator strategy", '"name"', '"accessibility id"'); } var doFind = function (findCb) { if (strategy === "xpath") { this.findUIElementsByXPath(selector, many, function (err, res) { this.handleFindCb(err, res, many, findCb); }.bind(this)); } else { this.proxy(["find", params], function (err, res) { this.handleFindCb(err, res, many, findCb); }.bind(this)); } }.bind(this); this.waitForCondition(this.implicitWaitMs, 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); }; var _pathFromDomNode = function (node) { var path = ""; _.each(node.attributes, function (attrObj) { if (attrObj.name === "index") { path = _pathFromDomNode(node.parentNode) + "/" + attrObj.value; } }); return path; }; androidController.findUIElementsByXPath = function (selector, many, cb) { this.getPageSource(function (err, res) { if (err || res.status !== status.codes.Success.code) return cb(err, res); var dom, nodes; var xmlSource = res.value; try { dom = new XMLDom.DOMParser().parseFromString(xmlSource); nodes = xpath.select(selector, dom); } catch (e) { logger.error(e); return cb(e); } if (!many) nodes = nodes.slice(0, 1); var indexPaths = _.map(nodes, _pathFromDomNode); if (!many && indexPaths.length < 1) { // if we don't have any matching nodes, and we wanted at least one, fail return cb(null, { status: status.codes.NoSuchElement.code, value: null }); } else if (indexPaths.length < 1) { // and if we don't have any matching nodes, return the empty array return cb(null, { status: status.codes.Success.code, value: [] }); } var findParams = { strategy: "index paths", selector: indexPaths.join(","), multiple: many }; this.proxy(["find", findParams], cb); }.bind(this)); }; androidController.setValueImmediate = function (elementId, value, cb) { cb(new NotYetImplementedError(), null); }; androidController.setValue = function (elementId, value, cb) { this.proxy(["element:setText", {elementId: elementId, text: value}], 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 (elementId, keys, cb) { this.proxy(["element:setText", {elementId: elementId, text: keys}], cb); }; androidController.frame = function (frame, cb) { cb(new NotYetImplementedError(), null); }; androidController.leaveWebView = function (cb) { warnDeprecated('function', 'leaveWebView', 'context(null)'); this.setWindow(this.defaultContext(), cb); }; 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.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 _updateSourceXMLNodeNames = function (source) { var newSource; var origDom = new XMLDom.DOMParser().parseFromString(source); var newDom = new XMLDom.DOMImplementation().createDocument(null); _buildClassNodeFromPlainNode(newDom, newDom, origDom); newSource = new XMLDom.XMLSerializer().serializeToString(newDom); return newSource; }; 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); }); }; var _buildClassNodeFromPlainNode = function (newDom, newParent, oldNode) { var newNode; var nodeClass = _getNodeClass(oldNode); if (nodeClass) { newNode = newDom.createElement(nodeClass); _copyNodeAttributes(oldNode, newNode); } else { newNode = oldNode.cloneNode(false); } newParent.appendChild(newNode); if (oldNode.hasChildNodes()) { _.each(oldNode.childNodes, function (childNode) { _buildClassNodeFromPlainNode(newDom, newNode, childNode); }); } }; androidController.getPageSource = function (cb) { var xmlFile = temp.path({suffix: '.xml'}); var onDeviceXmlPath = this.dataDir + '/local/tmp/dump.xml'; async.series( [ function (cb) { this.proxy(["dumpWindowHierarchy"], cb); }.bind(this), function (cb) { var cmd = this.adb.adbCmd + ' pull ' + onDeviceXmlPath + ' "' + xmlFile + '"'; logger.debug('transferPageSourceXML command: ' + cmd); exec(cmd, { maxBuffer: 524288 }, function (err, stdout, stderr) { if (err) { logger.warn(stderr); return cb(err); } cb(null); }); }.bind(this) ], // Top level cb function (err) { if (err) return cb(err); var xml = ''; if (fs.existsSync(xmlFile)) { xml = fs.readFileSync(xmlFile, 'utf8'); fs.unlinkSync(xmlFile); } // xml file may not exist or it could be empty. if (xml === '') { var error = "dumpWindowHierarchy failed"; logger.error(error); return cb(error); } try { xml = _updateSourceXMLNodeNames(xml); } catch (e) { logger.error(e); return cb(e); } cb(null, { status: status.codes.Success.code , value: xml }); }); }; 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) { 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.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) }; if (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 !== null) { 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.hideKeyboard = function (keyName, cb) { cb(new NotYetImplementedError(), null); }; androidController.url = function (url, cb) { cb(new NotYetImplementedError(), null); }; androidController.active = function (cb) { cb(new NotYetImplementedError(), null); }; androidController.getCurrentContext = function (cb) { var response = { status: status.codes.Success.code , value: this.curContext || null }; cb(null, response); }; androidController.getContexts = function (cb) { this.listWebviews(function (err, webviews) { if (err) return cb(err); this.contexts = [NATIVE_WIN]; _.each(webviews, function (view, idx) { this.contexts.push(WEBVIEW_BASE + (idx + 1).toString()); }.bind(this)); logger.debug("Available contexts: " + this.contexts); cb(null, { status: status.codes.Success.code , value: this.contexts }); }.bind(this)); }; androidController.setContext = function (name, cb) { if (name === null) { name = this.defaultContext(); } else if (name === WEBVIEW_WIN) { name = WEBVIEW_BASE + "1"; } this.getContexts(function () { if (!_.contains(this.contexts, name)) { return cb(null, { status: status.codes.NoSuchContext.code , value: "Context '" + name + "' does not exist" }); } if (name === this.curContext) { return jwpSuccess(cb); } var next = function (err) { if (err) return cb(err); this.curContext = name; jwpSuccess(cb); }.bind(this); // current ChromeDriver doesn't handle more than a single web view if (name.indexOf(WEBVIEW_WIN) !== -1) { this.startChromedriverProxy(next); } else { this.stopChromedriverProxy(next); } }.bind(this)); }; androidController.getWindowHandle = function (cb) { cb(new NotYetImplementedError(), null); }; androidController.getWindowHandles = function (cb) { cb(new NotYetImplementedError(), null); }; androidController.setWindow = function (name, 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.startApp.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 (appPackage, cb) { var installationCommand = null; if (this.args.udid) { installationCommand = 'adb -s ' + this.args.udid + ' install ' + appPackage; } else { installationCommand = 'adb install ' + appPackage; } deviceCommon.installApp(installationCommand, this.args.udid, appPackage, 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) { this.proxy(["element:getLocation", {elementId: elementId}], function (err, res) { if (err) return cb(err); var value = res.value; x += parseInt(value.x); y += parseInt(value.y); opts = ["click", {x: x, y: y}]; loop(); }.bind(this)); } 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 + "'"); } }; // drag is *not* press-move-release, so we need to translate // drag works fine for scroll, as well var doTouchDrag = function (gestures, cb) { var longPress = gestures[0]; var elementId = longPress.options.element; this.getLocation(elementId, function (err, res) { if (err) return cb(err); var startX = res.value.x; var startY = res.value.y; var moveTo = gestures[1]; var destElId = moveTo.options.element; var endX = moveTo.options.x; var endY = moveTo.options.y; if (typeof destElId !== 'undefined' && destElId) { this.getLocation(destElId, function (err, res) { if (err) return cb(err); endX = res.value.x + (endX || 0); endY = res.value.y + (endY || 0); return this.drag(startX, startY, endX, endY, 1, 1, elementId, destElId, cb); }.bind(this)); } else { return this.drag(startX, startY, endX, endY, 1, 1, elementId, destElId, cb); } }.bind(this)); return; }; androidController.performTouch = function (gestures, cb) { // some things are special var actions = _.pluck(gestures, "action"); if (actions[0] === 'longPress' && actions[1] === 'moveTo' && actions[2] === 'release') { return doTouchDrag.apply(this, [gestures, cb]); } else if ((actions[actions.length - 2] === 'tap' || actions[actions.length - 2] === 'longPress') && actions[actions.length - 1] === 'release') { // the `longPress` and `tap` methods release on their own gestures.pop(); } var cycleThroughGestures = function (err, res) { if (err) return cb(err); var gesture = gestures.shift(); if (typeof gesture === "undefined") return cb(null, res); var action = gesture.action; var options = gesture.options || {}; this.doTouchAction(action, options, cycleThroughGestures); }.bind(this); cycleThroughGestures(); }; androidController.parseTouch = function (gestures, cb) { if (_.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 tapPoint = false; if (needsPoint(gesture.action)) { // press, longPress, moveTo and tap all need a position 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) { tapPoint = { offset: false, x: pos.x + (gesture.options.x || 0), y: pos.y + (gesture.options.y || 0) }; } else { tapPoint = { offset: false, x: pos.x + (size.w / 2), y: pos.y + (size.h / 2) }; } var touchStateObject = { timeOffset: 0.005, touch: tapPoint }; 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 tapPoint = { offset: true, x: (gesture.options.x || 0), y: (gesture.options.y || 0) }; touchStateObject = { timeOffset: 0.005, touch: tapPoint }; touchStateObjects.push(touchStateObject); done(); } } else { // in this case we need the previous entry's tap point tapPoint = false; // temporary marker var offset = 0.005; if (gesture.action === 'wait') { if (typeof gesture.options.ms !== 'undefined' || gesture.options.ms !== null) { offset = (parseInt(gesture.options.ms) / 1000); } } var touchStateObject = { timeOffset: offset, touch: tapPoint }; 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 (state.touch === false) { // if we have no position (this happens with `wait`) we need the previous one state.touch = prevPos; } else if (state.touch.offset && prevPos) { // the current position is an offset state.touch.x += prevPos.x; state.touch.y += prevPos.y; } delete state.touch.offset; prevPos = state.touch; var timeOffset = state.timeOffset; time += timeOffset; state.time = helpers.truncateDecimals(time, 3); 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, 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)); }; module.exports = androidController;