"use strict"; var logger = require('../../server/logger.js').get('appium') , _ = require('underscore') , errors = require('../../server/errors.js') , UnknownError = errors.UnknownError , async = require('async') , Chromedriver = require('appium-chromedriver') , status = require("../../server/status.js"); var androidHybrid = {}; androidHybrid.chromedriver = null; androidHybrid.sessionChromedrivers = {}; androidHybrid.listWebviews = function (cb) { logger.debug("Getting a list of available webviews"); var webviews = []; var definedDeviceSocket = this.args.androidDeviceSocket; this.adb.shell("cat /proc/net/unix", function (err, out) { if (err) return cb(err); _.each(out.split("\n"), function (line) { line = line.trim(); var webviewPid = line.match(/@?webview_devtools_remote_(\d+)/); if (definedDeviceSocket) { if (line.indexOf("@" + definedDeviceSocket) === line.length - definedDeviceSocket.length - 1) { if (webviewPid) { webviews.push(this.WEBVIEW_BASE + webviewPid[1]); } else { webviews.push(this.CHROMIUM_WIN); } } } else if (webviewPid) { // for multiple webviews a list of 'WEBVIEW_' will be returned // where is zero based (same is in selendroid) webviews.push(this.WEBVIEW_BASE + webviewPid[1]); } }.bind(this)); webviews = _.uniq(webviews); if (definedDeviceSocket) { return cb(null, webviews); } var webviewsTmp = webviews; webviews = []; var getProcessNameFromWebview = function (view, cb) { this.getProcessNameFromWebview(view, function (err, pkg) { if (err) return cb(err); webviews.push(this.WEBVIEW_BASE + pkg); cb(); }.bind(this)); }.bind(this); async.each(webviewsTmp, getProcessNameFromWebview, function (err) { if (err) return cb(err); logger.debug("Available contexts: " + this.contexts); logger.debug(JSON.stringify(webviews)); cb(null, webviews); }.bind(this)); }.bind(this)); }; var previousState = {}; // remember whether we were previously proxying to a chromedriver or not androidHybrid.rememberProxyState = function () { previousState.isProxy = this.isProxy; }; androidHybrid.restoreProxyState = function () { this.isProxy = previousState.isProxy; }; androidHybrid.getProcessNameFromWebview = function (webview, cb) { // webview_devtools_remote_4296 => 4296 var pid = webview.match(/\d+$/); if (!pid) return cb("No pid for webview " + webview); pid = pid[0]; logger.debug(webview + " mapped to pid " + pid); logger.debug("Getting process name for webview"); this.adb.shell("ps", function (err, out) { if (err) return cb(err); var pkg = "unknown"; var lines = out.split(/\r?\n/); /* USER PID PPID VSIZE RSS WCHAN PC NAME u0_a136 6248 179 946000 48144 ffffffff 4005903e R com.example.test */ var header = lines[0].trim().split(/\s+/); // the column order may not be identical on all androids // dynamically locate the pid and name column. var pidColumn = header.indexOf("PID"); var pkgColumn = header.indexOf("NAME") + 1; _.find(lines, function (line) { line = line.trim().split(/\s+/); if (line[pidColumn].indexOf(pid) !== -1) { logger.debug("Parsed pid: " + line[pidColumn] + " pkg: " + line[pkgColumn]); logger.debug("from: " + line); pkg = line[pkgColumn]; return pkg; // exit from _.find } }); logger.debug("returning process name: " + pkg); cb(null, pkg); }); }; androidHybrid.startChromedriverProxy = function (context, cb) { cb = _.once(cb); logger.debug("Connecting to chrome-backed webview"); if (this.chromedriver !== null) { return cb(new Error("We already have a chromedriver instance running")); } if (this.sessionChromedrivers[context]) { // in the case where we've already set up a chromedriver for a context, // we want to reconnect to it, not create a whole new one this.setupExistingChromedriver(context, cb); } else { this.setupNewChromedriver(context, cb); } }; androidHybrid.setupNewChromedriver = function (context, cb) { var chromeArgs = { port: this.args.chromeDriverPort, executable: this.args.chromedriverExecutable }; this.chromedriver = new Chromedriver(chromeArgs); this.proxyReqRes = this.chromedriver.proxyReq.bind(this.chromedriver); this.rememberProxyState(); this.isProxy = true; var caps = { chromeOptions: { androidPackage: this.args.appPackage, androidUseRunningApp: true } }; if (this.args.enablePerformanceLogging) { caps.loggingPrefs = {performance: 'ALL'}; } // For now the only known arg passed this way is androidDeviceSocket used // by Operadriver (deriving from Chromedriver) // We don't know how other // Chromium embedders will call this argument so for now it's name needs to // be configurable. When Google adds the androidDeviceSocket argument to // the original Chromedriver then we will be sure about its name for all // Chromium embedders (as their Webdrivers will derive from Chromedriver) if (this.args.specialChromedriverSessionArgs) { _.each(this.args.specialChromedriverSessionArgs, function (val, option) { logger.debug("This method is being deprecated. Apply chromeOptions " + "normally to pass along options,see sites.google.com/a/" + "chromium.org/chromedriver/capabilities for more info"); caps.chromeOptions[option] = val; }); } caps = this.decorateChromeOptions(caps); this.chromedriver.on(Chromedriver.EVENT_CHANGED, function (msg) { if (msg.state === Chromedriver.STATE_STOPPED) { // bind our stop/exit handler, passing in context so we know which // one stopped unexpectedly this.onChromedriverStop(context); } }.bind(this)); this.chromedriver.start(caps).nodeify(function (err) { if (err) return cb(err); // save the chromedriver object under the context this.sessionChromedrivers[context] = this.chromedriver; cb(); }.bind(this)); }; androidHybrid.setupExistingChromedriver = function (context, cb) { logger.debug("Found existing Chromedriver for context '" + context + "'." + " Using it."); this.rememberProxyState(); this.chromedriver = this.sessionChromedrivers[context]; this.proxyReqRes = this.chromedriver.proxyReq.bind(this.chromedriver); this.isProxy = true; // check the status by sending a simple window-based command to ChromeDriver // if there is an error, we want to recreate the ChromeDriver session this.chromedriver.hasWorkingWebview().nodeify(function (err, works) { if (err) return cb(err); if (works) return cb(); logger.debug("ChromeDriver is not associated with a window. " + "Re-initializing the session."); this.chromedriverRestartingContext = context; this.chromedriver.restart().nodeify(function (err) { if (err) return cb(err); this.chromedriverRestartingContext = null; cb(); }.bind(this)); }.bind(this)); }; androidHybrid.onChromedriverStop = function (context) { logger.warn("Chromedriver for context " + context + " stopped unexpectedly"); if (context === this.curContext) { // if we don't have a stop callback, we exited unexpectedly and so want // to shut down the session and respond with an error // TODO: this kind of thing should be emitted and handled by a higher-level // controlling function var error = new UnknownError("Chromedriver quit unexpectedly during session"); logger.error(error.message); if (typeof this.cbForCurrentCmd === "function") { this.shutdown(function () { this.cbForCurrentCmd(error, null); }.bind(this)); } } else if (context !== this.chromedriverRestartingContext) { // if a Chromedriver in the non-active context barfs, we don't really // care, we'll just make a new one next time we need the context. // The only time we ignore this is if we know we're in the middle of a // Chromedriver restart logger.warn("Chromedriver quit unexpectedly, but it wasn't the active " + "context, ignoring"); delete this.sessionChromedrivers[context]; } }; androidHybrid.suspendChromedriverProxy = function (cb) { this.chromedriver = null; this.restoreProxyState(); cb(); }; androidHybrid.stopChromedriverProxies = function (ocb) { async.eachSeries(Object.keys(this.sessionChromedrivers), function (context, cb) { logger.debug("Stopping chromedriver for context " + context); // stop listening for the stopped state event this.sessionChromedrivers[context].removeAllListeners(Chromedriver.EVENT_CHANGED); this.sessionChromedrivers[context].stop().nodeify(function (err) { if (err) logger.warn("Error stopping Chromedriver: " + err.message); // chromedriver isn't valid anymore, so remove it from context list delete this.sessionChromedrivers[context]; cb(); }.bind(this)); }.bind(this), function (err) { // if one of these fails, go back to last proxy state and error out this.restoreProxyState(); ocb(err); }.bind(this)); }; androidHybrid.defaultWebviewName = function () { return this.WEBVIEW_BASE + this.appProcess; }; androidHybrid.initAutoWebview = function (cb) { if (this.args.autoWebview) { logger.debug('Setting auto webview'); var viewName = this.defaultWebviewName(); var timeout = (this.args.autoWebviewTimeout) || 2000; this.setContext(viewName, function (err, res) { if (err && res.status !== status.codes.NoSuchContext.code) return cb(err); if (res.status === status.codes.Success.code) return cb(); setTimeout(function () { logger.debug("Retrying context switch with timeout '" + timeout + "'"); this.setContext(viewName, cb); }.bind(this), timeout); }.bind(this)); } else { cb(); } }; module.exports = androidHybrid;