use crate::capabilities::AndroidOptions; use mozdevice::{AndroidStorage, Device, Host, UnixPathBuf}; use mozprofile::profile::Profile; use serde::Serialize; use serde_yaml::{Mapping, Value}; use std::io; use std::time; use thiserror::Error; use webdriver::error::{ErrorStatus, WebDriverError}; // TODO: avoid port clashes across GeckoView-vehicles. // For now, we always use target port 2829, leading to issues like bug 1533704. const MARIONETTE_TARGET_PORT: u16 = 2829; const CONFIG_FILE_HEADING: &str = r#"## GeckoView configuration YAML ## ## Auto-generated by geckodriver. ## See https://mozilla.github.io/geckoview/consumer/docs/automation. "#; pub type Result = std::result::Result; #[derive(Debug, Error)] pub enum AndroidError { #[error("Activity for package '{0}' not found")] ActivityNotFound(String), #[error(transparent)] Device(#[from] mozdevice::DeviceError), #[error(transparent)] IO(#[from] io::Error), #[error("Package '{0}' not found")] PackageNotFound(String), #[error(transparent)] Serde(#[from] serde_yaml::Error), } impl From for WebDriverError { fn from(value: AndroidError) -> WebDriverError { WebDriverError::new(ErrorStatus::UnknownError, value.to_string()) } } /// 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 { Ok(AndroidProcess { device, package, activity, }) } } #[derive(Debug)] pub struct AndroidHandler { pub config: UnixPathBuf, pub options: AndroidOptions, pub process: AndroidProcess, pub profile: UnixPathBuf, pub test_root: UnixPathBuf, // Port forwarding for Marionette: host => target pub marionette_host_port: u16, pub marionette_target_port: u16, // Port forwarding for WebSocket connections (WebDriver BiDi and CDP) pub websocket_port: Option, } impl Drop for AndroidHandler { fn drop(&mut self) { // Try to clean up various settings let clear_command = format!("am clear-debug-app {}", self.process.package); match self .process .device .execute_host_shell_command(&clear_command) { Ok(_) => debug!("Disabled reading from configuration file"), Err(e) => error!("Failed disabling from configuration file: {}", e), } match self.process.device.remove(&self.config) { Ok(_) => debug!("Deleted GeckoView configuration file"), Err(e) => error!("Failed deleting GeckoView configuration file: {}", e), } match self.process.device.remove(&self.test_root) { Ok(_) => debug!("Deleted test root folder: {}", &self.test_root.display()), Err(e) => error!("Failed deleting test root folder: {}", e), } match self .process .device .kill_forward_port(self.marionette_host_port) { Ok(_) => debug!( "Marionette port forward ({} -> {}) stopped", &self.marionette_host_port, &self.marionette_target_port ), Err(e) => error!( "Marionette port forward ({} -> {}) failed to stop: {}", &self.marionette_host_port, &self.marionette_target_port, e ), } if let Some(port) = self.websocket_port { match self.process.device.kill_forward_port(port) { Ok(_) => debug!("WebSocket port forward ({0} -> {0}) stopped", &port), Err(e) => error!( "WebSocket port forward ({0} -> {0}) failed to stop: {1}", &port, e ), } } } } impl AndroidHandler { pub fn new( options: &AndroidOptions, marionette_host_port: u16, websocket_port: Option, ) -> Result { // 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 host = Host { host: None, port: None, read_timeout: Some(time::Duration::from_millis(5000)), write_timeout: Some(time::Duration::from_millis(5000)), }; let mut device = host.device_or_default(options.device_serial.as_ref(), options.storage)?; // Set up port forwarding for Marionette. device.forward_port(marionette_host_port, MARIONETTE_TARGET_PORT)?; debug!( "Marionette port forward ({} -> {}) started", marionette_host_port, MARIONETTE_TARGET_PORT ); if let Some(port) = websocket_port { // Set up port forwarding for WebSocket connections (WebDriver BiDi, and CDP). device.forward_port(port, port)?; debug!("WebSocket port forward ({} -> {}) started", port, port); } let test_root = match device.storage { AndroidStorage::App => { device.run_as_package = Some(options.package.to_owned()); let mut buf = UnixPathBuf::from("/data/data"); buf.push(&options.package); buf.push("test_root"); buf } AndroidStorage::Internal => UnixPathBuf::from("/data/local/tmp/test_root"), AndroidStorage::Sdcard => { // We need to push the profile to a location on the device that can also // be read and write by the application, and works for unrooted devices. // The only location that meets this criteria is under: // $EXTERNAL_STORAGE/Android/data/%options.package%/files let response = device.execute_host_shell_command("echo $EXTERNAL_STORAGE")?; let mut buf = UnixPathBuf::from(response.trim_end_matches('\n')); buf.push("Android/data"); buf.push(&options.package); buf.push("files/test_root"); buf } }; debug!( "Connecting: options={:?}, storage={:?}) test_root={}, run_as_package={:?}", options, device.storage, test_root.display(), device.run_as_package ); let mut profile = test_root.clone(); profile.push(format!("{}-geckodriver-profile", &options.package)); // Check if the specified package is installed let response = device.execute_host_shell_command(&format!("pm list packages {}", &options.package))?; let mut packages = response .trim() .split_terminator('\n') .filter(|line| line.starts_with("package:")) .map(|line| line.rsplit(':').next().expect("Package name found")); if !packages.any(|x| x == options.package.as_str()) { return Err(AndroidError::PackageNotFound(options.package.clone())); } let config = UnixPathBuf::from(format!( "/data/local/tmp/{}-geckoview-config.yaml", &options.package )); // If activity hasn't been specified default to the main activity of the package let activity = match options.activity { Some(ref activity) => activity.clone(), None => { let response = device.execute_host_shell_command(&format!( "cmd package resolve-activity --brief {}", &options.package ))?; let activities = response .split_terminator('\n') .filter(|line| line.starts_with(&options.package)) .map(|line| line.rsplit('/').next().unwrap()) .collect::>(); if activities.is_empty() { return Err(AndroidError::ActivityNotFound(options.package.clone())); } activities[0].to_owned() } }; let process = AndroidProcess::new(device, options.package.clone(), activity)?; Ok(AndroidHandler { config, process, profile, test_root, marionette_host_port, marionette_target_port: MARIONETTE_TARGET_PORT, options: options.clone(), websocket_port, }) } pub fn generate_config_file( &self, args: Option>, envs: I, enable_crash_reporter: bool, ) -> Result where I: IntoIterator, K: ToString, V: ToString, { // To configure GeckoView, we use the automation techniques documented at // https://mozilla.github.io/geckoview/consumer/docs/automation. #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] pub struct Config { pub env: Mapping, pub args: Vec, } let mut config = Config { args: vec![ "--marionette".into(), "--profile".into(), self.profile.display().to_string(), ], env: Mapping::new(), }; config.args.append(&mut args.unwrap_or_default()); for (key, value) in envs { config.env.insert( Value::String(key.to_string()), Value::String(value.to_string()), ); } if !enable_crash_reporter { config.env.insert( Value::String("MOZ_CRASHREPORTER".to_owned()), Value::String("1".to_owned()), ); config.env.insert( Value::String("MOZ_CRASHREPORTER_NO_REPORT".to_owned()), Value::String("1".to_owned()), ); config.env.insert( Value::String("MOZ_CRASHREPORTER_SHUTDOWN".to_owned()), Value::String("1".to_owned()), ); } let mut contents: Vec = vec![CONFIG_FILE_HEADING.to_owned()]; contents.push(serde_yaml::to_string(&config)?); Ok(contents.concat()) } pub fn prepare( &self, profile: &Profile, args: Option>, env: I, enable_crash_reporter: bool, ) -> Result<()> where I: IntoIterator, K: ToString, V: ToString, { self.process.device.clear_app_data(&self.process.package)?; // These permissions, at least, are required to read profiles in /mnt/sdcard. for perm in &["READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE"] { self.process.device.execute_host_shell_command(&format!( "pm grant {} android.permission.{}", &self.process.package, perm ))?; } // Make sure to create the test root. self.process.device.create_dir(&self.test_root)?; self.process.device.chmod(&self.test_root, "777", true)?; // Replace the profile self.process.device.remove(&self.profile)?; self.process .device .push_dir(&profile.path, &self.profile, 0o777)?; let contents = self.generate_config_file(args, env, enable_crash_reporter)?; debug!("Content of generated GeckoView config file:\n{}", contents); let reader = &mut io::BufReader::new(contents.as_bytes()); debug!( "Pushing GeckoView configuration file to {}", self.config.display() ); self.process.device.push(reader, &self.config, 0o777)?; // Tell GeckoView to read configuration even when `android:debuggable="false"`. self.process.device.execute_host_shell_command(&format!( "am set-debug-app --persistent {}", self.process.package ))?; Ok(()) } pub fn launch(&self) -> Result<()> { // TODO: Remove the usage of intent arguments once Fennec is no longer // supported. Packages which are using GeckoView always read the arguments // via the YAML configuration file. 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())); debug!( "Launching {}/{}", self.process.package, self.process.activity ); self.process .device .launch( &self.process.package, &self.process.activity, &intent_arguments, ) .map_err(|e| { let message = format!( "Could not launch Android {}/{}: {}", self.process.package, self.process.activity, e ); mozdevice::DeviceError::Adb(message) })?; Ok(()) } pub fn force_stop(&self) -> Result<()> { debug!( "Force stopping the Android package: {}", &self.process.package ); self.process.device.force_stop(&self.process.package)?; Ok(()) } } #[cfg(test)] mod test { // To successfully run those tests the geckoview_example package needs to // be installed on the device or emulator. After setting up the build // environment (https://mzl.la/3muLv5M), the following mach commands have to // be executed: // // $ ./mach build && ./mach install // // Currently the mozdevice API is not safe for multiple requests at the same // time. It is recommended to run each of the unit tests on its own. Also adb // specific tests cannot be run in CI yet. To check those locally, also run // the ignored tests. // // Use the following command to accomplish that: // // $ cargo test -- --ignored --test-threads=1 use crate::android::AndroidHandler; use crate::capabilities::AndroidOptions; use mozdevice::{AndroidStorage, AndroidStorageInput, UnixPathBuf}; fn run_handler_storage_test(package: &str, storage: AndroidStorageInput) { let options = AndroidOptions::new(package.to_owned(), storage); let handler = AndroidHandler::new(&options, 4242, None).expect("has valid Android handler"); assert_eq!(handler.options, options); assert_eq!(handler.process.package, package); let expected_config_path = UnixPathBuf::from(format!( "/data/local/tmp/{}-geckoview-config.yaml", &package )); assert_eq!(handler.config, expected_config_path); if handler.process.device.storage == AndroidStorage::App { assert_eq!( handler.process.device.run_as_package, Some(package.to_owned()) ); } else { assert_eq!(handler.process.device.run_as_package, None); } let test_root = match handler.process.device.storage { AndroidStorage::App => { let mut buf = UnixPathBuf::from("/data/data"); buf.push(&package); buf.push("test_root"); buf } AndroidStorage::Internal => UnixPathBuf::from("/data/local/tmp/test_root"), AndroidStorage::Sdcard => { let response = handler .process .device .execute_host_shell_command("echo $EXTERNAL_STORAGE") .unwrap(); let mut buf = UnixPathBuf::from(response.trim_end_matches('\n')); buf.push("Android/data/"); buf.push(&package); buf.push("files/test_root"); buf } }; assert_eq!(handler.test_root, test_root); let mut profile = test_root; profile.push(format!("{}-geckodriver-profile", &package)); assert_eq!(handler.profile, profile); } #[test] #[ignore] fn android_handler_storage_as_app() { let package = "org.mozilla.geckoview_example"; run_handler_storage_test(package, AndroidStorageInput::App); } #[test] #[ignore] fn android_handler_storage_as_auto() { let package = "org.mozilla.geckoview_example"; run_handler_storage_test(package, AndroidStorageInput::Auto); } #[test] #[ignore] fn android_handler_storage_as_internal() { let package = "org.mozilla.geckoview_example"; run_handler_storage_test(package, AndroidStorageInput::Internal); } #[test] #[ignore] fn android_handler_storage_as_sdcard() { let package = "org.mozilla.geckoview_example"; run_handler_storage_test(package, AndroidStorageInput::Sdcard); } }