1#![doc(html_root_url = "https://docs.smix.dev/smix-adb")]
19
20use serde::{Deserialize, Serialize};
21use std::io;
22use std::path::Path;
23use thiserror::Error;
24use tokio::process::Command;
25
26#[derive(Debug, Error)]
28pub enum AdbError {
29 #[error("spawn adb failed: {0}")]
31 Spawn(#[from] io::Error),
32 #[error("adb binary not found in PATH; install Android SDK platform-tools")]
34 BinaryNotFound,
35 #[error("adb {subcommand} (serial={serial:?}) exited {code}: {stderr}")]
37 NonZeroExit {
38 subcommand: String,
40 serial: Option<String>,
42 code: i32,
44 stderr: String,
46 },
47 #[error("adb {subcommand} returned malformed output: {detail}")]
49 Malformed {
50 subcommand: String,
52 detail: String,
54 },
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct AdbDevice {
60 pub serial: String,
62 pub state: String,
64 pub product: Option<String>,
66 pub model: Option<String>,
68 pub device: Option<String>,
70 pub transport_id: Option<String>,
72}
73
74pub fn parse_devices_stdout(stdout: &str) -> Result<Vec<AdbDevice>, AdbError> {
89 let mut out = Vec::new();
90 let mut saw_header = false;
91 for line in stdout.lines() {
92 let trimmed = line.trim();
93 if trimmed.is_empty() {
94 continue;
95 }
96 if trimmed.starts_with("List of devices") {
97 saw_header = true;
98 continue;
99 }
100 let mut parts = trimmed.split_whitespace();
102 let serial = parts
103 .next()
104 .ok_or_else(|| AdbError::Malformed {
105 subcommand: "devices -l".into(),
106 detail: format!("empty serial in line: {trimmed:?}"),
107 })?
108 .to_string();
109 let state = parts
110 .next()
111 .ok_or_else(|| AdbError::Malformed {
112 subcommand: "devices -l".into(),
113 detail: format!("missing state field in line: {trimmed:?}"),
114 })?
115 .to_string();
116 let mut dev = AdbDevice {
117 serial,
118 state,
119 product: None,
120 model: None,
121 device: None,
122 transport_id: None,
123 };
124 for kv in parts {
125 if let Some((k, v)) = kv.split_once(':') {
126 let v_owned = v.to_string();
127 match k {
128 "product" => dev.product = Some(v_owned),
129 "model" => dev.model = Some(v_owned),
130 "device" => dev.device = Some(v_owned),
131 "transport_id" => dev.transport_id = Some(v_owned),
132 _ => {} }
134 }
135 }
136 out.push(dev);
137 }
138 let _ = saw_header;
142 Ok(out)
143}
144
145#[derive(Debug, Default, Clone)]
150pub struct AdbClient {
151 binary: Option<String>,
153}
154
155impl AdbClient {
156 #[must_use]
158 pub fn new() -> Self {
159 AdbClient { binary: None }
160 }
161
162 #[must_use]
165 pub fn with_binary(binary: impl Into<String>) -> Self {
166 AdbClient {
167 binary: Some(binary.into()),
168 }
169 }
170
171 fn cmd(&self) -> Command {
172 Command::new(self.binary.as_deref().unwrap_or("adb"))
173 }
174
175 async fn run_capture(
176 &self,
177 serial: Option<&str>,
178 subcommand: &str,
179 args: &[&str],
180 ) -> Result<(String, String), AdbError> {
181 let mut cmd = self.cmd();
182 if let Some(s) = serial {
183 cmd.args(["-s", s]);
184 }
185 for w in subcommand.split_whitespace() {
187 cmd.arg(w);
188 }
189 for a in args {
190 cmd.arg(a);
191 }
192 let output = cmd.output().await.map_err(|e| {
193 if e.kind() == io::ErrorKind::NotFound {
194 AdbError::BinaryNotFound
195 } else {
196 AdbError::Spawn(e)
197 }
198 })?;
199 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
200 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
201 if !output.status.success() {
202 return Err(AdbError::NonZeroExit {
203 subcommand: subcommand.into(),
204 serial: serial.map(str::to_owned),
205 code: output.status.code().unwrap_or(-1),
206 stderr,
207 });
208 }
209 Ok((stdout, stderr))
210 }
211
212 pub async fn devices(&self) -> Result<Vec<AdbDevice>, AdbError> {
214 let (stdout, _) = self.run_capture(None, "devices", &["-l"]).await?;
215 parse_devices_stdout(&stdout)
216 }
217
218 pub async fn install(&self, serial: &str, apk_path: &Path) -> Result<(), AdbError> {
220 let path = apk_path.to_string_lossy();
221 self.run_capture(Some(serial), "install", &["-r", &path])
222 .await?;
223 Ok(())
224 }
225
226 pub async fn uninstall(&self, serial: &str, package: &str) -> Result<(), AdbError> {
228 self.run_capture(Some(serial), "uninstall", &[package])
229 .await?;
230 Ok(())
231 }
232
233 pub async fn start_activity(
236 &self,
237 serial: &str,
238 package: &str,
239 activity: &str,
240 extras: &[(&str, &str)],
241 ) -> Result<(), AdbError> {
242 let component = format!("{package}/{activity}");
243 let mut args = vec![
244 "am".to_string(),
245 "start".to_string(),
246 "-n".to_string(),
247 component,
248 ];
249 for (k, v) in extras {
250 args.push("--es".to_string());
251 args.push((*k).to_string());
252 args.push((*v).to_string());
253 }
254 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
255 self.run_capture(Some(serial), "shell", &arg_refs).await?;
256 Ok(())
257 }
258
259 pub async fn force_stop(&self, serial: &str, package: &str) -> Result<(), AdbError> {
261 self.run_capture(Some(serial), "shell", &["am", "force-stop", package])
262 .await?;
263 Ok(())
264 }
265
266 pub async fn screenshot(&self, serial: &str) -> Result<Vec<u8>, AdbError> {
269 let mut cmd = self.cmd();
270 cmd.args(["-s", serial, "shell", "screencap", "-p"]);
271 let output = cmd.output().await.map_err(AdbError::from)?;
272 if !output.status.success() {
273 return Err(AdbError::NonZeroExit {
274 subcommand: "shell screencap -p".into(),
275 serial: Some(serial.to_string()),
276 code: output.status.code().unwrap_or(-1),
277 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
278 });
279 }
280 Ok(output.stdout)
281 }
282
283 pub async fn forward(
286 &self,
287 serial: &str,
288 host_port: u16,
289 device_port: u16,
290 ) -> Result<(), AdbError> {
291 let host = format!("tcp:{host_port}");
292 let dev = format!("tcp:{device_port}");
293 self.run_capture(Some(serial), "forward", &[&host, &dev])
294 .await?;
295 Ok(())
296 }
297
298 pub async fn unforward(&self, serial: &str, host_port: u16) -> Result<(), AdbError> {
300 let host = format!("tcp:{host_port}");
301 self.run_capture(Some(serial), "forward", &["--remove", &host])
302 .await?;
303 Ok(())
304 }
305
306 pub async fn shell(&self, serial: &str, cmd: &[&str]) -> Result<String, AdbError> {
308 let (stdout, _) = self.run_capture(Some(serial), "shell", cmd).await?;
309 Ok(stdout)
310 }
311
312 pub async fn pm_grant(
314 &self,
315 serial: &str,
316 package: &str,
317 permission: &str,
318 ) -> Result<(), AdbError> {
319 self.run_capture(Some(serial), "shell", &["pm", "grant", package, permission])
320 .await?;
321 Ok(())
322 }
323
324 pub async fn pm_revoke(
326 &self,
327 serial: &str,
328 package: &str,
329 permission: &str,
330 ) -> Result<(), AdbError> {
331 self.run_capture(
332 Some(serial),
333 "shell",
334 &["pm", "revoke", package, permission],
335 )
336 .await?;
337 Ok(())
338 }
339}
340
341#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn parses_emulator_device_line() {
349 let line = "emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n";
350 let devs = parse_devices_stdout(line).unwrap();
351 assert_eq!(devs.len(), 1);
352 assert_eq!(devs[0].serial, "emulator-5554");
353 assert_eq!(devs[0].state, "device");
354 assert_eq!(devs[0].transport_id.as_deref(), Some("1"));
355 }
356}