Bug 1525126 - [geckodriver] Add Android support. r=jgraham,nalexander
authorHenrik Skupin <mail@hskupin.info>
Wed, 25 Sep 2019 23:00:40 +0000
changeset 495007 8ff6cdd2e04a3edf2fdefabea40c8a4b5e3fbd74
parent 495006 e20ffbef6a295e9f9b1302f2dd3b9ca92bbad2b0
child 495008 9fafa0d11bfb3085dbfb74d4b780db4e88e2d833
push id114131
push userdluca@mozilla.com
push dateThu, 26 Sep 2019 09:47:34 +0000
treeherdermozilla-inbound@1dc1a755079a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjgraham, nalexander
bugs1525126
milestone71.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1525126 - [geckodriver] Add Android support. r=jgraham,nalexander This patch allows geckodriver to interact with processes of GeckoView packages on Android devices while running itself on a host machine. The connection to and from the application under test is done via adb forward ports. Depends on D45363 Differential Revision: https://phabricator.services.mozilla.com/D44897
Cargo.lock
testing/geckodriver/Cargo.toml
testing/geckodriver/doc/Building.md
testing/geckodriver/doc/Capabilities.md
testing/geckodriver/doc/Support.md
testing/geckodriver/moz.build
testing/geckodriver/src/android.rs
testing/geckodriver/src/capabilities.rs
testing/geckodriver/src/command.rs
testing/geckodriver/src/logging.rs
testing/geckodriver/src/main.rs
testing/geckodriver/src/marionette.rs
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1138,16 +1138,17 @@ version = "0.26.0-alpha.0"
 dependencies = [
  "base64 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)",
  "chrono 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
  "clap 2.31.2 (registry+https://github.com/rust-lang/crates.io-index)",
  "hyper 0.12.19 (registry+https://github.com/rust-lang/crates.io-index)",
  "lazy_static 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
  "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
  "marionette 0.1.0",
+ "mozdevice 0.1.0",
  "mozprofile 0.6.0",
  "mozrunner 0.10.0",
  "mozversion 0.2.1",
  "regex 1.1.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "serde 1.0.88 (registry+https://github.com/rust-lang/crates.io-index)",
  "serde_derive 1.0.88 (registry+https://github.com/rust-lang/crates.io-index)",
  "serde_json 1.0.26 (registry+https://github.com/rust-lang/crates.io-index)",
  "uuid 0.7.4 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1836,16 +1837,26 @@ version = "0.1.0"
 dependencies = [
  "libc 0.2.60 (registry+https://github.com/rust-lang/crates.io-index)",
  "nserror 0.1.0",
  "nsstring 0.1.0",
  "xpcom 0.1.0",
 ]
 
 [[package]]
+name = "mozdevice"
+version = "0.1.0"
+dependencies = [
+ "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
+ "regex 1.1.9 (registry+https://github.com/rust-lang/crates.io-index)",
+ "tempfile 3.0.5 (registry+https://github.com/rust-lang/crates.io-index)",
+ "walkdir 2.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
+[[package]]
 name = "mozilla-central-workspace-hack"
 version = "0.1.0"
 dependencies = [
  "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
  "proc-macro2 0.4.27 (registry+https://github.com/rust-lang/crates.io-index)",
  "quote 0.6.11 (registry+https://github.com/rust-lang/crates.io-index)",
  "serde 1.0.88 (registry+https://github.com/rust-lang/crates.io-index)",
  "serde_derive 1.0.88 (registry+https://github.com/rust-lang/crates.io-index)",
--- a/testing/geckodriver/Cargo.toml
+++ b/testing/geckodriver/Cargo.toml
@@ -11,16 +11,17 @@ publish = false
 [dependencies]
 base64 = "0.10"
 chrono = "0.4.6"
 clap = { version = "^2.19", default-features = false, features = ["suggestions", "wrap_help"] }
 hyper = "0.12"
 lazy_static = "1.0"
 log = { version = "0.4", features = ["std"] }
 marionette = { path = "./marionette" }
+mozdevice = { path = "../mozbase/rust/mozdevice" }
 mozprofile = { path = "../mozbase/rust/mozprofile" }
 mozrunner = { path = "../mozbase/rust/mozrunner" }
 mozversion = { path = "../mozbase/rust/mozversion" }
 regex = "1.0"
 serde = "1.0"
 serde_json = "1.0"
 serde_derive = "1.0"
 uuid = { version = "0.7", features = ["v4"] }
--- a/testing/geckodriver/doc/Building.md
+++ b/testing/geckodriver/doc/Building.md
@@ -24,17 +24,17 @@ since mach in this case does not have a 
 
 Because all Rust code in central shares the same cargo workspace,
 the binary will be put in the `$(topsrcdir)/target` directory.
 
 You can run your freshly built geckodriver this way:
 
 	% ./mach geckodriver -- --other --flags
 
-See <Testing.md> for how to run tests.
+See [Testing](Testing.html) for how to run tests.
 
 [Rust]: https://www.rust-lang.org/
 [webdriver crate]: https://crates.io/crates/webdriver
 [commands]: https://docs.rs/webdriver/newest/webdriver/command/
 [responses]: https://docs.rs/webdriver/newest/webdriver/response/
 [errors]: https://docs.rs/webdriver/newest/webdriver/error/enum.ErrorStatus.html
 [Marionette protocol]: /testing/marionette/doc/marionette/Protocol.html
 [WebDriver]: https://w3c.github.io/webdriver/
--- a/testing/geckodriver/doc/Capabilities.md
+++ b/testing/geckodriver/doc/Capabilities.md
@@ -1,35 +1,44 @@
-Firefox capabilities
-====================
+# Firefox capabilities
 
 geckodriver has a few capabilities that are specific to Firefox.
 
 
-`moz:firefoxOptions`
---------------------
+## `moz:firefoxOptions`
 
 A dictionary used to define options which control how Firefox gets
 started and run. It may contain any of the following fields:
 
 <style type="text/css">
   table { width: 100%; margin-bottom: 2em; }
   table, th, td { border: solid gray 1px; }
   td, th { padding: 5px 10px; }
 </style>
 
-<table>
+<table id="capabilities-common">
  <thead>
   <tr>
    <th>Name
    <th>Type
    <th>Description
   </tr>
  </thead>
 
+ <tr id=capability-args>
+  <td><code>args</code>
+  <td align="center">array&nbsp;of&nbsp;strings
+  <td><p>Command line arguments to pass to the Firefox binary.
+   These must include the leading dash (<code>-</code>) where required,
+   e.g. <code>["-devtools"]</code>.
+
+   <p>To have geckodriver pick up an existing profile on the filesystem,
+    you may pass <code>["-profile", "/path/to/profile"]</code>.
+ </tr>
+
  <tr id=capability-binary>
   <td><code>binary</code>
   <td align="center">string
   <td><p>
    Path to the Firefox executable to select which custom browser binary to use.
    If left undefined geckodriver will attempt
    to deduce the default location of Firefox on the current system.
    If Firefox stable is not installed, it will suggest
@@ -39,25 +48,30 @@ started and run. It may contain any of t
   On macOS the path can either be for the application bundle,
   e.g. <code>/Applications/Firefox.app</code>
   or <code>/Applications/Firefox Nightly.app</code>,
   or point at the executable absolute path inside the bundle,
   e.g. <code>/Applications/Firefox.app/Contents/MacOS/firefox</code>
   or <code>/Applications/Firefox Nightly.app/Contents/MacOS/firefox</code>.
  </tr>
 
- <tr id=capability-args>
-  <td><code>args</code>
-  <td align="center">array&nbsp;of&nbsp;strings
-  <td><p>Command line arguments to pass to the Firefox binary.
-   These must include the leading dash (<code>-</code>) where required,
-   e.g. <code>["-devtools"]</code>.
+ <tr id=capability-log>
+  <td><code>log</code>
+  <td align="center"><a href=#log-object><code>log</code></a>&nbsp;object
+  <td>To increase the logging verbosity of geckodriver and Firefox,
+   you may pass a <a href=#log-object><code>log</code> object</a>
+   that may look like <code>{"log": {"level": "trace"}}</code>
+   to include all trace-level logs and above.
+ </tr>
 
-   <p>To have geckodriver pick up an existing profile on the filesystem,
-    you may pass <code>["-profile", "/path/to/profile"]</code>.
+ <tr id=capability-prefs>
+  <td><code>prefs</code>
+  <td align="center"><a href=#prefs-object><code>prefs</code></a>&nbsp;object
+  <td>Map of preference name to preference value, which can be a
+   string, a boolean or an integer.
  </tr>
 
  <tr id=capability-profile>
   <td><code>profile</code>
   <td align="center">string
   <td><p>Base64-encoded ZIP of a profile directory to use for the Firefox instance.
    This may be used to e.g. install extensions or custom certificates,
    but for setting custom preferences
@@ -72,55 +86,82 @@ started and run. It may contain any of t
    <p>The effective profile in use by the WebDriver session
     is returned to the user in the <code>moz:profile</code> capability
     in the new session response.
 
    <p>To have geckodriver pick up an <em>existing profile</em> on the filesystem,
     please set the <a href=#capability-args><code>args</code></a> field
     to <code>{"args": ["-profile", "/path/to/your/profile"]}</code>.
  </tr>
+</table>
 
- <tr id=capability-log>
-  <td><code>log</code>
-  <td align="center"><a href=#log-object><code>log</code></a>&nbsp;object
-  <td>To increase the logging verbosity of geckodriver and Firefox,
-   you may pass a <a href=#log-object><code>log</code> object</a>
-   that may look like <code>{"log": {"level": "trace"}}</code>
-   to include all trace-level logs and above.
+### Android
+
+Starting with geckodriver 0.26.0 additional capabilities exist if Firefox
+or an application embedding [GeckoView] has to be controlled on Android:
+
+<table id="capabilities-android">
+ <thead>
+  <tr>
+   <th>Name
+   <th>Type
+   <th>Optional
+   <th>Description
+  </tr>
+ </thead>
+
+ <tr id=capability-androidPackage>
+  <td><code>androidPackage</code>
+  <td align="center">string
+  <td align="center">no
+  <td><p>
+    The package name of the application embedding GeckoView, eg.
+    <code>org.mozilla.geckoview_example</code>.
  </tr>
 
- <tr id=capability-prefs>
-  <td><code>prefs</code>
-  <td align="center"><a href=#prefs-object><code>prefs</code></a>&nbsp;object
-  <td>Map of preference name to preference value, which can be a
-   string, a boolean or an integer.
+ <tr id=capability-androidActivity>
+  <td><code>androidActivity</code>
+  <td align="center">string
+  <td align="center">yes
+  <td><p>
+    The fully qualified class name of the activity to be launched, eg.
+    <code>.GeckoViewActivity</code>.
+    If not specified the package's default activity will be used.
+ </tr>
+
+ <tr id=capability-androidDeviceSerial>
+  <td><code>androidDeviceSerial</code>
+  <td align="center">string
+  <td align="center">yes
+  <td><p>
+    The serial number of the device on which to launch the application.
+    If not specified and multiple devices are attached, an error will be raised.
  </tr>
 </table>
 
+[GeckoView]: https://wiki.mozilla.org/Mobile/GeckoView
 
-`moz:useNonSpecCompliantPointerOrigin`
---------------------------------------
+## `moz:useNonSpecCompliantPointerOrigin`
 
 A boolean value to indicate how the pointer origin for an action
 command will be calculated.
 
 With Firefox 59 the calculation will be based on the requirements by
 the [WebDriver] specification. This means that the pointer origin
 is no longer computed based on the top and left position of the
 referenced element, but on the in-view center point.
 
 To temporarily disable the WebDriver conformant behavior use `false`
 as value for this capability.
 
 Please note that this capability exists only temporarily, and that
 it will be removed once all Selenium bindings can handle the new behavior.
 
 
-`moz:webdriverClick`
---------------------
+## `moz:webdriverClick`
 
 A boolean value to indicate which kind of interactability checks
 to run when performing a click or sending keys to an elements. For
 Firefoxen prior to version 58.0 some legacy code as imported from
 an older version of FirefoxDriver was in use.
 
 With Firefox 58 the interactability checks as required by the
 [WebDriver] specification are enabled by default. This means
@@ -135,18 +176,17 @@ problem is located in geckodriver, then 
 
 To temporarily disable the WebDriver conformant checks use `false`
 as value for this capability.
 
 Please note that this capability exists only temporarily, and that
 it will be removed once the interactability checks have been stabilized.
 
 
-`log` object
-------------
+## `log` object
 
 <table>
  <thead>
   <tr>
    <th>Name
    <th>Type
    <th>Description
   </tr>
@@ -161,18 +201,17 @@ it will be removed once the interactabil
    <code>info</code>, <code>warn</code>,
    <code>error</code>, and <code>fatal</code>.
    If left undefined the default is <code>info</code>.
    The value is treated case-insensitively.
  </tr>
 </table>
 
 
-`prefs` object
---------------
+## `prefs` object
 
 <table>
  <thead>
   <tr>
    <th>Name
    <th>Type
    <th>Description
   </tr>
@@ -181,32 +220,53 @@ it will be removed once the interactabil
  <tr>
   <td><var>preference name</var>
   <td align="center">string, number, boolean
   <td>One entry per preference to override.
  </tr>
 </table>
 
 
-Capabilities example
-====================
+## Capabilities examples
+
+### Custom profile, and headless mode
 
-The following example selects a specific Firefox binary to run with
-a prepared profile from the filesystem in headless mode (available on
-certain systems and recent Firefoxen).  It also increases the number
-of IPC processes through a preference and enables more verbose logging.
+This runs a specific Firefox binary with a prepared profile from the filesystem
+in headless mode.  It also increases the number of IPC processes through a
+preference and enables more verbose logging.
 
 	{
 		"capabilities": {
 			"alwaysMatch": {
 				"moz:firefoxOptions": {
 					"binary": "/usr/local/firefox/bin/firefox",
 					"args": ["-headless", "-profile", "/path/to/my/profile"],
 					"prefs": {
 						"dom.ipc.processCount": 8
 					},
 					"log": {
 						"level": "trace"
 					}
 				}
 			}
 		}
-	}
\ No newline at end of file
+	}
+
+### Android
+
+This runs the GeckoView example application as installed on the first Android
+emulator running on the host machine.
+
+	{
+		"capabilities": {
+			"alwaysMatch": {
+				"moz:firefoxOptions": {
+					"androidPackage": "org.mozilla.geckoview_example",
+					"androidActivity": "org.mozilla.geckoview_example.GeckoViewActivity",
+					"androidDeviceSerial": "emulator-5554",
+          "androidIntentArguments": [
+            "-d", "http://example.org"
+          ]
+				}
+			}
+		}
+	}
+
--- a/testing/geckodriver/doc/Support.md
+++ b/testing/geckodriver/doc/Support.md
@@ -82,17 +82,16 @@ and required versions of Selenium and Fi
 
 Clients
 -------
 
 [Selenium] users must update to version 3.11 or later to use geckodriver.
 Other clients that follow the [W3C WebDriver specification][WebDriver]
 are also supported.
 
-
 Firefoxen
 ---------
 
 geckodriver is not yet feature complete.  This means that it does
 not yet offer full conformance with the [WebDriver] standard
 or complete compatibility with [Selenium].  You can track the
 [implementation status] of the latest [Firefox Nightly] on MDN.
 We also keep track of known [Selenium], [remote protocol], and
@@ -100,18 +99,33 @@ We also keep track of known [Selenium], 
 
 Support is best in Firefox 57 and greater, although generally the more
 recent the Firefox version, the better the experience as they have
 more bug fixes and features.  Some features will only be available
 in the most recent Firefox versions, and we strongly advise using the
 latest [Firefox Nightly] with geckodriver.  Since Windows XP support
 in Firefox was dropped with Firefox 53, we do not support this platform.
 
+Android
+-------
+
+Starting with the 0.26.0 release geckodriver is able to connect
+to Android devices, and to control packages which are based on [GeckoView]
+(eg. [Firefox Preview] aka Fenix, or [Firefox Reality]). But it also still
+supports versions of Fennec up to 68 ESR, which is the last officially
+supported release from Mozilla.
+
+To run tests on Android specific capabilities under `moz:firefoxOptions`
+have to be set when requesting a new session. See the Android section under
+[Firefox Capabilties](Capabilities.html#android) for more details.
 
 [geckodriver releases]: https://github.com/mozilla/geckodriver/releases
 [Selenium]: https://github.com/seleniumhq/selenium
 [WebDriver]: https://w3c.github.io/webdriver/
 [implementation status]: https://bugzilla.mozilla.org/showdependencytree.cgi?id=721859&hide_resolved=1
 [Firefox Nightly]: https://whattrainisitnow.com/
 [remote protocol]: https://github.com/mozilla/geckodriver/issues?q=is%3Aissue+is%3Aopen+label%3Amarionette
 [specification]: https://github.com/mozilla/geckodriver/issues?q=is%3Aissue+is%3Aopen+label%3Aspec
 [issue tracker]: https://github.com/mozilla/geckodriver/issues
 [Firefox Nightly]: https://nightly.mozilla.org/
+[GeckoView]: https://wiki.mozilla.org/Mobile/GeckoView
+[Firefox Preview]: https://play.google.com/store/apps/details?id=org.mozilla.fenix
+[Firefox Reality]: https://play.google.com/store/apps/details?id=org.mozilla.vrbrowser
--- a/testing/geckodriver/moz.build
+++ b/testing/geckodriver/moz.build
@@ -5,19 +5,20 @@
 RUST_PROGRAMS += ["geckodriver"]
 # Some Rust build scripts compile C/C++ sources, don't error on warnings for them.
 AllowCompilerWarnings()
 
 RUST_TESTS = [
     "geckodriver",
     "webdriver",
     "marionette",
-    
+
     # TODO: Move to mozbase/rust/moz.build once those crates can be
     # tested separately.
+    # "mozdevice",  // Tests require adb, and cannot be run in CI
     "mozprofile",
     "mozrunner",
     "mozversion",
 ]
 
 with Files("**"):
     BUG_COMPONENT = ("Testing", "geckodriver")
 
new file mode 100644
--- /dev/null
+++ b/testing/geckodriver/src/android.rs
@@ -0,0 +1,213 @@
+use crate::capabilities::{AndroidOptions};
+use mozdevice::{Device, Host};
+use mozprofile::profile::Profile;
+use std::fmt;
+use std::path::PathBuf;
+use std::time;
+
+// TODO: avoid port clashes across GeckoView-vehicles.
+// For now, we always use target port 2829, leading to issues like bug 1533704.
+const TARGET_PORT: u16 = 2829;
+
+pub type Result<T> = std::result::Result<T, AndroidError>;
+
+#[derive(Debug)]
+pub enum AndroidError {
+    ActivityNotFound(String),
+    Device(mozdevice::DeviceError),
+    NotConnected,
+}
+
+impl fmt::Display for AndroidError {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match *self {
+            AndroidError::ActivityNotFound(ref package) => {
+                write!(f, "Activity not found for package '{}'", package)
+            },
+            AndroidError::Device(ref message) => message.fmt(f),
+            AndroidError::NotConnected =>
+                write!(f, "Not connected to any Android device"),
+        }
+
+    }
+}
+
+impl From<mozdevice::DeviceError> for AndroidError {
+    fn from(value: mozdevice::DeviceError) -> AndroidError {
+        AndroidError::Device(value)
+    }
+}
+
+/// A remote Gecko instance.
+///
+/// Host refers to the device running `geckodriver`.  Target refers to the
+/// Android device running Gecko in a GeckoView-based vehicle.
+#[derive(Debug)]
+pub struct AndroidProcess {
+    pub device: Device,
+    pub package: String,
+    pub activity: String,
+}
+
+impl AndroidProcess {
+    pub fn new(
+        device: Device,
+        package: String,
+        activity: String,
+    ) -> mozdevice::Result<AndroidProcess> {
+        Ok(AndroidProcess { device, package, activity })
+    }
+}
+
+#[derive(Debug, Default)]
+pub struct AndroidHandler {
+    pub options: AndroidOptions,
+    pub process: Option<AndroidProcess>,
+    pub profile: PathBuf,
+
+    // For port forwarding host => target
+    pub host_port: u16,
+    pub target_port: u16,
+}
+
+impl Drop for AndroidHandler {
+    fn drop(&mut self) {
+        // Try to clean up port forwarding.
+        if let Some(ref process) = self.process {
+            match process.device.kill_forward_port(self.host_port) {
+                Ok(_) => debug!("Android port forward ({} -> {}) stopped",
+                                &self.host_port, &self.target_port),
+                Err(e) => error!("Android port forward ({} -> {}) failed to stop: {}",
+                                 &self.host_port, &self.target_port, e),
+            }
+        }
+    }
+}
+
+impl AndroidHandler {
+    pub fn new(options: &AndroidOptions) -> AndroidHandler {
+        // We need to push profile.pathbuf to a safe space on the device.
+        // Make it per-Android package to avoid clashes and confusion.
+        // This naming scheme follows GeckoView's configuration file naming scheme,
+        // see bug 1533385.
+        let profile = PathBuf::from(format!(
+            "/mnt/sdcard/{}-geckodriver-profile", &options.package));
+
+        AndroidHandler {
+            options: options.clone(),
+            profile,
+            process: None,
+            ..Default::default()
+        }
+    }
+
+    pub fn connect(&mut self, host_port: u16) -> Result<()> {
+        let host = Host {
+            host: None,
+            port: None,
+            read_timeout: Some(time::Duration::from_millis(5000)),
+            write_timeout: Some(time::Duration::from_millis(5000)),
+        };
+
+        let device = host.device_or_default(self.options.device_serial.as_ref())?;
+
+        self.host_port = host_port;
+        self.target_port = TARGET_PORT;
+
+        // Set up port forward.  Port forwarding will be torn down, if possible,
+        device.forward_port(self.host_port, self.target_port)?;
+        debug!("Android port forward ({} -> {}) started", &self.host_port, &self.target_port);
+
+        // If activity hasn't been specified default to the main activity of the package
+        let activity = match self.options.activity {
+            Some(ref activity) => activity.clone(),
+            None => {
+                let response = device.execute_host_shell_command(&format!(
+                    "cmd package resolve-activity --brief {} | tail -n 1",
+                    &self.options.package))?;
+                let parts = response
+                    .trim_end()
+                    .split("/")
+                    .collect::<Vec<&str>>();
+
+                if parts.len() == 1 {
+                    return Err(AndroidError::ActivityNotFound(self.options.package.clone()));
+                }
+
+                parts[1].to_owned()
+            }
+        };
+
+        self.process = Some(AndroidProcess::new(
+            device,
+            self.options.package.clone(),
+            activity,
+        )?);
+
+        Ok(())
+    }
+
+    pub fn prepare(&self, profile: &Profile) -> Result<()> {
+        match self.process {
+            Some(ref process) => {
+                process.device.clear_app_data(&self.options.package)?;
+
+                // These permissions, at least, are required to read profiles in /mnt/sdcard.
+                for perm in &["READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE"] {
+                    process.device.execute_host_shell_command(&format!(
+                        "pm grant {} android.permission.{}", &self.options.package, perm))?;
+                }
+
+                debug!("Deleting {}", self.profile.display());
+                process.device.execute_host_shell_command(&format!(
+                    "rm -rf {}", self.profile.display()))?;
+
+                debug!("Pushing {} to {}", profile.path.display(), self.profile.display());
+                process.device.push_dir(&profile.path, &self.profile, 0o777)?;
+            },
+            None => return Err(AndroidError::NotConnected)
+        }
+
+        Ok(())
+    }
+
+    pub fn launch(&self) -> Result<()> {
+        match self.process {
+            Some(ref process) => {
+                // TODO: Use GeckoView's local configuration file (bug 1577850) unless a special
+                // "I'm Fennec" flag is set, indicating we should use command line arguments.
+                // Fenix does not handle command line arguments at this time.
+                let mut intent_arguments = self.options.intent_arguments.clone()
+                    .unwrap_or_else(|| Vec::with_capacity(3));
+                intent_arguments.push("--es".to_owned());
+                intent_arguments.push("args".to_owned());
+                intent_arguments.push(format!(
+                    "-marionette -profile {}", self.profile.display()).to_owned());
+
+                debug!("Launching {}/{}", process.package, process.activity);
+                process.device
+                    .launch(&process.package, &process.activity, &intent_arguments)
+                    .map_err(|e| {
+                        let message = format!(
+                            "Could not launch Android {}/{}: {}", process.package, process.activity, e);
+                        mozdevice::DeviceError::Adb(message)
+                    })?;
+            },
+            None => return Err(AndroidError::NotConnected)
+        }
+
+        Ok(())
+    }
+
+    pub fn force_stop(&self) -> Result<()> {
+        match &self.process {
+            Some(process) => {
+                debug!("Force stopping the Android package: {}", &process.package);
+                process.device.force_stop(&process.package)?;
+            },
+            None => return Err(AndroidError::NotConnected)
+        }
+
+        Ok(())
+   }
+}
--- a/testing/geckodriver/src/capabilities.rs
+++ b/testing/geckodriver/src/capabilities.rs
@@ -176,44 +176,41 @@ impl<'a> BrowserCapabilities for Firefox
             "moz:firefoxOptions" => {
                 let data = try_opt!(
                     value.as_object(),
                     ErrorStatus::InvalidArgument,
                     "moz:firefoxOptions is not an object"
                 );
                 for (key, value) in data.iter() {
                     match &**key {
-                        "binary" => {
+                        "androidActivity" |
+                        "androidDeviceSerial" |
+                        "androidPackage" |
+                        "binary" |
+                        "profile" => {
                             if !value.is_string() {
                                 return Err(WebDriverError::new(
                                     ErrorStatus::InvalidArgument,
-                                    "binary path is not a string",
+                                    format!("{} is not a string", &**key),
                                 ));
                             }
                         }
+                        "androidIntentArguments" |
                         "args" => {
                             if !try_opt!(
                                 value.as_array(),
                                 ErrorStatus::InvalidArgument,
-                                "args is not an array"
+                                format!("{} is not an array", &**key)
                             )
                             .iter()
                             .all(|value| value.is_string())
                             {
                                 return Err(WebDriverError::new(
                                     ErrorStatus::InvalidArgument,
-                                    "args entry is not a string",
-                                ));
-                            }
-                        }
-                        "profile" => {
-                            if !value.is_string() {
-                                return Err(WebDriverError::new(
-                                    ErrorStatus::InvalidArgument,
-                                    "profile is not a string",
+                                    format!("{} entry is not a string", &**key),
                                 ));
                             }
                         }
                         "log" => {
                             let log_data = try_opt!(
                                 value.as_object(),
                                 ErrorStatus::InvalidArgument,
                                 "log value is not an object"
@@ -293,29 +290,50 @@ impl<'a> BrowserCapabilities for Firefox
         Ok(())
     }
 
     fn accept_custom(&mut self, _: &str, _: &Value, _: &Capabilities) -> WebDriverResult<bool> {
         Ok(true)
     }
 }
 
+/// Android-specific options in the `moz:firefoxOptions` struct.
+/// These map to "androidCamelCase", following [chromedriver's Android-specific
+/// Capabilities](http://chromedriver.chromium.org/getting-started/getting-started---android).
+#[derive(Default, Clone, Debug, PartialEq)]
+pub struct AndroidOptions {
+    pub activity: Option<String>,
+    pub device_serial: Option<String>,
+    pub intent_arguments: Option<Vec<String>>,
+    pub package: String,
+}
+
+impl AndroidOptions {
+    fn new(package: String) -> AndroidOptions {
+        AndroidOptions {
+            package,
+            ..Default::default()
+        }
+    }
+}
+
 /// Rust representation of `moz:firefoxOptions`.
 ///
 /// Calling `FirefoxOptions::from_capabilities(binary, capabilities)` causes
 /// the encoded profile, the binary arguments, log settings, and additional
 /// preferences to be checked and unmarshaled from the `moz:firefoxOptions`
 /// JSON Object into a Rust representation.
 #[derive(Default, Debug)]
 pub struct FirefoxOptions {
     pub binary: Option<PathBuf>,
     pub profile: Option<Profile>,
     pub args: Option<Vec<String>>,
     pub log: LogOptions,
     pub prefs: Vec<(String, Pref)>,
+    pub android: Option<AndroidOptions>,
 }
 
 impl FirefoxOptions {
     pub fn new() -> FirefoxOptions {
         Default::default()
     }
 
     pub fn from_capabilities(
@@ -327,20 +345,21 @@ impl FirefoxOptions {
 
         if let Some(json) = matched.remove("moz:firefoxOptions") {
             let options = json.as_object().ok_or_else(|| WebDriverError::new(
                 ErrorStatus::InvalidArgument,
                 "'moz:firefoxOptions' \
                  capability is not an object",
             ))?;
 
-            rv.profile = FirefoxOptions::load_profile(&options)?;
+            rv.android = FirefoxOptions::load_android(&options)?;
             rv.args = FirefoxOptions::load_args(&options)?;
             rv.log = FirefoxOptions::load_log(&options)?;
             rv.prefs = FirefoxOptions::load_prefs(&options)?;
+            rv.profile = FirefoxOptions::load_profile(&options)?;
         }
 
         Ok(rv)
     }
 
     fn load_profile(options: &Capabilities) -> WebDriverResult<Option<Profile>> {
         if let Some(profile_json) = options.get("profile") {
             let profile_base64 = profile_json.as_str().ok_or_else(|| WebDriverError::new(
@@ -374,18 +393,17 @@ impl FirefoxOptions {
                  array",
             ))?;
             let args = args_array
                 .iter()
                 .map(|x| x.as_str().map(|x| x.to_owned()))
                 .collect::<Option<Vec<String>>>()
                 .ok_or_else(|| WebDriverError::new(
                     ErrorStatus::UnknownError,
-                    "Arguments entries were not all \
-                     strings",
+                    "Arguments entries were not all strings",
                 ))?;
             Ok(Some(args))
         } else {
             Ok(None)
         }
     }
 
     fn load_log(options: &Capabilities) -> WebDriverResult<LogOptions> {
@@ -425,16 +443,71 @@ impl FirefoxOptions {
             for (key, value) in prefs.iter() {
                 rv.push((key.clone(), pref_from_json(value)?));
             }
             Ok(rv)
         } else {
             Ok(vec![])
         }
     }
+
+    pub fn load_android(options: &Capabilities) -> WebDriverResult<Option<AndroidOptions>> {
+        if let Some(package_json) = options.get("androidPackage") {
+            let package = package_json.as_str().ok_or_else(|| WebDriverError::new(
+                ErrorStatus::InvalidArgument,
+                "androidPackage was not a string"
+            ))?.to_owned();
+
+            let mut android = AndroidOptions::new(package);
+
+            android.activity = match options.get("androidActivity") {
+                Some(json) => {
+                    Some(json.as_str().ok_or_else(|| WebDriverError::new(
+                        ErrorStatus::InvalidArgument,
+                        "androidActivity was not a string"
+                    ))?.to_owned())
+                },
+                None => None
+            };
+
+            android.device_serial = match options.get("androidDeviceSerial") {
+                Some(json) => {
+                    Some(json.as_str().ok_or_else(|| WebDriverError::new(
+                        ErrorStatus::InvalidArgument,
+                        "androidDeviceSerial was not a string"
+                    ))?.to_owned())
+                },
+                None => None
+            };
+
+            android.intent_arguments = match options.get("androidIntentArguments") {
+                Some(json) => {
+                    let args_array = json.as_array().ok_or_else(|| WebDriverError::new(
+                        ErrorStatus::InvalidArgument,
+                        "androidIntentArguments were not an array"
+                    ))?;
+                    let args = args_array
+                        .iter()
+                        .map(|x| x.as_str().map(|x| x.to_owned()))
+                        .collect::<Option<Vec<String>>>()
+                        .ok_or_else(|| WebDriverError::new(
+                            ErrorStatus::InvalidArgument,
+                            "androidIntentArguments entries were not all strings"
+                        ))?;
+
+                    Some(args)
+                }
+                None => None
+            };
+
+            Ok(Some(android))
+        } else {
+            Ok(None)
+        }
+    }
 }
 
 fn pref_from_json(value: &Value) -> WebDriverResult<Pref> {
     match *value {
         Value::String(ref x) => Ok(Pref::new(x.clone())),
         Value::Number(ref x) => Ok(Pref::new(x.as_i64().unwrap())),
         Value::Bool(x) => Ok(Pref::new(x)),
         _ => Err(WebDriverError::new(
@@ -495,48 +568,205 @@ fn unzip_buffer(buf: &[u8], dest_dir: &P
 
     Ok(())
 }
 
 #[cfg(test)]
 mod tests {
     extern crate mozprofile;
 
-    use self::mozprofile::preferences::Pref;
     use super::*;
     use crate::marionette::MarionetteHandler;
+
+    use self::mozprofile::preferences::Pref;
+    use serde_json::json;
     use std::default::Default;
     use std::fs::File;
     use std::io::Read;
 
     use webdriver::capabilities::Capabilities;
 
     fn example_profile() -> Value {
         let mut profile_data = Vec::with_capacity(1024);
         let mut profile = File::open("src/tests/profile.zip").unwrap();
         profile.read_to_end(&mut profile_data).unwrap();
         Value::String(base64::encode(&profile_data))
     }
 
-    fn make_options(firefox_opts: Capabilities) -> FirefoxOptions {
+    fn make_options(firefox_opts: Capabilities) -> WebDriverResult<FirefoxOptions> {
         let mut caps = Capabilities::new();
         caps.insert("moz:firefoxOptions".into(), Value::Object(firefox_opts));
-        let binary = None;
-        FirefoxOptions::from_capabilities(binary, &mut caps).unwrap()
+
+        FirefoxOptions::from_capabilities(None, &mut caps)
+    }
+
+    #[test]
+    fn fx_options_default() {
+        let opts = FirefoxOptions::new();
+        assert_eq!(opts.android, None);
+        assert_eq!(opts.args, None);
+        assert_eq!(opts.binary, None);
+        assert_eq!(opts.log, LogOptions { level: None });
+        assert_eq!(opts.prefs, vec![]);
+        // Profile doesn't support PartialEq
+        // assert_eq!(opts.profile, None);
+    }
+
+    #[test]
+    fn fx_options_from_capabilities_no_binary_and_caps() {
+        let mut caps = Capabilities::new();
+
+        let opts = FirefoxOptions::from_capabilities(None, &mut caps).unwrap();
+        assert_eq!(opts.android, None);
+        assert_eq!(opts.args, None);
+        assert_eq!(opts.binary, None);
+        assert_eq!(opts.log, LogOptions { level: None });
+        assert_eq!(opts.prefs, vec![]);
+    }
+
+    #[test]
+    fn fx_options_from_capabilities_with_binary_and_caps() {
+        let mut caps = Capabilities::new();
+        caps.insert("moz:firefoxOptions".into(), Value::Object(Capabilities::new()));
+
+        let binary = PathBuf::from("foo");
+
+        let opts = FirefoxOptions::from_capabilities(Some(binary.clone()), &mut caps).unwrap();
+        assert_eq!(opts.android, None);
+        assert_eq!(opts.args, None);
+        assert_eq!(opts.binary, Some(binary));
+        assert_eq!(opts.log, LogOptions { level: None });
+        assert_eq!(opts.prefs, vec![]);
+    }
+
+    #[test]
+    fn fx_options_from_capabilities_with_invalid_caps() {
+        let mut caps = Capabilities::new();
+        caps.insert("moz:firefoxOptions".into(), json!(42));
+
+        FirefoxOptions::from_capabilities(None, &mut caps)
+            .expect_err("Firefox options need to be of type object");
+    }
+
+    #[test]
+    fn fx_options_android_no_package() {
+        let mut firefox_opts = Capabilities::new();
+        firefox_opts.insert("androidAvtivity".into(), json!("foo"));
+
+        let opts = make_options(firefox_opts).expect("valid firefox options");
+        assert_eq!(opts.android, None);
+    }
+
+    #[test]
+    fn fx_options_android_package_name() {
+        let mut firefox_opts = Capabilities::new();
+        firefox_opts.insert("androidPackage".into(), json!("foo"));
+
+        let opts = make_options(firefox_opts).expect("valid firefox options");
+        assert_eq!(opts.android, Some(AndroidOptions::new("foo".to_owned())));
+    }
+
+    #[test]
+    fn fx_options_android_package_name_invalid() {
+        let mut firefox_opts = Capabilities::new();
+        firefox_opts.insert("androidPackage".into(), json!(42));
+
+        make_options(firefox_opts).expect_err("invalid firefox options");
+    }
+
+    #[test]
+    fn fx_options_android_activity() {
+        let mut firefox_opts = Capabilities::new();
+        firefox_opts.insert("androidPackage".into(), json!("foo"));
+        firefox_opts.insert("androidActivity".into(), json!("bar"));
+
+        let opts = make_options(firefox_opts).expect("valid firefox options");
+        let android_opts = AndroidOptions {
+            package: "foo".to_owned(),
+            activity: Some("bar".to_owned()),
+            ..Default::default()
+        };
+        assert_eq!(opts.android, Some(android_opts));
+    }
+
+    #[test]
+    fn fx_options_android_activity_invalid() {
+        let mut firefox_opts = Capabilities::new();
+        firefox_opts.insert("androidPackage".into(), json!("foo"));
+        firefox_opts.insert("androidActivity".into(), json!(42));
+
+        make_options(firefox_opts).expect_err("invalid firefox options");
+    }
+
+    #[test]
+    fn fx_options_android_device_serial() {
+        let mut firefox_opts = Capabilities::new();
+        firefox_opts.insert("androidPackage".into(), json!("foo"));
+        firefox_opts.insert("androidDeviceSerial".into(), json!("bar"));
+
+        let opts = make_options(firefox_opts).expect("valid firefox options");
+        let android_opts = AndroidOptions {
+            package: "foo".to_owned(),
+            device_serial: Some("bar".to_owned()),
+            ..Default::default()
+        };
+        assert_eq!(opts.android, Some(android_opts));
+    }
+
+    #[test]
+    fn fx_options_android_serial_invalid() {
+        let mut firefox_opts = Capabilities::new();
+        firefox_opts.insert("androidPackage".into(), json!("foo"));
+        firefox_opts.insert("androidDeviceSerial".into(), json!(42));
+
+        make_options(firefox_opts).expect_err("invalid firefox options");
+    }
+
+    #[test]
+    fn fx_options_android_intent_arguments() {
+        let mut firefox_opts = Capabilities::new();
+        firefox_opts.insert("androidPackage".into(), json!("foo"));
+        firefox_opts.insert("androidIntentArguments".into(), json!(["lorem", "ipsum"]));
+
+        let opts = make_options(firefox_opts).expect("valid firefox options");
+        let android_opts = AndroidOptions {
+            package: "foo".to_owned(),
+            intent_arguments: Some(vec!["lorem".to_owned(), "ipsum".to_owned()]),
+            ..Default::default()
+        };
+        assert_eq!(opts.android, Some(android_opts));
+    }
+
+    #[test]
+    fn fx_options_android_intent_arguments_no_array() {
+        let mut firefox_opts = Capabilities::new();
+        firefox_opts.insert("androidPackage".into(), json!("foo"));
+        firefox_opts.insert("androidIntentArguments".into(), json!(42));
+
+        make_options(firefox_opts).expect_err("invalid firefox options");
+    }
+
+    #[test]
+    fn fx_options_android_intent_arguments_invalid_value() {
+        let mut firefox_opts = Capabilities::new();
+        firefox_opts.insert("androidPackage".into(), json!("foo"));
+        firefox_opts.insert("androidIntentArguments".into(), json!(["lorem", 42]));
+
+        make_options(firefox_opts).expect_err("invalid firefox options");
     }
 
     #[test]
     fn test_profile() {
         let encoded_profile = example_profile();
         let mut firefox_opts = Capabilities::new();
         firefox_opts.insert("profile".into(), encoded_profile);
 
-        let opts = make_options(firefox_opts);
-        let mut profile = opts.profile.unwrap();
-        let prefs = profile.user_prefs().unwrap();
+        let opts = make_options(firefox_opts).expect("valid firefox options");
+        let mut profile = opts.profile.expect("valid firefox profile");
+        let prefs = profile.user_prefs().expect("valid preferences");
 
         println!("{:#?}", prefs.prefs);
 
         assert_eq!(
             prefs.get("startup.homepage_welcome_url"),
             Some(&Pref::new("data:text/html,PASS"))
         );
     }
@@ -549,25 +779,25 @@ mod tests {
             "browser.display.background_color".into(),
             Value::String("#00ff00".into()),
         );
 
         let mut firefox_opts = Capabilities::new();
         firefox_opts.insert("profile".into(), encoded_profile);
         firefox_opts.insert("prefs".into(), Value::Object(prefs));
 
-        let opts = make_options(firefox_opts);
-        let mut profile = opts.profile.unwrap();
+        let opts = make_options(firefox_opts).expect("valid profile and prefs");
+        let mut profile = opts.profile.expect("valid firefox profile");
 
         let handler = MarionetteHandler::new(Default::default());
         handler
             .set_prefs(2828, &mut profile, true, opts.prefs)
-            .unwrap();
+            .expect("set preferences");
 
-        let prefs_set = profile.user_prefs().unwrap();
+        let prefs_set = profile.user_prefs().expect("valid user preferences");
         println!("{:#?}", prefs_set.prefs);
 
         assert_eq!(
             prefs_set.get("startup.homepage_welcome_url"),
             Some(&Pref::new("data:text/html,PASS"))
         );
         assert_eq!(
             prefs_set.get("browser.display.background_color"),
--- a/testing/geckodriver/src/command.rs
+++ b/testing/geckodriver/src/command.rs
@@ -222,17 +222,17 @@ pub struct GeckoContextParameters {
 }
 
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 pub struct XblLocatorParameters {
     pub name: String,
     pub value: String,
 }
 
-#[derive(Default, Debug)]
+#[derive(Default, Debug, PartialEq)]
 pub struct LogOptions {
     pub level: Option<logging::Level>,
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
     use crate::test::check_deserialize;
--- a/testing/geckodriver/src/logging.rs
+++ b/testing/geckodriver/src/logging.rs
@@ -35,16 +35,17 @@ use std::sync::atomic::{AtomicUsize, Ord
 
 use chrono;
 use log;
 use mozprofile::preferences::Pref;
 
 static MAX_LOG_LEVEL: AtomicUsize = AtomicUsize::new(0);
 const LOGGED_TARGETS: &[&str] = &[
     "geckodriver",
+    "mozdevice",
     "mozprofile",
     "mozrunner",
     "mozversion",
     "webdriver",
 ];
 
 /// Logger levels from [Log.jsm].
 ///
--- a/testing/geckodriver/src/main.rs
+++ b/testing/geckodriver/src/main.rs
@@ -3,16 +3,17 @@
 extern crate base64;
 extern crate chrono;
 #[macro_use]
 extern crate clap;
 #[macro_use]
 extern crate lazy_static;
 extern crate hyper;
 extern crate marionette as marionette_rs;
+extern crate mozdevice;
 extern crate mozprofile;
 extern crate mozrunner;
 extern crate mozversion;
 extern crate regex;
 extern crate serde;
 #[macro_use]
 extern crate serde_derive;
 extern crate serde_json;
@@ -37,16 +38,17 @@ macro_rules! try_opt {
     ($expr:expr, $err_type:expr, $err_msg:expr) => {{
         match $expr {
             Some(x) => x,
             None => return Err(WebDriverError::new($err_type, $err_msg)),
         }
     }};
 }
 
+mod android;
 mod build;
 mod capabilities;
 mod command;
 mod logging;
 mod marionette;
 mod prefs;
 
 #[cfg(test)]
--- a/testing/geckodriver/src/marionette.rs
+++ b/testing/geckodriver/src/marionette.rs
@@ -1,8 +1,9 @@
+use crate::android::{AndroidHandler};
 use crate::command::{
     AddonInstallParameters, AddonUninstallParameters, GeckoContextParameters,
     GeckoExtensionCommand, GeckoExtensionRoute, XblLocatorParameters, CHROME_ELEMENT_KEY,
 };
 use marionette_rs::common::{
     Cookie as MarionetteCookie, Date as MarionetteDate, Timeouts as MarionetteTimeouts,
 };
 use marionette_rs::marionette::AppStatus;
@@ -57,16 +58,26 @@ use webdriver::response::{
 };
 use webdriver::server::{Session, WebDriverHandler};
 
 use crate::build;
 use crate::capabilities::{FirefoxCapabilities, FirefoxOptions};
 use crate::logging;
 use crate::prefs;
 
+/// A running Gecko instance.
+#[derive(Debug)]
+pub enum Browser {
+    /// A local Firefox process, running on this (host) device.
+    Host(FirefoxProcess),
+
+    /// A remote instance, running on a (target) Android device.
+    Target(AndroidHandler),
+}
+
 #[derive(Debug, PartialEq, Deserialize)]
 pub struct MarionetteHandshake {
     #[serde(rename = "marionetteProtocol")]
     protocol: u16,
     #[serde(rename = "applicationType")]
     application_type: String,
 }
 
@@ -81,17 +92,17 @@ pub struct MarionetteSettings {
     /// letting you debug internals.
     pub jsdebugger: bool,
 }
 
 #[derive(Default)]
 pub struct MarionetteHandler {
     pub connection: Mutex<Option<MarionetteConnection>>,
     pub settings: MarionetteSettings,
-    pub browser: Option<FirefoxProcess>,
+    pub browser: Option<Browser>,
 }
 
 impl MarionetteHandler {
     pub fn new(settings: MarionetteSettings) -> MarionetteHandler {
         MarionetteHandler {
             connection: Mutex::new(None),
             settings,
             browser: None,
@@ -120,31 +131,100 @@ impl MarionetteHandler {
         };
 
         if let Some(l) = options.log.level {
             logging::set_max_level(l);
         }
 
         let host = self.settings.host.to_owned();
         let port = self.settings.port.unwrap_or(get_free_port(&host)?);
-        if !self.settings.connect_existing {
-            self.start_browser(port, options)?;
+
+        match options.android {
+            Some(_) => {
+                // TODO: support connecting to running Apps.  There's no real obstruction here,
+                // just some details about port forwarding to work through.  We can't follow
+                // `chromedriver` here since it uses an abstract socket rather than a TCP socket:
+                // see bug 1240830 for thoughts on doing that for Marionette.
+                if self.settings.connect_existing {
+                    return Err(WebDriverError::new(
+                        ErrorStatus::SessionNotCreated,
+                        "Cannot connect to an existing Android App yet",
+                   ));
+                }
+
+                self.start_android(port, options)?;
+            },
+            None => {
+                if !self.settings.connect_existing {
+                    self.start_browser(port, options)?;
+                }
+            }
         }
 
         let mut connection = MarionetteConnection::new(host, port, session_id.clone());
         connection.connect(&mut self.browser).or_else(|e| {
-            if let Some(ref mut runner) = self.browser {
-                runner.kill()?;
+            match self.browser {
+                Some(Browser::Host(ref mut runner)) => {
+                    runner.kill()?;
+                },
+                Some(Browser::Target(ref mut handler)) => {
+                    handler.force_stop().map_err(|e| WebDriverError::new(
+                        ErrorStatus::UnknownError,
+                        e.to_string()
+                    ))?;
+                },
+                _ => {}
             }
+
             Err(e)
         })?;
         self.connection = Mutex::new(Some(connection));
         Ok(capabilities)
     }
 
+    fn start_android(&mut self, port: u16, options: FirefoxOptions) -> WebDriverResult<()> {
+        let android_options = options.android.unwrap();
+
+        let mut handler = AndroidHandler::new(&android_options);
+        handler.connect(port).map_err(|e| WebDriverError::new(
+            ErrorStatus::UnknownError,
+            e.to_string()
+        ))?;
+
+        // Profile management.
+        let is_custom_profile = options.profile.is_some();
+
+        let mut profile = options.profile
+            .unwrap_or(Profile::new()?);
+
+        self.set_prefs(
+            handler.target_port,
+            &mut profile,
+            is_custom_profile,
+            options.prefs
+        ).map_err(|e| WebDriverError::new(
+            ErrorStatus::SessionNotCreated,
+            format!("Failed to set preferences: {}", e),
+        ))?;
+
+        handler.prepare(&profile).map_err(|e| WebDriverError::new(
+            ErrorStatus::UnknownError,
+            e.to_string()
+        ))?;
+
+        handler.launch().map_err(|e| WebDriverError::new(
+            ErrorStatus::UnknownError,
+            e.to_string()
+        ))?;
+
+        self.browser = Some(Browser::Target(handler));
+
+        Ok(())
+    }
+
     fn start_browser(&mut self, port: u16, options: FirefoxOptions) -> WebDriverResult<()> {
         let binary = options.binary.ok_or_else(|| WebDriverError::new(
             ErrorStatus::SessionNotCreated,
             "Expected browser binary location, but unable to find \
              binary in default location, no \
              'moz:firefoxOptions.binary' capability provided, and \
              no binary flag set on the command line",
         ))?;
@@ -182,17 +262,17 @@ impl MarionetteHandler {
         }
 
         let browser_proc = runner.start().map_err(|e| {
             WebDriverError::new(
                 ErrorStatus::SessionNotCreated,
                 format!("Failed to start browser {}: {}", binary.display(), e),
             )
         })?;
-        self.browser = Some(browser_proc);
+        self.browser = Some(Browser::Host(browser_proc));
 
         Ok(())
     }
 
     pub fn set_prefs(
         &self,
         port: u16,
         profile: &mut Profile,
@@ -327,23 +407,33 @@ impl WebDriverHandler<GeckoExtensionRout
         }
 
         if let Ok(ref mut connection) = self.connection.lock() {
             if let Some(conn) = connection.as_mut() {
                 conn.close();
             }
         }
 
-        if let Some(ref mut runner) = self.browser {
-            // TODO(https://bugzil.la/1443922):
-            // Use toolkit.asyncshutdown.crash_timout pref
-            match runner.wait(time::Duration::from_secs(70)) {
-                Ok(x) => debug!("Browser process stopped: {}", x),
-                Err(e) => error!("Failed to stop browser process: {}", e),
+        match self.browser {
+            Some(Browser::Host(ref mut runner)) => {
+                // TODO(https://bugzil.la/1443922):
+                // Use toolkit.asyncshutdown.crash_timout pref
+                match runner.wait(time::Duration::from_secs(70)) {
+                    Ok(x) => debug!("Browser process stopped: {}", x),
+                    Err(e) => error!("Failed to stop browser process: {}", e),
+                }
+            },
+            Some(Browser::Target(ref mut handler)) => {
+                // Try to force-stop the process on the target device
+                match handler.force_stop() {
+                    Ok(_) => debug!("Android package force-stopped"),
+                    Err(e) => error!("Failed to force-stop Android package: {}", e),
+                }
             }
+            None => {},
         }
 
         self.connection = Mutex::new(None);
         self.browser = None;
     }
 }
 
 pub struct MarionetteSession {
@@ -1166,31 +1256,31 @@ impl MarionetteConnection {
         MarionetteConnection {
             host,
             port,
             stream: None,
             session,
         }
     }
 
-    pub fn connect(&mut self, browser: &mut Option<FirefoxProcess>) -> WebDriverResult<()> {
+    pub fn connect(&mut self, browser: &mut Option<Browser>) -> WebDriverResult<()> {
         let timeout = time::Duration::from_secs(60);
         let poll_interval = time::Duration::from_millis(100);
         let now = time::Instant::now();
 
         debug!(
             "Waiting {}s to connect to browser on {}:{}",
             timeout.as_secs(),
             self.host,
             self.port
         );
 
         loop {
             // immediately abort connection attempts if process disappears
-            if let Some(ref mut runner) = *browser {
+            if let Some(Browser::Host(ref mut runner)) = *browser {
                 let exit_status = match runner.try_wait() {
                     Ok(Some(status)) => Some(
                         status
                             .code()
                             .map(|c| c.to_string())
                             .unwrap_or_else(|| "signal".into()),
                     ),
                     Ok(None) => None,