Skip to main content

adbshell/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! `adbshell` — a reusable Rust crate for interacting with Android devices via ADB.
4//!
5//! Provides [`AdbShell`], a concrete implementation of all common ADB operations:
6//! device info queries, file transfers, JAR execution, and reverse-tunnel management.
7//!
8//! # Quick start
9//!
10//! ```rust,no_run
11//! use adbshell::{AdbResult, AdbShell};
12//!
13//! // Verify adb is installed and in PATH
14//! AdbShell::verify_adb_available().expect("adb not found");
15//!
16//! // Get the first connected device serial
17//! let serial = AdbShell::get_device_serial().expect("no device connected");
18//!
19//! // Query a system property
20//! let sdk = AdbShell::get_prop(&serial, "ro.build.version.sdk").unwrap();
21//! println!("SDK: {sdk}");
22//! ```
23
24use {
25    std::{
26        ffi::OsStr,
27        fmt,
28        io::Read,
29        process::{Child, Command, ExitStatus, Stdio},
30        thread,
31        time::Duration,
32    },
33    thiserror::Error,
34    tracing::trace,
35    wait_timeout::ChildExt,
36};
37
38// ── Error type ──────────────────────────────────────────────────────────────
39
40/// Errors produced by ADB operations.
41#[derive(Debug, Error)]
42pub enum AdbError {
43    /// The `adb` binary could not be found or spawned.
44    #[error("ADB not found: {0}")]
45    NotFound(String),
46
47    /// An ADB command was launched but exited with a non-zero status.
48    #[error("ADB command failed: {0}")]
49    CommandFailed(String),
50
51    /// An ADB command timed out.
52    #[error("ADB command timed out")]
53    Timeout,
54
55    /// No device (or the requested device) could be found.
56    #[error("ADB device not found: {0}")]
57    DeviceNotFound(String),
58}
59
60/// Convenience result alias for ADB operations.
61pub type AdbResult<T> = std::result::Result<T, AdbError>;
62
63// ── Device state ─────────────────────────────────────────────────────────────
64
65/// Connection state of an Android device as reported by `adb get-state`.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum DeviceState {
68    /// Device is online and ready (`device`).
69    Connected,
70    /// Device is not connected or unauthorized.
71    Disconnected,
72    /// Device is present but in an unexpected state.
73    Unknown,
74}
75
76// ── AdbShell ─────────────────────────────────────────────────────────────────
77
78/// A unit-struct that provides stateless ADB operations as associated functions.
79///
80/// Every method issues a fresh `adb` subprocess call; no persistent connection
81/// is maintained.  Pass a device `serial` obtained from
82/// [`AdbShell::get_device_serial`] to methods that require one.
83pub struct AdbShell;
84
85impl AdbShell {
86    /// Verify that the `adb` binary is available and functional.
87    ///
88    /// Call once at application startup to fail fast if ADB is not installed
89    /// or not in `PATH`.
90    pub fn verify_adb_available() -> AdbResult<()> {
91        Command::new("adb")
92            .arg("version")
93            .stdout(Stdio::null())
94            .stderr(Stdio::null())
95            .status()
96            .map_err(|e| AdbError::NotFound(e.to_string()))?
97            .success()
98            .then_some(())
99            .ok_or_else(|| AdbError::NotFound("adb returned non-zero exit status".to_string()))
100    }
101
102    /// Return the serial number of the first connected device.
103    ///
104    /// Equivalent to `adb get-serialno`.  Note that if multiple devices are
105    /// connected, `adb get-serialno` returns an error; use `adb devices` to
106    /// list them and select one explicitly.
107    pub fn get_device_serial() -> AdbResult<String> {
108        run_adb_command(["get-serialno"], None, |status, output, stderr| {
109            if !status.success() {
110                return Err(AdbError::DeviceNotFound(if stderr.is_empty() {
111                    format!("adb exited with status: {}", status)
112                } else {
113                    format!(
114                        "adb exited with status: {}; stderr: {}",
115                        status,
116                        stderr.trim()
117                    )
118                }));
119            }
120            // `adb get-serialno` prints "unknown" (with exit 0) when no device is attached
121            // and the empty string when the daemon starts but no device is connected.
122            let serial = output.trim().to_string();
123            if serial.is_empty() || serial == "unknown" {
124                return Err(AdbError::DeviceNotFound(
125                    "no device connected (adb returned \"unknown\")".to_string(),
126                ));
127            }
128            Ok(serial)
129        })
130    }
131
132    /// Return the connection state of a device (`Connected`, `Disconnected`, or `Unknown`).
133    pub fn get_device_state(serial: &str) -> AdbResult<DeviceState> {
134        check_serial(serial)?;
135        run_adb_command(
136            ["-s", serial, "get-state"],
137            None,
138            |status, output, _stderr| {
139                if !status.success() {
140                    return Ok(DeviceState::Disconnected);
141                }
142                match output.trim() {
143                    "device" => Ok(DeviceState::Connected),
144                    _ => Ok(DeviceState::Unknown),
145                }
146            },
147        )
148    }
149
150    /// Return the physical screen size in pixels as `(width, height)`.
151    pub fn get_physical_screen_size(serial: &str) -> AdbResult<(u32, u32)> {
152        check_serial(serial)?;
153        run_adb_command(
154            ["-s", serial, "shell", "wm", "size"],
155            None,
156            |status, output, stderr| {
157                status
158                    .success()
159                    .then(|| {
160                        output
161                            .lines()
162                            .find(|line| line.contains("Physical size:"))
163                            .and_then(|line| line.split(':').nth(1))
164                            .and_then(|size_part| {
165                                let size_str = size_part.trim();
166                                let mut parts = size_str.split('x');
167                                let width = parts.next().and_then(|w| w.trim().parse::<u32>().ok());
168                                let height =
169                                    parts.next().and_then(|h| h.trim().parse::<u32>().ok());
170                                match (width, height) {
171                                    (Some(w), Some(h)) => Some((w, h)),
172                                    _ => None,
173                                }
174                            })
175                    })
176                    .flatten()
177                    .ok_or_else(|| {
178                        AdbError::CommandFailed(if stderr.is_empty() {
179                            format!("Failed to parse screen size from output: {}", output.trim())
180                        } else {
181                            format!("Failed to parse screen size; stderr: {}", stderr.trim())
182                        })
183                    })
184            },
185        )
186    }
187
188    /// Return the current screen orientation (0–3, where 1 = 90°, 2 = 180°, 3 = 270°).
189    pub fn get_screen_orientation(serial: &str) -> AdbResult<u32> {
190        check_serial(serial)?;
191        run_adb_command(
192            ["-s", serial, "shell", "dumpsys", "window", "displays"],
193            Some(Duration::from_secs(3)),
194            |status, output, stderr| {
195                status
196                    .success()
197                    .then(|| {
198                        output
199                            .lines()
200                            .find(|line| line.contains("mCurrentRotation"))
201                            .and_then(|line| line.split('=').nth(1))
202                            .and_then(|s| match s.trim() {
203                                "ROTATION_0" | "0" => Some(0),
204                                "ROTATION_90" | "1" => Some(1),
205                                "ROTATION_180" | "2" => Some(2),
206                                "ROTATION_270" | "3" => Some(3),
207                                _ => None,
208                            })
209                    })
210                    .flatten()
211                    .ok_or_else(|| {
212                        AdbError::CommandFailed(if stderr.is_empty() {
213                            "Failed to parse screen orientation in dumpsys output".to_string()
214                        } else {
215                            format!(
216                                "Failed to parse screen orientation; stderr: {}",
217                                stderr.trim()
218                            )
219                        })
220                    })
221            },
222        )
223    }
224
225    /// Return `true` if the soft keyboard (IME) is currently visible.
226    pub fn get_ime_state(serial: &str) -> AdbResult<bool> {
227        check_serial(serial)?;
228        run_adb_command(
229            ["-s", serial, "shell", "dumpsys", "window", "InputMethod"],
230            Some(Duration::from_secs(3)),
231            |status, output, stderr| {
232                if !status.success() {
233                    return Err(AdbError::CommandFailed(if stderr.is_empty() {
234                        format!(
235                            "Failed to query IME state on [{}], adb exited with status: {}",
236                            serial, status
237                        )
238                    } else {
239                        format!(
240                            "Failed to query IME state on [{}], adb exited with status: {}; \
241                             stderr: {}",
242                            serial,
243                            status,
244                            stderr.trim()
245                        )
246                    }));
247                }
248                Ok(output.lines().any(|line| line.contains("isVisible=true")))
249            },
250        )
251    }
252
253    /// Return the Android API level (e.g. `30` for Android 11).
254    pub fn get_android_version(serial: &str) -> AdbResult<u32> {
255        let output = Self::get_prop(serial, "ro.build.version.sdk")?;
256        output
257            .trim()
258            .parse()
259            .map_err(|e| AdbError::CommandFailed(format!("Failed to parse Android version: {}", e)))
260    }
261
262    /// Return the device platform string (board or hardware name).
263    pub fn get_platform(serial: &str) -> AdbResult<String> {
264        let platform = Self::get_prop(serial, "ro.board.platform")?;
265        if !platform.is_empty() && platform != "unknown" {
266            return Ok(platform);
267        }
268        Self::get_prop(serial, "ro.hardware")
269    }
270
271    /// Return the value of an Android system property (`getprop <prop_name>`).
272    pub fn get_prop(serial: &str, prop_name: &str) -> AdbResult<String> {
273        check_serial(serial)?;
274        run_adb_command(
275            ["-s", serial, "shell", "getprop", prop_name],
276            None,
277            |status, output, stderr| {
278                status
279                    .success()
280                    .then(|| output.trim().to_string())
281                    .ok_or_else(|| {
282                        AdbError::CommandFailed(if stderr.is_empty() {
283                            format!(
284                                "Failed to get property [{}], adb exited with status: {}",
285                                prop_name, status
286                            )
287                        } else {
288                            format!(
289                                "Failed to get property [{}], adb exited with status: {}; stderr: {}",
290                                prop_name, status, stderr.trim()
291                            )
292                        })
293                    })
294            },
295        )
296    }
297
298    /// Push a local file to the device at the given path.
299    pub fn push_file(serial: &str, file: &str, path: &str) -> AdbResult<()> {
300        check_serial(serial)?;
301        let out = Command::new("adb")
302            .args(["-s", serial, "push", file, path])
303            .stdout(Stdio::null())
304            .stderr(Stdio::piped())
305            .output()
306            .map_err(|e| AdbError::NotFound(format!("Failed to spawn adb: {}", e)))?;
307        if !out.status.success() {
308            let stderr = String::from_utf8_lossy(&out.stderr);
309            return Err(AdbError::CommandFailed(if stderr.trim().is_empty() {
310                format!(
311                    "Failed to push file [{}] to [{}], exit: {}",
312                    file, path, out.status
313                )
314            } else {
315                format!(
316                    "Failed to push file [{}] to [{}], exit: {}; stderr: {}",
317                    file,
318                    path,
319                    out.status,
320                    stderr.trim()
321                )
322            }));
323        }
324        Ok(())
325    }
326
327    /// Execute a JAR file on the device via `app_process` and return the spawned [`Child`].
328    pub fn execute_jar<I, S>(
329        serial: &str,
330        jar: &str,
331        running_dir: &str,
332        class_name: &str,
333        version: &str,
334        args: I,
335    ) -> AdbResult<Child>
336    where
337        I: IntoIterator<Item = S>,
338        S: AsRef<OsStr>,
339    {
340        check_serial(serial)?;
341        let child = Command::new("adb")
342            .args(["-s", serial])
343            .args([
344                "shell".to_string(),
345                format!("CLASSPATH={}", jar),
346                "app_process".to_string(),
347                running_dir.to_string(),
348                class_name.to_string(),
349                version.to_string(),
350            ])
351            .args(args)
352            .stdout(Stdio::null())
353            .stderr(Stdio::null())
354            .spawn()
355            .map_err(|e| {
356                AdbError::CommandFailed(format!(
357                    "Failed to execute JAR [{}] on device [{}]: {}",
358                    jar, serial, e
359                ))
360            })?;
361        Ok(child)
362    }
363
364    /// Set up an ADB reverse tunnel: `localabstract:<socket_name>` → `tcp:<local_port>`.
365    pub fn setup_reverse_tunnel(serial: &str, socket_name: &str, local_port: u16) -> AdbResult<()> {
366        check_serial(serial)?;
367        let status = Command::new("adb")
368            .args([
369                "-s",
370                serial,
371                "reverse",
372                &format!("localabstract:{}", socket_name),
373                &format!("tcp:{}", local_port),
374            ])
375            .status()
376            .map_err(|e| {
377                AdbError::CommandFailed(format!(
378                    "Failed to setup reverse tunnel for [{}]: {}",
379                    serial, e
380                ))
381            })?;
382        if !status.success() {
383            return Err(AdbError::CommandFailed(format!(
384                "Failed to setup reverse tunnel for [{}], exit: {}",
385                serial, status
386            )));
387        }
388        Ok(())
389    }
390
391    /// Remove an ADB reverse tunnel.  Silently succeeds if the tunnel is already gone.
392    pub fn remove_reverse_tunnel(serial: &str, socket_name: &str) -> AdbResult<()> {
393        check_serial(serial)?;
394        let output = Command::new("adb")
395            .args([
396                "-s",
397                serial,
398                "reverse",
399                "--remove",
400                &format!("localabstract:{}", socket_name),
401            ])
402            .output();
403        match output {
404            Ok(out) if !out.status.success() => {
405                let stderr = String::from_utf8_lossy(&out.stderr);
406                if stderr.contains("not found") || stderr.contains("No such reverse") {
407                    return Ok(());
408                }
409                Err(AdbError::CommandFailed(if stderr.is_empty() {
410                    format!(
411                        "Failed to remove reverse tunnel for [{}], exit: {}",
412                        serial, out.status
413                    )
414                } else {
415                    format!(
416                        "Failed to remove reverse tunnel for [{}], exit: {}, stderr: {}",
417                        serial,
418                        out.status,
419                        stderr.trim()
420                    )
421                }))
422            }
423            Ok(_) => Ok(()),
424            Err(e) => Err(AdbError::CommandFailed(format!(
425                "Failed to remove reverse tunnel for [{}]: {}",
426                serial, e
427            ))),
428        }
429    }
430}
431
432// ── Helpers ───────────────────────────────────────────────────────────────────
433
434/// Spawn `adb <args>`, wait (with optional timeout), then parse the output.
435///
436/// When no timeout is given, uses `Command::output()` which reads both stdout
437/// and stderr concurrently before waiting — this prevents the pipe-buffer
438/// deadlock that can occur if `wait()` is called before the output pipes are
439/// drained.
440///
441/// When a timeout is given, two threads are spawned to drain stdout and stderr
442/// concurrently while the main thread waits with a timeout.
443fn run_adb_command<I, S, F, R>(args: I, timeout: Option<Duration>, parse: F) -> AdbResult<R>
444where
445    I: IntoIterator<Item = S> + fmt::Debug,
446    S: AsRef<OsStr>,
447    F: FnOnce(ExitStatus, &str, &str) -> AdbResult<R>,
448{
449    trace!("Running adb command with args: {:?}", args);
450
451    if timeout.is_none() {
452        // Fast path: no timeout — use output() which drains both pipes safely.
453        let out = Command::new("adb")
454            .args(args)
455            .output()
456            .map_err(|e| AdbError::NotFound(format!("Failed to spawn adb: {e}")))?;
457        let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
458        let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
459        return parse(out.status, &stdout, &stderr);
460    }
461
462    // Timeout path: spawn and drain stdout/stderr in two background threads.
463    let mut child = Command::new("adb")
464        .args(args)
465        .stdout(Stdio::piped())
466        .stderr(Stdio::piped())
467        .spawn()
468        .map_err(|e| AdbError::NotFound(format!("Failed to spawn adb: {e}")))?;
469    let stdout_pipe = child.stdout.take();
470    let stderr_pipe = child.stderr.take();
471
472    // Drain stdout in a background thread.
473    let stdout_thread = thread::spawn(move || {
474        let mut buf = Vec::new();
475        if let Some(mut pipe) = stdout_pipe {
476            let _ = pipe.read_to_end(&mut buf);
477        }
478        buf
479    });
480    // Drain stderr in a background thread.
481    let stderr_thread = thread::spawn(move || {
482        let mut buf = Vec::new();
483        if let Some(mut pipe) = stderr_pipe {
484            let _ = pipe.read_to_end(&mut buf);
485        }
486        buf
487    });
488
489    let to = timeout.unwrap();
490    let status = match child
491        .wait_timeout(to)
492        .map_err(|e| AdbError::CommandFailed(format!("Failed to wait with timeout for adb: {e}")))?
493    {
494        Some(s) => s,
495        None => {
496            let _ = child.kill();
497            let _ = child.wait();
498            return Err(AdbError::Timeout);
499        }
500    };
501
502    let stdout_bytes = stdout_thread.join().unwrap_or_default();
503    let stderr_bytes = stderr_thread.join().unwrap_or_default();
504    let stdout = String::from_utf8_lossy(&stdout_bytes).into_owned();
505    let stderr = String::from_utf8_lossy(&stderr_bytes).into_owned();
506    parse(status, &stdout, &stderr)
507}
508
509fn check_serial(serial: &str) -> AdbResult<()> {
510    if serial.is_empty() {
511        return Err(AdbError::CommandFailed(
512            "Device serial cannot be empty".to_string(),
513        ));
514    }
515    Ok(())
516}