diff --git a/lib/devices/android/adb.js b/lib/devices/android/adb.js
index af509ae2c7e..68185791961 100644
--- a/lib/devices/android/adb.js
+++ b/lib/devices/android/adb.js
@@ -591,6 +591,21 @@ ADB.prototype.isDeviceConnected = function(cb) {
});
};
+/*
+ * Check whether the ADB connection is up
+ */
+ADB.prototype.ping = function(cb) {
+ this.shell("echo 'ping'", function(err, stdout) {
+ if (!err && stdout.indexOf("ping") === 0) {
+ cb(null, true);
+ } else if (err) {
+ cb(err);
+ } else {
+ cb(new Error("ADB ping failed, returned: " + stdout));
+ }
+ });
+};
+
ADB.prototype.setDeviceId = function(deviceId) {
this.curDeviceId = deviceId;
this.adbCmd = this.adb + " -s " + deviceId;
@@ -661,6 +676,16 @@ ADB.prototype.restartAdb = function(cb) {
});
};
+
+ADB.prototype.restart = function(cb) {
+ async.series([
+ this.stopLogcat.bind(this)
+ , this.restartAdb.bind(this)
+ , this.waitForDevice.bind(this)
+ , this.startLogcat.bind(this)
+ ], cb);
+};
+
ADB.prototype.startLogcat = function(cb) {
if (this.logcat !== null) {
cb(new Error("Trying to start logcat capture but it's already started!"));
@@ -678,6 +703,7 @@ ADB.prototype.startLogcat = function(cb) {
ADB.prototype.stopLogcat = function(cb) {
if (this.logcat !== null) {
this.logcat.stopCapture(cb);
+ this.logcat = null;
}
};
diff --git a/lib/devices/android/android-common.js b/lib/devices/android/android-common.js
index 6c96f464de8..8427f938242 100644
--- a/lib/devices/android/android-common.js
+++ b/lib/devices/android/android-common.js
@@ -5,7 +5,9 @@ var logger = require('../../server/logger.js').get('appium')
, fs = require('fs')
, path = require('path')
, md5 = require('md5calculator')
- , async = require('async');
+ , async = require('async')
+ , errors = require('../../server/errors.js')
+ , NotYetImplementedError = errors.NotYetImplementedError;
var logTypesSupported = {
'logcat' : 'Logs for Android applications on real device and emulators ' +
@@ -34,6 +36,163 @@ androidCommon.background = function(secs, cb) {
}.bind(this));
};
+androidCommon.openSettingsActivity = function(setting, cb) {
+ this.adb.getFocusedPackageAndActivity(function(err, foundPackage,
+ foundActivity) {
+ var cmd = 'am start -a android.settings.' + setting;
+ this.adb.shell(cmd, function(err) {
+ if (err) {
+ cb(err);
+ } else {
+ this.adb.waitForNotActivity(foundPackage, foundActivity, 5000, cb);
+ }
+ }.bind(this));
+ }.bind(this));
+};
+
+androidCommon.toggleSetting = function(setting, preKeySeq, ocb) {
+ var doKey = function(key) {
+ return function(cb) {
+ setTimeout(function() {
+ this.adb.keyevent(key, cb);
+ }.bind(this), 2000);
+ }.bind(this);
+ }.bind(this);
+
+ var settPkg, settAct;
+
+ var back = function(cb) {
+ this.adb.back(function(err) {
+ if (err) {
+ cb(err);
+ } else {
+ this.adb.waitForNotActivity(settPkg, settAct, 5000, cb);
+ }
+ }.bind(this));
+ }.bind(this);
+
+ /*
+ * preKeySeq is the keyevent sequence to send over ADB in order
+ * to position the cursor on the right option.
+ * By default it's [up, up, down] because we usually target the 1st item in
+ * the screen, and sometimes when opening settings activities the cursor is
+ * already positionned on the 1st item, but we can't know for sure
+ */
+ if (preKeySeq === null) preKeySeq = [19, 19, 20]; // up, up, down
+
+ var sequence = [
+ function(cb) {
+ this.openSettingsActivity(setting, cb);
+ }.bind(this)
+ ];
+ var len = preKeySeq.length;
+
+ for(var i = 0; i < len; i++) {
+ sequence.push(doKey(preKeySeq[i]));
+ }
+
+ sequence.push(
+ function(cb) {
+ this.adb.getFocusedPackageAndActivity(function(err, foundPackage,
+ foundActivity) {
+ settPkg = foundPackage;
+ settAct = foundActivity;
+ cb(err);
+ }.bind(this));
+ }.bind(this)
+ , function(cb) {
+ /*
+ * Click and handle potential ADB disconnect that occurs on official
+ * emulator when the network connection is disabled
+ */
+ this.wrapActionAndHandleADBDisconnect(doKey(23), cb);
+ }.bind(this)
+ , function(cb) {
+ /*
+ * In one particular case (enable Location Services), a pop-up is
+ * displayed on some platforms so the user accepts or refuses that Google
+ * collects location data. So we wait for that pop-up to open, if it
+ * doesn't then proceed
+ */
+ this.adb.waitForNotActivity(settPkg, settAct, 5000, function(err) {
+ if (err) {
+ cb(null);
+ } else {
+ // Click on right button, "Accept"
+ async.series([
+ doKey(22) // right
+ , doKey(23) // click
+ , function(cb) {
+ // Wait for pop-up to close
+ this.adb.waitForActivity(settPkg, settAct, 5000, cb);
+ }.bind(this)
+ ], function(err) {
+ cb(err);
+ }.bind(this));
+ }
+ }.bind(this));
+ }.bind(this)
+ , back
+ );
+
+ async.series(sequence, function(err) {
+ if (err) return ocb(err);
+ ocb(null, { status: status.codes.Success.code });
+ }.bind(this));
+};
+
+androidCommon.toggleData = function(ocb) {
+ // up, up, down
+ this.toggleSetting('DATA_ROAMING_SETTINGS', [19, 19, 20], ocb);
+};
+
+androidCommon.toggleFlightMode = function(ocb) {
+ this.adb.getApiLevel(function(err, api) {
+ var seq = [19, 19]; // up, up
+ /*
+ * On Android 4.0 there's no "parent" button in the action bar, so we don't
+ * need to go down, the cursor is already at the top of the list
+ */
+ if (api > 15) {
+ seq.push(20); // down
+ }
+ this.toggleSetting('AIRPLANE_MODE_SETTINGS', seq, ocb);
+ }.bind(this));
+};
+
+androidCommon.toggleWiFi = function(ocb) {
+ // right, right
+ this.toggleSetting('WIFI_SETTINGS', [22, 22], ocb);
+};
+
+androidCommon.toggleLocationServices = function(ocb) {
+ this.adb.getApiLevel(function(err, api) {
+ if (api > 15) {
+ var seq = [19, 19]; // up, up
+ if (api === 16) {
+ // This version of Android has a "parent" button in its action bar
+ seq.push(20); // down
+ } else if (api >= 19) {
+ // Newer versions of Android have the toggle in the Action bar
+ seq = [22, 22]; // right, right
+ /*
+ * Once the Location services switch is OFF, it won't receive focus
+ * when going back to the Location Services settings screen unless we
+ * send a dummy keyevent (UP) *before* opening the settings screen
+ */
+ this.adb.keyevent(19, function(err) {
+ this.toggleSetting('LOCATION_SOURCE_SETTINGS', seq, ocb);
+ }.bind(this));
+ return;
+ }
+ this.toggleSetting('LOCATION_SOURCE_SETTINGS', seq, ocb);
+ } else {
+ // There's no global location services toggle on older Android versions
+ ocb(new NotYetImplementedError(), null);
+ }
+ }.bind(this));
+};
+
androidCommon.prepareDevice = function(onReady) {
logger.info("Preparing device for session");
async.series([
diff --git a/lib/devices/android/android.js b/lib/devices/android/android.js
index e4fb228fa4a..c606f3bfeba 100644
--- a/lib/devices/android/android.js
+++ b/lib/devices/android/android.js
@@ -46,6 +46,8 @@ Android.prototype.initialize = function(opts) {
this.shuttingDown = false;
this.adb = null;
this.uiautomator = null;
+ this.uiautomatorRestartOnExit = false;
+ this.uiautomatorIgnoreExit = false;
this.swipeStepsPerSec = 28;
this.dragStepsPerSec = 40;
this.asyncWaitMs = 0;
@@ -99,6 +101,7 @@ Android.prototype.start = function(cb, onDie) {
this.launchCb(err);
}.bind(this));
} else {
+ this.didLaunch = true;
this.launchCb();
}
}.bind(this));
@@ -135,6 +138,31 @@ Android.prototype.onLaunch = function(err) {
}
};
+Android.prototype.restartUiautomator = function(cb) {
+ async.series([
+ this.forwardPort.bind(this)
+ , this.uiautomator.start.bind(this.uiautomator)
+ ], cb);
+};
+
+/*
+ * Execute an arbitrary function and handle potential ADB disconnection before
+ * proceeding
+ */
+Android.prototype.wrapActionAndHandleADBDisconnect = function(action, ocb) {
+ async.series([
+ function(cb) {
+ this.uiautomatorIgnoreExit = true;
+ action(cb);
+ }.bind(this)
+ , this.adb.restart.bind(this.adb)
+ , this.restartUiautomator.bind(this)
+ ], function(err) {
+ this.uiautomatorIgnoreExit = false;
+ ocb(err);
+ }.bind(this));
+};
+
Android.prototype.onUiautomatorExit = function() {
var respondToClient = function() {
this.cleanup();
@@ -153,11 +181,41 @@ Android.prototype.onUiautomatorExit = function() {
}.bind(this);
if (this.adb) {
- logger.info("Attempting to uninstall app");
- this.uninstallApp(function() {
- this.shuttingDown = false;
- respondToClient();
- }.bind(this));
+ var uninstall = function() {
+ logger.info("Attempting to uninstall app");
+ this.uninstallApp(function() {
+ this.shuttingDown = false;
+ respondToClient();
+ }.bind(this));
+ }.bind(this);
+
+ if (!this.uiautomatorIgnoreExit) {
+ this.adb.ping(function(err, ok) {
+ if (ok) {
+ uninstall();
+ } else {
+ logger.debug(err);
+ this.adb.restart(function(err) {
+ if (err) {
+ logger.debug(err);
+ }
+ if (this.uiautomatorRestartOnExit) {
+ this.uiautomatorRestartOnExit = false;
+ this.restartUiautomator(function(err) {
+ if (err) {
+ logger.debug(err);
+ uninstall();
+ }
+ }.bind(this));
+ } else {
+ uninstall();
+ }
+ }.bind(this.adb));
+ }
+ }.bind(this));
+ } else {
+ this.uiautomatorIgnoreExit = false;
+ }
} else {
logger.info("We're in uiautomator's exit callback but adb is gone already");
respondToClient();
diff --git a/lib/devices/android/logcat.js b/lib/devices/android/logcat.js
index 43cb8278dbd..2b283a4019e 100644
--- a/lib/devices/android/logcat.js
+++ b/lib/devices/android/logcat.js
@@ -35,12 +35,21 @@ Logcat.prototype.startCapture = function(cb) {
cb(err);
}
}.bind(this));
+ this.proc.on('exit', function(code, signal) {
+ logger.debug('Logcat terminated with code ' + code + ', signal ' + signal);
+ this.proc = null;
+ }.bind(this));
this.proc.stdout.pipe(through(this.onStdout.bind(this)));
this.proc.stderr.pipe(through(this.onStderr.bind(this)));
};
Logcat.prototype.stopCapture = function(cb) {
logger.info("Stopping logcat capture");
+ if(this.proc === null) {
+ logger.debug("Logcat already stopped");
+ cb();
+ return;
+ }
this.proc.on('exit', function() {
cb();
});
diff --git a/lib/devices/android/selendroid.js b/lib/devices/android/selendroid.js
index ace9a47d441..8ce1b0381c9 100644
--- a/lib/devices/android/selendroid.js
+++ b/lib/devices/android/selendroid.js
@@ -48,6 +48,10 @@ var Selendroid = function(opts) {
, 'closeApp'
, 'isAppInstalled'
, 'launchApp'
+ , 'toggleData'
+ , 'toggleFlightMode'
+ , 'toggleWiFi'
+ , 'toggleLocationServices'
];
this.proxyHost = 'localhost';
this.proxyPort = opts.systemPort;
@@ -180,6 +184,22 @@ Selendroid.prototype.keyevent = function(keycode, metastate, cb) {
});
};
+/*
+ * Execute an arbitrary function and handle potential ADB disconnection before
+ * proceeding
+ */
+Selendroid.prototype.wrapActionAndHandleADBDisconnect = function(action, ocb) {
+ async.series([
+ function(cb) {
+ action(cb);
+ }.bind(this)
+ , this.adb.restart.bind(this.adb)
+ , this.forwardPort.bind(this)
+ ], function(err) {
+ ocb(err);
+ }.bind(this));
+};
+
Selendroid.prototype.ensureServerExists = function(cb) {
logger.info("Checking whether selendroid is built yet");
var selBin = path.resolve(__dirname, "..", "..", "..", "build", "selendroid",
diff --git a/lib/devices/android/uiautomator.js b/lib/devices/android/uiautomator.js
index 4a75c66870d..a42c3c9e094 100644
--- a/lib/devices/android/uiautomator.js
+++ b/lib/devices/android/uiautomator.js
@@ -35,6 +35,7 @@ UiAutomator.prototype.start = function(readyCb) {
return readyCb(new Error("Could not start adb, is it around?"));
}
+ this.alreadyExited = false;
this.onSocketReady = readyCb;
this.proc.stdout.on('data', this.outputStreamHandler.bind(this));
diff --git a/lib/devices/ios/ios-controller.js b/lib/devices/ios/ios-controller.js
index 4d442c8996c..752e34dcc0d 100644
--- a/lib/devices/ios/ios-controller.js
+++ b/lib/devices/ios/ios-controller.js
@@ -266,6 +266,22 @@ iOSController.touchLongClick = function(elementId, cb) {
cb(new NotYetImplementedError(), null);
};
+iOSController.toggleData = function(cb) {
+ cb(new NotYetImplementedError(), null);
+};
+
+iOSController.toggleFlightMode = function(cb) {
+ cb(new NotYetImplementedError(), null);
+};
+
+iOSController.toggleWiFi = function(cb) {
+ cb(new NotYetImplementedError(), null);
+};
+
+iOSController.toggleLocationServices = function(cb) {
+ cb(new NotYetImplementedError(), null);
+};
+
iOSController.getStrings = function(cb) {
var strings = this.localizableStrings;
if (strings && strings.length >= 1) strings = strings[0];
diff --git a/lib/server/controller.js b/lib/server/controller.js
index c3595aea218..04f9afb3e01 100644
--- a/lib/server/controller.js
+++ b/lib/server/controller.js
@@ -924,6 +924,22 @@ exports.localScreenshot = function(req, res) {
}
};
+exports.toggleData = function(req, res) {
+ req.device.toggleData(getResponseHandler(req, res));
+};
+
+exports.toggleFlightMode = function(req, res) {
+ req.device.toggleFlightMode(getResponseHandler(req, res));
+};
+
+exports.toggleWiFi = function(req, res) {
+ req.device.toggleWiFi(getResponseHandler(req, res));
+};
+
+exports.toggleLocationServices = function(req, res) {
+ req.device.toggleLocationServices(getResponseHandler(req, res));
+};
+
exports.notYetImplemented = notYetImplemented;
var mobileCmdMap = {
'tap': exports.mobileTap
@@ -960,6 +976,10 @@ var mobileCmdMap = {
, 'pinchOpen': exports.mobilePinchOpen
, 'localScreenshot': exports.localScreenshot
, 'getStrings': exports.getStrings
+ , 'toggleData': exports.toggleData
+ , 'toggleFlightMode': exports.toggleFlightMode
+ , 'toggleWiFi': exports.toggleWiFi
+ , 'toggleLocationServices': exports.toggleLocationServices
};
exports.produceError = function(req, res) {
diff --git a/sample-code/apps/ToggleTest/.classpath b/sample-code/apps/ToggleTest/.classpath
new file mode 100644
index 00000000000..7bc01d9a9c6
--- /dev/null
+++ b/sample-code/apps/ToggleTest/.classpath
@@ -0,0 +1,9 @@
+
+