Pipe Android/ADB logs into CLI (#4853)

* Enhance Android log handling in AppBuilder

- Introduced adb_logcat fields to manage Android logcat output.
- Implemented logcat spawning and parsing to differentiate log levels.
- Updated push_stdio method to filter Android logs based on trace settings.
- Added utility functions for retrieving app PID and parsing logcat levels.

* automatic retry to get pid

* pipe RustStdoutStderr as well

* strip logcat prefix

* filter in rust instead

* Refine log filtering in Rust output handling

- Improved log level detection by checking for prefixes after the colon in messages.
- Enhanced filtering to only show logs with standard tracing levels or fatal errors.
- Streamlined the process of stripping logcat metadata while preserving message content.

* cleanup output and kill proc

* capture logs from ios device

* clippy

---------

Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
Sabin RegmiandJonathan Kelley authored and GitHub committed 2025-10-28 12:35:34 -07:00
1 parent 24df6439ea
commit 6e3f362872
2 files changed
+162 -12

No files matched your search

+125 -11
View File
@@ -2,7 +2,7 @@ use crate::{
serve::WebServer, verbosity_or_default, BuildArtifacts, BuildRequest, BuildStage, serve::WebServer, verbosity_or_default, BuildArtifacts, BuildRequest, BuildStage,
BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result, RustcArgs, StructuredOutput, BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result, RustcArgs, StructuredOutput,
}; };
use anyhow::{bail, Context}; use anyhow::{bail, Context, Error};
use dioxus_cli_opt::process_file_to; use dioxus_cli_opt::process_file_to;
use futures_util::{future::OptionFuture, pin_mut, FutureExt}; use futures_util::{future::OptionFuture, pin_mut, FutureExt};
use itertools::Itertools; use itertools::Itertools;
@@ -22,6 +22,7 @@ use tokio::{
process::{Child, ChildStderr, ChildStdout, Command}, process::{Child, ChildStderr, ChildStdout, Command},
task::JoinHandle, task::JoinHandle,
}; };
use tokio_stream::wrappers::UnboundedReceiverStream;
use super::{BuildContext, BuildId, BuildMode, HotpatchModuleCache}; use super::{BuildContext, BuildId, BuildMode, HotpatchModuleCache};
@@ -78,6 +79,12 @@ pub(crate) struct AppBuilder {
pub stdout: Option<Lines<BufReader<ChildStdout>>>, pub stdout: Option<Lines<BufReader<ChildStdout>>>,
pub stderr: Option<Lines<BufReader<ChildStderr>>>, pub stderr: Option<Lines<BufReader<ChildStderr>>>,
// Android logcat stream (treated as stderr for error/warn levels)
pub adb_logcat_stdout: Option<UnboundedReceiverStream<String>>,
/// Handle to the task that's monitoring the child process
pub spawn_handle: Option<JoinHandle<Result<()>>>,
/// The executables but with some extra entropy in their name so we can run two instances of the /// The executables but with some extra entropy in their name so we can run two instances of the
/// same app without causing collisions on the filesystem. /// same app without causing collisions on the filesystem.
pub entropy_app_exe: Option<PathBuf>, pub entropy_app_exe: Option<PathBuf>,
@@ -146,6 +153,8 @@ impl AppBuilder {
child: None, child: None,
stderr: None, stderr: None,
stdout: None, stdout: None,
adb_logcat_stdout: None,
spawn_handle: None,
entropy_app_exe: None, entropy_app_exe: None,
artifacts: None, artifacts: None,
patch_cache: None, patch_cache: None,
@@ -200,6 +209,25 @@ impl AppBuilder {
Some(Ok(Some(msg))) = OptionFuture::from(self.stderr.as_mut().map(|f| f.next_line())) => { Some(Ok(Some(msg))) = OptionFuture::from(self.stderr.as_mut().map(|f| f.next_line())) => {
StderrReceived { msg } StderrReceived { msg }
}, },
Some(msg) = OptionFuture::from(self.spawn_handle.as_mut()) => {
match msg {
Ok(Ok(_)) => StdoutReceived { msg: "Finished launching app".to_string() },
Ok(Err(err)) => StderrReceived { msg: err.to_string() },
Err(err) => StderrReceived { msg: err.to_string() }
}
},
Some(Some(msg)) = OptionFuture::from(self.adb_logcat_stdout.as_mut().map(|s| s.next())) => {
// Send as stderr for errors/warnings, stdout for info/debug
// Parse the priority level from a logcat line
//
// Logcat brief format: "I/TAG(12345): message"
// Returns the priority char (V, D, I, W, E, F)
if matches!(msg.chars().next().unwrap_or('I'), 'E' | 'W' | 'F') {
StderrReceived { msg }
} else {
StdoutReceived { msg }
}
},
Some(status) = OptionFuture::from(self.child.as_mut().map(|f| f.wait())) => { Some(status) = OptionFuture::from(self.child.as_mut().map(|f| f.wait())) => {
match status { match status {
Ok(status) => { Ok(status) => {
@@ -562,8 +590,8 @@ impl AppBuilder {
} }
BundleFormat::Ios => { BundleFormat::Ios => {
if let Some(device) = self.build.device_name.as_deref() { if let Some(device) = self.build.device_name.to_owned() {
self.open_ios_device(device).await? self.open_ios_device(&device).await?
} else { } else {
self.open_ios_sim(envs).await? self.open_ios_sim(envs).await?
} }
@@ -631,6 +659,11 @@ impl AppBuilder {
if let Some(entropy_app_exe) = self.entropy_app_exe.take() { if let Some(entropy_app_exe) = self.entropy_app_exe.take() {
_ = std::fs::remove_file(entropy_app_exe); _ = std::fs::remove_file(entropy_app_exe);
} }
// Abort the spawn handle monitoring task if it exists
if let Some(spawn_handle) = self.spawn_handle.take() {
spawn_handle.abort();
}
} }
pub(crate) async fn hotpatch( pub(crate) async fn hotpatch(
@@ -904,10 +937,10 @@ impl AppBuilder {
} }
/// Upload the app to the device and launch it /// Upload the app to the device and launch it
async fn open_ios_device(&self, device_query: &str) -> Result<()> { async fn open_ios_device(&mut self, device_query: &str) -> Result<()> {
let device_query = device_query.to_string(); let device_query = device_query.to_string();
let root_dir = self.build.root_dir().clone(); let root_dir = self.build.root_dir().clone();
tokio::task::spawn(async move { self.spawn_handle = Some(tokio::task::spawn(async move {
// 1. Find an active device // 1. Find an active device
let device_uuid = Self::get_ios_device_uuid(&device_query).await?; let device_uuid = Self::get_ios_device_uuid(&device_query).await?;
@@ -920,7 +953,7 @@ impl AppBuilder {
Self::launch_ios_app_paused(&device_uuid, &installation_url).await?; Self::launch_ios_app_paused(&device_uuid, &installation_url).await?;
Result::Ok(()) as Result<()> Result::Ok(()) as Result<()>
}); }));
Ok(()) Ok(())
} }
@@ -1187,7 +1220,7 @@ impl AppBuilder {
/// # Resources: /// # Resources:
/// - <https://developer.android.com/studio/run/emulator-commandline> /// - <https://developer.android.com/studio/run/emulator-commandline>
async fn open_android( async fn open_android(
&self, &mut self,
root: bool, root: bool,
devserver_socket: SocketAddr, devserver_socket: SocketAddr,
envs: Vec<(String, String)>, envs: Vec<(String, String)>,
@@ -1197,9 +1230,10 @@ impl AppBuilder {
let session_cache = self.build.session_cache_dir(); let session_cache = self.build.session_cache_dir();
let application_id = self.build.bundle_identifier(); let application_id = self.build.bundle_identifier();
let adb = self.build.workspace.android_tools()?.adb.clone(); let adb = self.build.workspace.android_tools()?.adb.clone();
let (stdout_tx, stdout_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
// Start backgrounded since .open() is called while in the arm of the top-level match // Start backgrounded since .open() is called while in the arm of the top-level match
let _handle: JoinHandle<Result<()>> = tokio::task::spawn(async move { let task = tokio::task::spawn(async move {
// call `adb root` so we can push patches to the device // call `adb root` so we can push patches to the device
if root { if root {
if let Err(e) = Command::new(&adb).arg("root").output().await { if let Err(e) = Command::new(&adb).arg("root").output().await {
@@ -1210,7 +1244,7 @@ impl AppBuilder {
// Try to get the transport ID for the device in case there are multiple specified devices // Try to get the transport ID for the device in case there are multiple specified devices
// All future commands should use this since its the most recent. // All future commands should use this since its the most recent.
let transport_id_args = let transport_id_args =
Self::get_android_device_transport_id(&adb, device_name_query).await; Self::get_android_device_transport_id(&adb, device_name_query.as_deref()).await;
// Wait for device to be ready // Wait for device to be ready
let cmd = Command::new(&adb) let cmd = Command::new(&adb)
@@ -1296,9 +1330,66 @@ impl AppBuilder {
tracing::error!("Failed to start app with `adb`: {std_err}"); tracing::error!("Failed to start app with `adb`: {std_err}");
} }
Ok(()) // Try to get the transport ID for the device
let transport_id_args =
Self::get_android_device_transport_id(&adb, device_name_query.as_deref()).await;
// Get the app's PID with retries
// Retry up to 10 times (10 seconds total) since app launch is asynchronous
let mut pid: Option<String> = None;
for attempt in 1..=10 {
match Self::get_android_app_pid(&adb, &application_id, &transport_id_args).await {
Ok(p) => {
pid = Some(p);
break;
}
Err(_) if attempt < 10 => {
tracing::debug!(
"App PID not found yet, retrying in 1 second... (attempt {}/10)",
attempt
);
tokio::time::sleep(Duration::from_secs(1)).await;
}
Err(e) => {
return Err(e).context(
"Failed to get app PID after 10 attempts - app may not have started",
);
}
}
}
let pid = pid.context("Failed to get app PID")?;
// Spawn logcat with filtering
// By default: show only RustStdoutStderr (app Rust logs) and fatal errors
// With tracing enabled: show all logs from the app process
// Note: We always capture at DEBUG level, then filter in Rust based on trace flag
let mut child = Command::new(&adb)
.args(&transport_id_args)
.arg("logcat")
.arg("-v")
.arg("brief")
.arg("--pid")
.arg(&pid)
.arg("*:D") // Capture all logs at DEBUG level (filtered in Rust)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
_ = stdout_tx.send(line);
}
Ok::<(), Error>(())
}); });
self.spawn_handle = Some(task);
self.adb_logcat_stdout = Some(UnboundedReceiverStream::new(stdout_rx));
Ok(()) Ok(())
} }
@@ -1570,7 +1661,7 @@ impl AppBuilder {
async fn get_android_device_transport_id( async fn get_android_device_transport_id(
adb: &PathBuf, adb: &PathBuf,
device_name_query: Option<String>, device_name_query: Option<&str>,
) -> Vec<String> { ) -> Vec<String> {
// If there are multiple devices, we pick the one matching the query // If there are multiple devices, we pick the one matching the query
let mut device_specifier_args = vec![]; let mut device_specifier_args = vec![];
@@ -1621,4 +1712,27 @@ impl AppBuilder {
device_specifier_args device_specifier_args
} }
/// Get the PID of the running Android app
async fn get_android_app_pid(
adb: &Path,
application_id: &str,
transport_id_args: &[String],
) -> Result<String> {
let output = Command::new(adb)
.args(transport_id_args)
.arg("shell")
.arg("pidof")
.arg(application_id)
.output()
.await?;
let pid = String::from_utf8(output.stdout)?.trim().to_string();
if pid.is_empty() {
anyhow::bail!("App process not found - may not have started yet");
}
Ok(pid)
}
} }
+37 -1
View File
@@ -337,7 +337,43 @@ impl Output {
/// This will queue the stderr message as a TraceMsg and print it on the next render /// This will queue the stderr message as a TraceMsg and print it on the next render
/// We'll use the `App` TraceSrc for the msg, and whatever level is provided /// We'll use the `App` TraceSrc for the msg, and whatever level is provided
pub fn push_stdio(&mut self, bundle: BundleFormat, msg: String, level: Level) { pub fn push_stdio(&mut self, bundle: BundleFormat, msg: String, level: Level) {
self.push_log(TraceMsg::text(TraceSrc::App(bundle), level, msg)); match bundle {
// If tracing is disabled, we need to filter out all the noise from Android logcat
BundleFormat::Android if !self.trace => {
// By default (trace off): only show RustStdoutStderr logs with proper log levels
// Filter out raw output like GL bindings, WebView internals, etc.
let is_rust_log = msg.contains("RustStdoutStderr");
let is_fatal = msg.contains("AndroidRuntime") || msg.starts_with('F');
let mut rendered_msg = None;
// If we're not in trace mode, then we need to filter out non-log messages and clean them up
// Only show logs with standard tracing level prefixes (check in the message after the colon)
if is_rust_log {
if let Some(colon_pos) = msg.find(':') {
let content = &msg[colon_pos + 1..];
if content.contains(" TRACE ")
|| content.contains(" DEBUG ")
|| content.contains(" INFO ")
|| content.contains(" WARN ")
|| content.contains(" ERROR ")
{
rendered_msg = Some(content.trim().to_string());
}
}
}
// Always show fatal errors, even if they don't come from Rust logging
if is_fatal {
rendered_msg = Some(msg);
}
if let Some(msg) = rendered_msg {
self.push_log(TraceMsg::text(TraceSrc::App(bundle), level, msg));
}
}
_ => self.push_log(TraceMsg::text(TraceSrc::App(bundle), level, msg)),
}
} }
/// Push a message from the websocket to the logs /// Push a message from the websocket to the logs