#![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} 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>,
}
fn parse_granted_runtime_permissions(dumpsys_stdout: &str) -> Vec<String> {
let mut granted = Vec::new();
let mut in_section = false;
for line in dumpsys_stdout.lines() {
let trimmed = line.trim();
if trimmed == "runtime permissions:" {
in_section = true;
continue;
}
if !in_section {
continue;
}
let Some((name, rest)) = trimmed.split_once(": ") else {
in_section = false;
continue;
};
if !name.contains('.') {
in_section = false;
continue;
}
if rest.starts_with("granted=true") {
granted.push(name.to_string());
}
}
granted
}
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() {
let mut full = subcommand.to_string();
for a in args {
full.push(' ');
full.push_str(a);
}
return Err(AdbError::NonZeroExit {
subcommand: full,
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 fn spawn_shell(
&self,
serial: &str,
cmd: &[&str],
) -> Result<tokio::process::Child, AdbError> {
let mut c = self.cmd();
c.args(["-s", serial, "shell"]);
for a in cmd {
c.arg(a);
}
c.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
c.spawn().map_err(|e| {
if e.kind() == io::ErrorKind::NotFound {
AdbError::BinaryNotFound
} else {
AdbError::Spawn(e)
}
})
}
pub async fn pull(&self, serial: &str, remote: &str, local: &Path) -> Result<(), AdbError> {
let local = local.to_string_lossy();
self.run_capture(Some(serial), "pull", &[remote, &local])
.await?;
Ok(())
}
pub async fn push(&self, serial: &str, local: &Path, remote: &str) -> Result<(), AdbError> {
let local = local.to_string_lossy();
self.run_capture(Some(serial), "push", &[&local, remote])
.await?;
Ok(())
}
pub async fn broadcast(
&self,
serial: &str,
action: &str,
data: Option<&str>,
) -> Result<(), AdbError> {
let mut args: Vec<&str> = vec!["am", "broadcast", "-a", action];
if let Some(d) = data {
args.push("-d");
args.push(d);
}
self.run_capture(Some(serial), "shell", &args).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 emu(&self, serial: &str, cmd: &[&str]) -> Result<String, AdbError> {
let (stdout, _) = self.run_capture(Some(serial), "emu", cmd).await?;
if stdout.trim_start().starts_with("KO") {
return Err(AdbError::Malformed {
subcommand: format!("emu {}", cmd.join(" ")),
detail: stdout.trim().to_string(),
});
}
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_clear(&self, serial: &str, package: &str) -> Result<(), AdbError> {
let (stdout, _) = self
.run_capture(Some(serial), "shell", &["pm", "clear", package])
.await?;
if stdout.trim().starts_with("Success") {
Ok(())
} else {
Err(AdbError::Malformed {
subcommand: format!("shell pm clear {package}"),
detail: stdout.trim().to_string(),
})
}
}
pub async fn runtime_permissions_granted(
&self,
serial: &str,
package: &str,
) -> Result<Vec<String>, AdbError> {
let (stdout, _) = self
.run_capture(Some(serial), "shell", &["dumpsys", "package", package])
.await?;
Ok(parse_granted_runtime_permissions(&stdout))
}
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 {
const DUMPSYS_REAL: &str = "\
requested permissions:
android.permission.INTERNET
android.permission.ACCESS_FINE_LOCATION
install permissions:
android.permission.INTERNET: granted=true
runtime permissions:
android.permission.POST_NOTIFICATIONS: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
android.permission.ACCESS_FINE_LOCATION: granted=true, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
android.permission.ACCESS_COARSE_LOCATION: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
android.permission.CAMERA: granted=true, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
User 0: ceDataInode=1234 installed=true hidden=false
";
#[test]
fn reads_only_the_granted_runtime_permissions() {
let granted = parse_granted_runtime_permissions(DUMPSYS_REAL);
assert_eq!(
granted,
vec![
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.CAMERA"
]
);
}
#[test]
fn stays_out_of_the_neighbouring_sections() {
assert!(
!parse_granted_runtime_permissions(DUMPSYS_REAL)
.contains(&"android.permission.INTERNET".to_string())
);
}
#[test]
fn an_app_holding_nothing_yields_nothing() {
assert!(parse_granted_runtime_permissions("runtime permissions:\n\nUser 0:").is_empty());
assert!(parse_granted_runtime_permissions("").is_empty());
}
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"));
}
}