use crate::error::TransportError;
use crate::hierarchy::find_element;
use crate::transport::Transport;
use crate::types::{Direction, Selector};
use async_trait::async_trait;
use tokio::process::Command;
use tonic::transport::Channel;
pub(crate) mod proto {
include!(concat!(env!("OUT_DIR"), "/maestro_android.rs"));
}
use proto::maestro_driver_client::MaestroDriverClient;
use proto::{
CheckWindowUpdatingRequest, DeviceInfoRequest, InputTextRequest, LaunchAppRequest,
ScreenshotRequest, TapRequest, ViewHierarchyRequest,
};
const MAESTRO_RUNNER: &str = "dev.mobile.maestro.test/androidx.test.runner.AndroidJUnitRunner";
const MAESTRO_APP_PACKAGE: &str = "dev.mobile.maestro";
const MAESTRO_TEST_PACKAGE: &str = "dev.mobile.maestro.test";
const MAESTRO_VERSION: &str = "2.6.1";
const MAESTRO_APP_APK: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/maestro-app-2.6.1.apk"));
const MAESTRO_SERVER_APK: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/maestro-server-2.6.1.apk"));
const STARTUP_TIMEOUT_MS: u64 = 30_000;
const STARTUP_POLL_MS: u64 = 200;
pub(crate) struct AdbTransport {
serial: Option<String>,
#[allow(dead_code)]
port: u16,
client: MaestroDriverClient<Channel>,
}
impl AdbTransport {
pub(crate) async fn connect(serial: Option<String>, port: u16) -> Result<Self, TransportError> {
ensure_driver_installed(&serial).await?;
adb_cmd(
&serial,
&["forward", &format!("tcp:{port}"), &format!("tcp:{port}")],
)
.status()
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("adb forward: {e}"),
})?;
let server_alive = probe_grpc_server(port).await;
if !server_alive {
adb_cmd(
&serial,
&[
"shell",
"am",
"instrument",
"-w",
"-e",
"class",
"dev.mobile.maestro.MaestroDriverService#grpcServer",
"-e",
"port",
&port.to_string(),
MAESTRO_RUNNER,
],
)
.spawn()
.map_err(|e| TransportError::OperationFailed {
reason: format!("am instrument spawn: {e}"),
})?;
let deadline =
std::time::Instant::now() + std::time::Duration::from_millis(STARTUP_TIMEOUT_MS);
loop {
if probe_grpc_server(port).await {
break;
}
if std::time::Instant::now() >= deadline {
return Err(TransportError::OperationFailed {
reason: format!(
"Maestro gRPC server did not start on port {port} within {STARTUP_TIMEOUT_MS}ms"
),
});
}
tokio::time::sleep(std::time::Duration::from_millis(STARTUP_POLL_MS)).await;
}
}
let endpoint = format!("http://127.0.0.1:{port}");
let channel = Channel::from_shared(endpoint)
.map_err(|e| TransportError::OperationFailed {
reason: format!("channel: {e}"),
})?
.connect()
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("connect: {e}"),
})?;
Ok(Self {
serial,
port,
client: MaestroDriverClient::new(channel),
})
}
fn adb_shell(&self, args: &[&str]) -> Command {
let mut cmd = adb_cmd(&self.serial, &["shell"]);
cmd.args(args);
cmd
}
}
async fn probe_grpc_server(port: u16) -> bool {
let endpoint = format!("http://127.0.0.1:{port}");
let Ok(channel) = Channel::from_shared(endpoint).map(|e| e.connect_lazy()) else {
return false;
};
let mut client = MaestroDriverClient::new(channel);
client.device_info(DeviceInfoRequest {}).await.is_ok()
}
async fn ensure_driver_installed(serial: &Option<String>) -> Result<(), TransportError> {
let installed = adb_cmd(serial, &["shell", "pm", "list", "packages"])
.output()
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("pm list packages: {e}"),
})
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
let app_present = installed
.lines()
.any(|l| l.trim() == format!("package:{MAESTRO_APP_PACKAGE}"));
let test_present = installed
.lines()
.any(|l| l.trim() == format!("package:{MAESTRO_TEST_PACKAGE}"));
if app_present && test_present {
return Ok(());
}
eprintln!("podium: installing Maestro driver v{MAESTRO_VERSION}...");
if !app_present {
install_apk(serial, "maestro-app", MAESTRO_APP_APK).await?;
}
if !test_present {
install_apk(serial, "maestro-server", MAESTRO_SERVER_APK).await?;
}
Ok(())
}
async fn install_apk(
serial: &Option<String>,
name: &str,
bytes: &[u8],
) -> Result<(), TransportError> {
let tmp = std::env::temp_dir().join(format!("{name}-{MAESTRO_VERSION}.apk"));
tokio::fs::write(&tmp, bytes)
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("write {name} APK: {e}"),
})?;
let status = adb_cmd(serial, &["install", "-r", tmp.to_str().unwrap()])
.status()
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("adb install {name}: {e}"),
})?;
let _ = tokio::fs::remove_file(&tmp).await;
if !status.success() {
return Err(TransportError::OperationFailed {
reason: format!("adb install of {name} APK failed"),
});
}
Ok(())
}
fn adb_cmd(serial: &Option<String>, args: &[&str]) -> Command {
let mut cmd = Command::new("adb");
if let Some(s) = serial {
cmd.args(["-s", s.as_str()]);
}
cmd.args(args);
cmd
}
#[async_trait]
impl Transport for AdbTransport {
async fn launch_app(&self, app_id: &str, clear_state: bool) -> Result<(), TransportError> {
if clear_state {
self.adb_shell(&["pm", "clear", app_id])
.status()
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("pm clear: {e}"),
})?;
}
let mut client = self.client.clone();
client
.launch_app(LaunchAppRequest {
package_name: app_id.to_string(),
arguments: vec![],
})
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("launchApp: {e}"),
})?;
Ok(())
}
async fn foreground_package(&self) -> Result<Option<String>, TransportError> {
let output = self
.adb_shell(&[
"dumpsys",
"activity",
"activities",
"|",
"grep",
"mResumedActivity",
])
.output()
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("dumpsys activity: {e}"),
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let package = stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("mResumedActivity") {
trimmed.split_whitespace().find_map(|token| {
if token.contains('/') {
token.split('/').next().map(|s| s.to_string())
} else {
None
}
})
} else {
None
}
});
Ok(package)
}
async fn is_visible(&self, selector: &Selector) -> Result<bool, TransportError> {
let xml = self.view_hierarchy().await?;
Ok(find_element(&xml, selector).is_some())
}
async fn tap(&self, selector: &Selector) -> Result<(), TransportError> {
let xml = self.view_hierarchy().await?;
let bounds =
find_element(&xml, selector).ok_or_else(|| TransportError::ElementNotFound {
reason: format!("tap: element not found: {selector:?}"),
})?;
let (cx, cy) = bounds.center();
let mut client = self.client.clone();
client.tap(TapRequest { x: cx, y: cy }).await.map_err(|e| {
TransportError::OperationFailed {
reason: format!("tap: {e}"),
}
})?;
Ok(())
}
async fn input_text(&self, text: &str) -> Result<(), TransportError> {
let mut client = self.client.clone();
client
.input_text(InputTextRequest {
text: text.to_string(),
})
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("inputText: {e}"),
})?;
Ok(())
}
async fn hide_keyboard(&self) -> Result<(), TransportError> {
self.adb_shell(&["input", "keyevent", "KEYCODE_BACK"])
.status()
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("hide_keyboard: {e}"),
})?;
Ok(())
}
async fn swipe(&self, direction: &Direction) -> Result<(), TransportError> {
let args: &[&str] = match direction {
Direction::Down => &["input", "swipe", "540", "1400", "540", "400", "300"],
Direction::Up => &["input", "swipe", "540", "400", "540", "1400", "300"],
Direction::Left => &["input", "swipe", "900", "800", "180", "800", "300"],
Direction::Right => &["input", "swipe", "180", "800", "900", "800", "300"],
};
self.adb_shell(args)
.status()
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("swipe: {e}"),
})?;
Ok(())
}
async fn back(&self) -> Result<(), TransportError> {
self.adb_shell(&["input", "keyevent", "KEYCODE_BACK"])
.status()
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("back: {e}"),
})?;
Ok(())
}
async fn wait_for_idle(&self, timeout_ms: u64) -> Result<(), TransportError> {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
loop {
let mut client = self.client.clone();
let updating = client
.is_window_updating(CheckWindowUpdatingRequest {
app_id: String::new(),
})
.await
.map(|r| r.into_inner().is_window_updating)
.unwrap_or(false);
if !updating {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Ok(()); }
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
async fn take_screenshot(&self, name: &str) -> Result<(), TransportError> {
let mut client = self.client.clone();
let resp = client.screenshot(ScreenshotRequest {}).await.map_err(|e| {
TransportError::OperationFailed {
reason: format!("screenshot: {e}"),
}
})?;
let bytes = resp.into_inner().bytes;
let path = format!("{name}.png");
tokio::fs::write(&path, &bytes)
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("write screenshot: {e}"),
})?;
Ok(())
}
async fn view_hierarchy(&self) -> Result<String, TransportError> {
let mut client = self.client.clone();
let resp = client
.view_hierarchy(ViewHierarchyRequest {})
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("viewHierarchy: {e}"),
})?;
Ok(resp.into_inner().hierarchy)
}
async fn tap_at(&self, x: u32, y: u32) -> Result<(), TransportError> {
let mut client = self.client.clone();
client
.tap(TapRequest { x, y })
.await
.map_err(|e| TransportError::OperationFailed {
reason: format!("tap_at: {e}"),
})?;
Ok(())
}
}