#![doc(html_root_url = "https://docs.smix.dev/smix-adb")]
use serde::{Deserialize, Serialize};
use std::io;
use std::path::Path;
use thiserror::Error;
use tokio::process::Command;
#[derive(Debug, Error)]
pub enum AdbError {
#[error("spawn adb failed: {0}")]
Spawn(#[from] io::Error),
#[error("adb binary not found in PATH; install Android SDK platform-tools")]
BinaryNotFound,
#[error("adb {subcommand} (serial={serial:?}) exited {code}: {stderr}")]
NonZeroExit {
subcommand: String,
serial: Option<String>,
code: i32,
stderr: String,
},
#[error("adb {subcommand} returned malformed output: {detail}")]
Malformed {
subcommand: String,
detail: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdbDevice {
pub serial: String,
pub state: String,
pub product: Option<String>,
pub model: Option<String>,
pub device: Option<String>,
pub transport_id: Option<String>,
}
pub fn parse_devices_stdout(stdout: &str) -> Result<Vec<AdbDevice>, AdbError> {
let mut out = Vec::new();
let mut saw_header = false;
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("List of devices") {
saw_header = true;
continue;
}
let mut parts = trimmed.split_whitespace();
let serial = parts
.next()
.ok_or_else(|| AdbError::Malformed {
subcommand: "devices -l".into(),
detail: format!("empty serial in line: {trimmed:?}"),
})?
.to_string();
let state = parts
.next()
.ok_or_else(|| AdbError::Malformed {
subcommand: "devices -l".into(),
detail: format!("missing state field in line: {trimmed:?}"),
})?
.to_string();
let mut dev = AdbDevice {
serial,
state,
product: None,
model: None,
device: None,
transport_id: None,
};
for kv in parts {
if let Some((k, v)) = kv.split_once(':') {
let v_owned = v.to_string();
match k {
"product" => dev.product = Some(v_owned),
"model" => dev.model = Some(v_owned),
"device" => dev.device = Some(v_owned),
"transport_id" => dev.transport_id = Some(v_owned),
_ => {} }
}
}
out.push(dev);
}
let _ = saw_header;
Ok(out)
}
#[derive(Debug, Default, Clone)]
pub struct AdbClient {
binary: Option<String>,
}
impl AdbClient {
#[must_use]
pub fn new() -> Self {
AdbClient { binary: None }
}
#[must_use]
pub fn with_binary(binary: impl Into<String>) -> Self {
AdbClient {
binary: Some(binary.into()),
}
}
fn cmd(&self) -> Command {
Command::new(self.binary.as_deref().unwrap_or("adb"))
}
async fn run_capture(
&self,
serial: Option<&str>,
subcommand: &str,
args: &[&str],
) -> Result<(String, String), AdbError> {
let mut cmd = self.cmd();
if let Some(s) = serial {
cmd.args(["-s", s]);
}
for w in subcommand.split_whitespace() {
cmd.arg(w);
}
for a in args {
cmd.arg(a);
}
let output = cmd.output().await.map_err(|e| {
if e.kind() == io::ErrorKind::NotFound {
AdbError::BinaryNotFound
} else {
AdbError::Spawn(e)
}
})?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if !output.status.success() {
return Err(AdbError::NonZeroExit {
subcommand: subcommand.into(),
serial: serial.map(str::to_owned),
code: output.status.code().unwrap_or(-1),
stderr,
});
}
Ok((stdout, stderr))
}
pub async fn devices(&self) -> Result<Vec<AdbDevice>, AdbError> {
let (stdout, _) = self.run_capture(None, "devices", &["-l"]).await?;
parse_devices_stdout(&stdout)
}
pub async fn install(&self, serial: &str, apk_path: &Path) -> Result<(), AdbError> {
let path = apk_path.to_string_lossy();
self.run_capture(Some(serial), "install", &["-r", &path])
.await?;
Ok(())
}
pub async fn uninstall(&self, serial: &str, package: &str) -> Result<(), AdbError> {
self.run_capture(Some(serial), "uninstall", &[package])
.await?;
Ok(())
}
pub async fn start_activity(
&self,
serial: &str,
package: &str,
activity: &str,
extras: &[(&str, &str)],
) -> Result<(), AdbError> {
let component = format!("{package}/{activity}");
let mut args = vec![
"am".to_string(),
"start".to_string(),
"-n".to_string(),
component,
];
for (k, v) in extras {
args.push("--es".to_string());
args.push((*k).to_string());
args.push((*v).to_string());
}
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
self.run_capture(Some(serial), "shell", &arg_refs).await?;
Ok(())
}
pub async fn force_stop(&self, serial: &str, package: &str) -> Result<(), AdbError> {
self.run_capture(Some(serial), "shell", &["am", "force-stop", package])
.await?;
Ok(())
}
pub async fn screenshot(&self, serial: &str) -> Result<Vec<u8>, AdbError> {
let mut cmd = self.cmd();
cmd.args(["-s", serial, "shell", "screencap", "-p"]);
let output = cmd.output().await.map_err(AdbError::from)?;
if !output.status.success() {
return Err(AdbError::NonZeroExit {
subcommand: "shell screencap -p".into(),
serial: Some(serial.to_string()),
code: output.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
Ok(output.stdout)
}
pub async fn forward(
&self,
serial: &str,
host_port: u16,
device_port: u16,
) -> Result<(), AdbError> {
let host = format!("tcp:{host_port}");
let dev = format!("tcp:{device_port}");
self.run_capture(Some(serial), "forward", &[&host, &dev])
.await?;
Ok(())
}
pub async fn unforward(&self, serial: &str, host_port: u16) -> Result<(), AdbError> {
let host = format!("tcp:{host_port}");
self.run_capture(Some(serial), "forward", &["--remove", &host])
.await?;
Ok(())
}
pub async fn shell(&self, serial: &str, cmd: &[&str]) -> Result<String, AdbError> {
let (stdout, _) = self.run_capture(Some(serial), "shell", cmd).await?;
Ok(stdout)
}
pub async fn pm_grant(
&self,
serial: &str,
package: &str,
permission: &str,
) -> Result<(), AdbError> {
self.run_capture(Some(serial), "shell", &["pm", "grant", package, permission])
.await?;
Ok(())
}
pub async fn pm_revoke(
&self,
serial: &str,
package: &str,
permission: &str,
) -> Result<(), AdbError> {
self.run_capture(
Some(serial),
"shell",
&["pm", "revoke", package, permission],
)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_emulator_device_line() {
let line = "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n";
let devs = parse_devices_stdout(line).unwrap();
assert_eq!(devs.len(), 1);
assert_eq!(devs[0].serial, "emulator-5554");
assert_eq!(devs[0].state, "device");
assert_eq!(devs[0].transport_id.as_deref(), Some("1"));
}
}