use crate::capabilities::AndroidOptions; use mozdevice::{AndroidStorage, Device, Host, RemoteMetadata, UnixPathBuf}; use mozprofile::profile::Profile; use std::env; use std::fs::File; use std::io; use std::path::PathBuf; use std::time; use thiserror::Error; use webdriver::error::{ErrorStatus, WebDriverError}; use yaml_rust::yaml::{Hash, Yaml}; // 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] yaml_rust::EmitError), } 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, pub system_access: bool, // 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), } debug!( "Stop forwarding Marionette port ({} -> {})", &self.marionette_host_port, &self.marionette_target_port ); match self .process .device .kill_forward_port(self.marionette_host_port) { Ok(_) => {} Err(e) => error!( "Failed to stop forwarding Marionette port ({} -> {}): {}", &self.marionette_host_port, &self.marionette_target_port, e ), } if let Some(port) = self.websocket_port { debug!( "Stop forwarding WebSocket port ({} -> {})", &self.marionette_host_port, &self.marionette_target_port ); match self.process.device.kill_forward_port(port) { Ok(_) => {} Err(e) => error!( "Failed to stop forwarding WebSocket port ({0} -> {0}): {1}", &port, e ), } } } } impl AndroidHandler { pub fn new( options: &AndroidOptions, marionette_host_port: u16, system_access: bool, 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. debug!( "Start forwarding Marionette port ({} -> {})", marionette_host_port, MARIONETTE_TARGET_PORT ); device.forward_port(marionette_host_port, MARIONETTE_TARGET_PORT)?; if let Some(port) = websocket_port { // Set up port forwarding for WebSocket connections (WebDriver BiDi, and CDP). debug!("Start forwarding WebSocket port ({} -> {})", port, port); device.forward_port(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(), system_access, websocket_port, }) } pub fn copy_minidumps_files(&self) -> Result<()> { let minidumps_path = self.profile.join("minidumps"); match self.process.device.list_dir(&minidumps_path) { Ok(entries) => { let save_path = match env::var("MINIDUMP_SAVE_PATH").map(PathBuf::from) { Ok(path) => path, Err(_) => { debug!("Set MINIDUMP_SAVE_PATH to store crash minidumps."); return Ok(()); } }; for entry in entries { if let RemoteMetadata::RemoteFile(_) = entry.metadata { let file_path = minidumps_path.join(&entry.name); let extension = file_path .extension() .and_then(|ext| ext.to_str()) .map(|ext| ext.to_lowercase()) .unwrap_or(String::from("")); if extension == "dmp" || extension == "extra" { let mut dest_path = save_path.clone(); dest_path.push(&entry.name); self.process .device .pull(&file_path, &mut File::create(dest_path.as_path())?)?; debug!( "Copied minidump file {:?} from the device to {:?}.", entry.name, save_path ); } } } } Err(_) => { warn!( "Couldn't read files from minidumps folder '{}'", minidumps_path.display(), ); return Ok(()); } } Ok(()) } pub fn generate_config_file( &self, args: Option>, envs: I, ) -> 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. let args = { let mut args_yaml = Vec::from([ "--marionette".into(), "--profile".into(), self.profile.display().to_string(), ]); if self.system_access { args_yaml.push("--remote-allow-system-access".into()); } args_yaml.append(&mut args.unwrap_or_default()); args_yaml.into_iter().map(Yaml::String).collect() }; let mut env = Hash::new(); for (key, value) in envs { env.insert( Yaml::String(key.to_string()), Yaml::String(value.to_string()), ); } env.insert( Yaml::String("MOZ_CRASHREPORTER".to_owned()), Yaml::String("1".to_owned()), ); env.insert( Yaml::String("MOZ_CRASHREPORTER_NO_REPORT".to_owned()), Yaml::String("1".to_owned()), ); env.insert( Yaml::String("MOZ_CRASHREPORTER_SHUTDOWN".to_owned()), Yaml::String("1".to_owned()), ); let config_yaml = { let mut config = Hash::new(); config.insert(Yaml::String("env".into()), Yaml::Hash(env)); config.insert(Yaml::String("args".into()), Yaml::Array(args)); let mut yaml = String::new(); let mut emitter = yaml_rust::YamlEmitter::new(&mut yaml); emitter.dump(&Yaml::Hash(config))?; yaml }; Ok([CONFIG_FILE_HEADING, &*config_yaml].concat()) } pub fn prepare( &self, profile: &Profile, args: Option>, env: I, ) -> 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)?; 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 ); // A counter to how many times to try launching the package. let max_start_attempts = 2; let mut n = 0; loop { match self.process.device.launch( &self.process.package, &self.process.activity, &intent_arguments, ) { Ok(_) => break, Err(e) => { n += 1; if n < max_start_attempts && e.to_string().contains("Resource temporarily unavailable") { debug!( "Failed the {} attempt to launch Android {}/{}: {}, wait for 2 seconds and try starting again", n, self.process.package, self.process.activity, e ); std::thread::sleep(std::time::Duration::from_secs(2)); continue; } else { let message = format!( "Could not launch Android {}/{}: {}", self.process.package, self.process.activity, e ); return Err(AndroidError::from(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, true, None).expect("has valid Android handler"); assert_eq!(handler.options, options); assert_eq!(handler.marionette_host_port, 4242); assert_eq!(handler.process.package, package); assert_eq!(handler.system_access, true); assert_eq!(handler.websocket_port, None); 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); } }