use std::fmt;
#[derive(Debug, Clone, serde::Serialize)]
pub struct UiElement {
pub index: u32,
pub class: String,
pub text: String,
pub content_desc: String,
pub resource_id: String,
pub clickable: bool,
pub focusable: bool,
pub scrollable: bool,
pub checkable: bool,
pub enabled: bool,
pub bounds: ((u32, u32), (u32, u32)),
pub center: (u32, u32),
}
impl UiElement {
pub fn is_interactive(&self) -> bool {
self.clickable || self.focusable || self.scrollable || self.checkable
}
}
impl fmt::Display for UiElement {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] ", self.index)?;
if self.is_interactive() {
write!(f, "{}", self.class)?;
} else {
write!(f, "[{}]", self.class)?;
}
if !self.text.is_empty() {
write!(f, " \"{}\"", self.text)?;
if !self.content_desc.is_empty() && self.content_desc != self.text {
write!(f, " desc:\"{}\"", self.content_desc)?;
}
} else if !self.content_desc.is_empty() {
write!(f, " desc:\"{}\"", self.content_desc)?;
}
if self.text.is_empty() && self.content_desc.is_empty() && !self.resource_id.is_empty() {
write!(f, " id:{}", self.resource_id)?;
}
write!(f, " ({}, {})", self.center.0, self.center.1)?;
let mut props = Vec::new();
if self.focusable && !self.clickable {
props.push("focusable");
}
if self.scrollable {
props.push("scrollable");
}
if self.checkable {
props.push("checkable");
}
if !self.enabled {
props.push("disabled");
}
if !props.is_empty() {
write!(f, " {}", props.join(" "))?;
}
Ok(())
}
}
fn short_class(class: &str) -> String {
class.rsplit('.').next().unwrap_or(class).to_string()
}
fn parse_bounds(bounds: &str) -> Option<((u32, u32), (u32, u32))> {
let stripped = bounds.replace('[', "").replace(']', ",");
let nums: Vec<u32> = stripped
.split(',')
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() == 4 {
Some(((nums[0], nums[1]), (nums[2], nums[3])))
} else {
None
}
}
fn short_resource_id(id: &str) -> String {
if let Some(pos) = id.rfind('/') {
id[pos + 1..].to_string()
} else {
id.to_string()
}
}
pub fn parse_elements(xml: &str, interactive_only: bool) -> Vec<UiElement> {
let doc = match roxmltree::Document::parse(xml) {
Ok(d) => d,
Err(_) => return Vec::new(),
};
let mut elements = Vec::new();
let mut index = 0u32;
for node in doc.descendants() {
if !node.is_element() || node.tag_name().name() != "node" {
continue;
}
let text = node.attribute("text").unwrap_or("").to_string();
let content_desc = node.attribute("content-desc").unwrap_or("").to_string();
let resource_id = short_resource_id(node.attribute("resource-id").unwrap_or(""));
let class = short_class(node.attribute("class").unwrap_or("View"));
let clickable = node.attribute("clickable") == Some("true");
let focusable = node.attribute("focusable") == Some("true");
let scrollable = node.attribute("scrollable") == Some("true");
let checkable = node.attribute("checkable") == Some("true");
let enabled = node.attribute("enabled") != Some("false");
let bounds_str = node.attribute("bounds").unwrap_or("");
let bounds = match parse_bounds(bounds_str) {
Some(b) => b,
None => continue,
};
let center = (
(bounds.0 .0 + bounds.1 .0) / 2,
(bounds.0 .1 + bounds.1 .1) / 2,
);
let is_interactive = clickable || focusable || scrollable || checkable;
let has_label = !text.is_empty() || !content_desc.is_empty();
if interactive_only && !is_interactive && !has_label {
continue;
}
index += 1;
elements.push(UiElement {
index,
class,
text,
content_desc,
resource_id,
clickable,
focusable,
scrollable,
checkable,
enabled,
bounds,
center,
});
}
elements
}
pub fn format_elements(elements: &[UiElement]) -> String {
elements
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout"
package="com.example" content-desc="" checkable="false" checked="false"
clickable="false" enabled="true" focusable="false" focused="false"
scrollable="false" long-clickable="false" password="false" selected="false"
bounds="[0,0][1080,2340]">
<node index="0" text="Username" resource-id="com.example:id/username"
class="android.widget.EditText" package="com.example" content-desc=""
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[100,400][980,500]" />
<node index="1" text="Password" resource-id="com.example:id/password"
class="android.widget.EditText" package="com.example" content-desc=""
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="true" selected="false" bounds="[100,550][980,650]" />
<node index="2" text="Login" resource-id="com.example:id/login_btn"
class="android.widget.Button" package="com.example" content-desc=""
checkable="false" checked="false" clickable="true" enabled="true"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[200,700][880,800]" />
<node index="3" text="Forgot password?" resource-id=""
class="android.widget.TextView" package="com.example" content-desc=""
checkable="false" checked="false" clickable="true" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[300,850][780,920]" />
<node index="4" text="" resource-id="com.example:id/logo"
class="android.widget.ImageView" package="com.example"
content-desc="Company logo"
checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[340,100][740,300]" />
<node index="5" text="" resource-id="com.example:id/spacer"
class="android.view.View" package="com.example" content-desc=""
checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[0,920][1080,940]" />
<node index="6" text="" resource-id="com.example:id/scroll_container"
class="android.widget.ScrollView" package="com.example" content-desc=""
checkable="false" checked="false" clickable="false" enabled="true"
focusable="false" focused="false" scrollable="true" long-clickable="false"
password="false" selected="false" bounds="[0,940][1080,2340]" />
<node index="7" text="Remember me" resource-id="com.example:id/remember"
class="android.widget.CheckBox" package="com.example" content-desc=""
checkable="true" checked="false" clickable="true" enabled="false"
focusable="true" focused="false" scrollable="false" long-clickable="false"
password="false" selected="false" bounds="[100,1000][500,1080]" />
</node>
</hierarchy>"#;
#[test]
fn parse_bounds_valid() {
assert_eq!(
parse_bounds("[100,200][300,400]"),
Some(((100, 200), (300, 400)))
);
}
#[test]
fn parse_bounds_invalid() {
assert_eq!(parse_bounds("bad"), None);
assert_eq!(parse_bounds("[100,200]"), None);
assert_eq!(parse_bounds(""), None);
}
#[test]
fn short_class_strips_package() {
assert_eq!(short_class("android.widget.Button"), "Button");
assert_eq!(short_class("Button"), "Button");
assert_eq!(short_class("com.custom.views.MyButton"), "MyButton");
}
#[test]
fn short_resource_id_strips_prefix() {
assert_eq!(short_resource_id("com.example:id/login_btn"), "login_btn");
assert_eq!(short_resource_id("login_btn"), "login_btn");
assert_eq!(short_resource_id(""), "");
}
#[test]
fn parse_elements_interactive_only() {
let elements = parse_elements(SAMPLE_XML, true);
assert_eq!(elements.len(), 7);
assert_eq!(elements[0].text, "Username");
assert_eq!(elements[0].class, "EditText");
assert!(elements[0].clickable);
assert!(elements[0].focusable);
assert_eq!(elements[0].center, (540, 450));
let login = elements.iter().find(|e| e.text == "Login").unwrap();
assert_eq!(login.class, "Button");
assert_eq!(login.center, (540, 750));
let logo = elements
.iter()
.find(|e| e.content_desc == "Company logo")
.unwrap();
assert_eq!(logo.class, "ImageView");
assert!(!logo.is_interactive());
let scroll = elements.iter().find(|e| e.class == "ScrollView").unwrap();
assert!(scroll.scrollable);
assert!(scroll.is_interactive());
let checkbox = elements.iter().find(|e| e.text == "Remember me").unwrap();
assert!(checkbox.checkable);
assert!(!checkbox.enabled);
}
#[test]
fn parse_elements_all() {
let all = parse_elements(SAMPLE_XML, false);
let interactive = parse_elements(SAMPLE_XML, true);
assert!(all.len() > interactive.len());
}
#[test]
fn format_compact_output() {
let elements = parse_elements(SAMPLE_XML, true);
let output = format_elements(&elements);
assert!(output.contains("[1] EditText \"Username\" (540, 450)"));
assert!(output.contains("[3] Button \"Login\" (540, 750)"));
assert!(output.contains("TextView \"Forgot password?\""));
assert!(output.contains("[ImageView] desc:\"Company logo\""));
assert!(output.contains("ScrollView id:scroll_container"));
assert!(output.contains("scrollable"));
assert!(output.contains("CheckBox \"Remember me\""));
assert!(output.contains("disabled"));
assert!(output.contains("checkable"));
}
#[test]
fn center_calculation() {
let elements = parse_elements(SAMPLE_XML, true);
let login = elements.iter().find(|e| e.text == "Login").unwrap();
assert_eq!(login.center, (540, 750));
assert_eq!(login.bounds, ((200, 700), (880, 800)));
}
#[test]
fn empty_xml_returns_empty() {
assert!(parse_elements("", true).is_empty());
assert!(parse_elements("not xml at all", true).is_empty());
}
#[test]
fn indices_are_sequential() {
let elements = parse_elements(SAMPLE_XML, true);
for (i, el) in elements.iter().enumerate() {
assert_eq!(el.index, (i + 1) as u32);
}
}
}