From 6e3f36287271be940f3fa30fb46e551d2fdb53c6 Mon Sep 17 00:00:00 2001 From: Sabin Regmi Date: Tue, 28 Oct 2025 15:35:34 -0400 Subject: [PATCH] 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 --- packages/cli/src/build/builder.rs | 136 +++++++++++++++++++++++++++--- packages/cli/src/serve/output.rs | 38 ++++++++- 2 files changed, 162 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/build/builder.rs b/packages/cli/src/build/builder.rs index c2b8f0ae5..5ea225f51 100644 --- a/packages/cli/src/build/builder.rs +++ b/packages/cli/src/build/builder.rs @@ -2,7 +2,7 @@ use crate::{ serve::WebServer, verbosity_or_default, BuildArtifacts, BuildRequest, BuildStage, BuilderUpdate, BundleFormat, ProgressRx, ProgressTx, Result, RustcArgs, StructuredOutput, }; -use anyhow::{bail, Context}; +use anyhow::{bail, Context, Error}; use dioxus_cli_opt::process_file_to; use futures_util::{future::OptionFuture, pin_mut, FutureExt}; use itertools::Itertools; @@ -22,6 +22,7 @@ use tokio::{ process::{Child, ChildStderr, ChildStdout, Command}, task::JoinHandle, }; +use tokio_stream::wrappers::UnboundedReceiverStream; use super::{BuildContext, BuildId, BuildMode, HotpatchModuleCache}; @@ -78,6 +79,12 @@ pub(crate) struct AppBuilder { pub stdout: Option>>, pub stderr: Option>>, + // Android logcat stream (treated as stderr for error/warn levels) + pub adb_logcat_stdout: Option>, + + /// Handle to the task that's monitoring the child process + pub spawn_handle: Option>>, + /// 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. pub entropy_app_exe: Option, @@ -146,6 +153,8 @@ impl AppBuilder { child: None, stderr: None, stdout: None, + adb_logcat_stdout: None, + spawn_handle: None, entropy_app_exe: None, artifacts: 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())) => { 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())) => { match status { Ok(status) => { @@ -562,8 +590,8 @@ impl AppBuilder { } BundleFormat::Ios => { - if let Some(device) = self.build.device_name.as_deref() { - self.open_ios_device(device).await? + if let Some(device) = self.build.device_name.to_owned() { + self.open_ios_device(&device).await? } else { self.open_ios_sim(envs).await? } @@ -631,6 +659,11 @@ impl AppBuilder { if let Some(entropy_app_exe) = self.entropy_app_exe.take() { _ = 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( @@ -904,10 +937,10 @@ impl AppBuilder { } /// 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 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 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?; Result::Ok(()) as Result<()> - }); + })); Ok(()) } @@ -1187,7 +1220,7 @@ impl AppBuilder { /// # Resources: /// - async fn open_android( - &self, + &mut self, root: bool, devserver_socket: SocketAddr, envs: Vec<(String, String)>, @@ -1197,9 +1230,10 @@ impl AppBuilder { let session_cache = self.build.session_cache_dir(); let application_id = self.build.bundle_identifier(); let adb = self.build.workspace.android_tools()?.adb.clone(); + let (stdout_tx, stdout_rx) = tokio::sync::mpsc::unbounded_channel::(); // Start backgrounded since .open() is called while in the arm of the top-level match - let _handle: JoinHandle> = tokio::task::spawn(async move { + let task = tokio::task::spawn(async move { // call `adb root` so we can push patches to the device if root { 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 // All future commands should use this since its the most recent. 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 let cmd = Command::new(&adb) @@ -1296,9 +1330,66 @@ impl AppBuilder { 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 = 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(()) } @@ -1570,7 +1661,7 @@ impl AppBuilder { async fn get_android_device_transport_id( adb: &PathBuf, - device_name_query: Option, + device_name_query: Option<&str>, ) -> Vec { // If there are multiple devices, we pick the one matching the query let mut device_specifier_args = vec![]; @@ -1621,4 +1712,27 @@ impl AppBuilder { 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 { + 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) + } } diff --git a/packages/cli/src/serve/output.rs b/packages/cli/src/serve/output.rs index e2bd677aa..91fac8bc4 100644 --- a/packages/cli/src/serve/output.rs +++ b/packages/cli/src/serve/output.rs @@ -337,7 +337,43 @@ impl Output { /// 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 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