Skip to main content

smix_adb/
lib.rs

1//! smix-adb — Android Debug Bridge (adb) child_process wrapper.
2//!
3//! Counterpart of `smix_simctl::SimctlClient` for Android. Used by
4//! `smix_sdk::AndroidDeviceControl` (v6.0 c2) to implement the
5//! `DeviceControl` trait on Android.
6//!
7//! Per docs/plan-cold/v6-android-master.md §1 v6.0 c2 — skeleton with
8//! parser unit tests + command dispatch + error envelope. Real-device
9//! invocations need a booted emulator (acceptance gated by `ignore`
10//! attribute on tests requiring live `adb`).
11//!
12//! ## Wire model
13//!
14//! Each `AdbClient` method spawns `adb -s <serial> <subcommand> ...` via
15//! tokio process, captures stdout+stderr, surfaces non-zero exit / spawn
16//! failure as [`AdbError`] variants. No retries — caller-side concern.
17
18#![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/// Failure variants for any `adb` invocation.
27#[derive(Debug, Error)]
28pub enum AdbError {
29    /// Failed to spawn `adb` (missing binary / PATH lookup / fork failure).
30    #[error("spawn adb failed: {0}")]
31    Spawn(#[from] io::Error),
32    /// `adb` was not found in PATH at all.
33    #[error("adb binary not found in PATH; install Android SDK platform-tools")]
34    BinaryNotFound,
35    /// `adb <sub>` exited non-zero.
36    #[error("adb {subcommand} (serial={serial:?}) exited {code}: {stderr}")]
37    NonZeroExit {
38        /// Subcommand name (e.g. `"install"`, `"shell"`).
39        subcommand: String,
40        /// Device serial if scoped to one (None for global like `devices`).
41        serial: Option<String>,
42        /// Exit code from `adb`.
43        code: i32,
44        /// Captured stderr (truncated).
45        stderr: String,
46    },
47    /// `adb <sub>` exited 0 but stdout didn't match the expected shape.
48    #[error("adb {subcommand} returned malformed output: {detail}")]
49    Malformed {
50        /// Subcommand name.
51        subcommand: String,
52        /// Parser-side detail.
53        detail: String,
54    },
55}
56
57/// One Android device known to `adb devices -l`.
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct AdbDevice {
60    /// Serial (e.g. `"emulator-5554"` or `"0a1b2c3d"`).
61    pub serial: String,
62    /// State: `"device"` (ready), `"offline"`, `"unauthorized"`, etc.
63    pub state: String,
64    /// `product:` field (often the same as model on emulators).
65    pub product: Option<String>,
66    /// `model:` field (e.g. `"sdk_gphone64_arm64"`, `"Pixel_2"`).
67    pub model: Option<String>,
68    /// `device:` field (codename, e.g. `"emu64a"`, `"walleye"`).
69    pub device: Option<String>,
70    /// `transport_id:` field (numeric transport ID).
71    pub transport_id: Option<String>,
72}
73
74/// Parse `adb devices -l` stdout into [`AdbDevice`] entries.
75///
76/// Format (one device per line after header):
77///
78/// ```text
79/// List of devices attached
80/// emulator-5554          device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1
81/// 0a1b2c3d               offline
82/// ```
83///
84/// # Errors
85///
86/// Returns [`AdbError::Malformed`] if the header line is missing or a
87/// non-empty line is unparsable.
88pub 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        // Each device row: <serial>\s+<state>(\s+key:val)*
101        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                    _ => {} // ignore unknown keys (future-compat)
133                }
134            }
135        }
136        out.push(dev);
137    }
138    // Tolerate parser-only callers that pass arbitrary stdout slices
139    // without the header (we still return the parsed rows). saw_header
140    // serves only as documentation of well-formed input.
141    let _ = saw_header;
142    Ok(out)
143}
144
145/// Client wrapping `adb` invocations.
146///
147/// Construction is cheap — internally just an `adb` binary path resolved
148/// via PATH lookup at command-spawn time. No persistent state.
149#[derive(Debug, Default, Clone)]
150pub struct AdbClient {
151    /// Override the `adb` binary path; defaults to `"adb"` (PATH lookup).
152    binary: Option<String>,
153}
154
155impl AdbClient {
156    /// Default constructor — uses `adb` from PATH.
157    #[must_use]
158    pub fn new() -> Self {
159        AdbClient { binary: None }
160    }
161
162    /// Build with an explicit `adb` binary path (useful for tests or
163    /// non-PATH SDK installs).
164    #[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        // first token of subcommand for error wrapping
186        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    /// `adb devices -l` — list all attached devices/emulators.
213    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    /// `adb -s <serial> install -r <apk>` — install (or upgrade) an apk.
219    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    /// `adb -s <serial> uninstall <pkg>` — uninstall a package.
227    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    /// `adb -s <serial> shell am start -n <pkg>/<activity> [args]` — launch
234    /// app's activity. Returns nothing on success.
235    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    /// `adb -s <serial> shell am force-stop <pkg>` — force-stop a package.
260    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    /// `adb -s <serial> shell screencap -p` — capture device screen as PNG.
267    /// Returns raw PNG bytes via stdout.
268    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    /// `adb -s <serial> forward tcp:<host> tcp:<device>` — set up port
284    /// forwarding from host loopback to device port.
285    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    /// `adb -s <serial> forward --remove tcp:<host>` — remove a forward.
299    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    /// `adb -s <serial> shell <cmd...>` — generic shell exec, returns stdout.
307    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    /// `adb -s <serial> shell pm grant <pkg> <android.permission.X>`.
313    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    /// `adb -s <serial> shell pm revoke <pkg> <android.permission.X>`.
325    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// -------------------- unit tests -------------------------------------------
342
343#[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}