droidrun_core/ui/
search.rs1use regex::Regex;
5use serde_json::Value;
6
7pub type ElementFilter = Box<dyn Fn(&[Value]) -> Vec<Value> + Send + Sync>;
9
10pub fn flatten_tree(root: &Value) -> Vec<Value> {
12 let mut results = vec![root.clone()];
13 if let Some(children) = root.get("children").and_then(|c| c.as_array()) {
14 for child in children {
15 results.extend(flatten_tree(child));
16 }
17 }
18 results
19}
20
21pub fn get_element_center(node: &Value) -> (i32, i32) {
23 let bounds = node.get("boundsInScreen").cloned().unwrap_or_default();
24 let left = bounds.get("left").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
25 let top = bounds.get("top").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
26 let right = bounds.get("right").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
27 let bottom = bounds.get("bottom").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
28 ((left + right) / 2, (top + bottom) / 2)
29}
30
31pub fn text_matches(pattern: &str) -> ElementFilter {
35 let regex = Regex::new(®ex::escape(pattern)).unwrap();
36 let pattern_owned = pattern.to_string();
37
38 Box::new(move |nodes: &[Value]| {
39 let all: Vec<Value> = nodes.iter().flat_map(flatten_tree).collect();
40
41 all.into_iter()
42 .filter(|node| {
43 for field in &["text", "contentDescription", "hint"] {
44 if let Some(val) = node.get(field).and_then(|v| v.as_str()) {
45 if val == pattern_owned || regex.is_match(val) {
46 return true;
47 }
48 let normalized = val.replace('\n', " ");
49 if normalized == pattern_owned || regex.is_match(&normalized) {
50 return true;
51 }
52 }
53 }
54 false
55 })
56 .collect()
57 })
58}
59
60pub fn id_matches(pattern: &str) -> ElementFilter {
62 let regex = Regex::new(®ex::escape(pattern)).unwrap();
63 let pattern_owned = pattern.to_string();
64
65 Box::new(move |nodes: &[Value]| {
66 let all: Vec<Value> = nodes.iter().flat_map(flatten_tree).collect();
67
68 all.into_iter()
69 .filter(|node| {
70 if let Some(id) = node.get("resourceId").and_then(|v| v.as_str()) {
71 if id == pattern_owned || regex.is_match(id) {
72 return true;
73 }
74 if let Some(short) = id.rsplit('/').next() {
76 if short == pattern_owned || regex.is_match(short) {
77 return true;
78 }
79 }
80 }
81 false
82 })
83 .collect()
84 })
85}
86
87pub fn clickable() -> ElementFilter {
89 Box::new(|nodes: &[Value]| {
90 let all: Vec<Value> = nodes.iter().flat_map(flatten_tree).collect();
91 all.into_iter()
92 .filter(|n| n.get("isClickable").and_then(|v| v.as_bool()).unwrap_or(false))
93 .collect()
94 })
95}
96
97pub fn has_text() -> ElementFilter {
99 Box::new(|nodes: &[Value]| {
100 let all: Vec<Value> = nodes.iter().flat_map(flatten_tree).collect();
101 all.into_iter()
102 .filter(|n| {
103 n.get("text")
104 .and_then(|v| v.as_str())
105 .map(|s| !s.is_empty())
106 .unwrap_or(false)
107 || n.get("contentDescription")
108 .and_then(|v| v.as_str())
109 .map(|s| !s.is_empty())
110 .unwrap_or(false)
111 })
112 .collect()
113 })
114}
115
116pub fn below(anchor_filter: ElementFilter) -> ElementFilter {
118 Box::new(move |nodes: &[Value]| {
119 let anchor_results = anchor_filter(nodes);
120 let Some(anchor) = anchor_results.first() else {
121 return vec![];
122 };
123
124 let (ax, ay) = get_element_center(anchor);
125 let anchor_bottom = anchor
126 .get("boundsInScreen")
127 .and_then(|b| b.get("bottom"))
128 .and_then(|v| v.as_i64())
129 .unwrap_or(0) as i32;
130
131 let all: Vec<Value> = nodes.iter().flat_map(flatten_tree).collect();
132 let mut candidates: Vec<(f64, Value)> = all
133 .into_iter()
134 .filter(|n| n != anchor)
135 .filter_map(|n| {
136 let top = n
137 .get("boundsInScreen")
138 .and_then(|b| b.get("top"))
139 .and_then(|v| v.as_i64())
140 .unwrap_or(0) as i32;
141 if top > anchor_bottom {
142 let (nx, ny) = get_element_center(&n);
143 let dist = (((nx - ax).pow(2) + (ny - ay).pow(2)) as f64).sqrt();
144 Some((dist, n))
145 } else {
146 None
147 }
148 })
149 .collect();
150
151 candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
152 candidates.into_iter().map(|(_, n)| n).collect()
153 })
154}
155
156pub fn compose(filters: Vec<ElementFilter>) -> ElementFilter {
158 Box::new(move |nodes: &[Value]| {
159 let mut result: Vec<Value> = nodes.to_vec();
160 for f in &filters {
161 result = f(&result);
162 if result.is_empty() {
163 break;
164 }
165 }
166 result
167 })
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use serde_json::json;
174
175 fn sample_tree() -> Vec<Value> {
176 vec![json!({
177 "text": "Hello World",
178 "className": "android.widget.TextView",
179 "resourceId": "com.example:id/title",
180 "isClickable": false,
181 "boundsInScreen": {"left": 0, "top": 0, "right": 500, "bottom": 100},
182 "children": [
183 {
184 "text": "OK",
185 "className": "android.widget.Button",
186 "resourceId": "com.example:id/btn_ok",
187 "isClickable": true,
188 "boundsInScreen": {"left": 100, "top": 200, "right": 300, "bottom": 300},
189 "children": []
190 },
191 {
192 "text": "Cancel",
193 "className": "android.widget.Button",
194 "resourceId": "com.example:id/btn_cancel",
195 "isClickable": true,
196 "boundsInScreen": {"left": 400, "top": 200, "right": 600, "bottom": 300},
197 "children": []
198 }
199 ]
200 })]
201 }
202
203 #[test]
204 fn test_text_matches() {
205 let filter = text_matches("OK");
206 let results = filter(&sample_tree());
207 assert_eq!(results.len(), 1);
208 assert_eq!(results[0].get("text").unwrap().as_str().unwrap(), "OK");
209 }
210
211 #[test]
212 fn test_id_matches_short() {
213 let filter = id_matches("btn_ok");
214 let results = filter(&sample_tree());
215 assert_eq!(results.len(), 1);
216 }
217
218 #[test]
219 fn test_clickable() {
220 let results = clickable()(&sample_tree());
221 assert_eq!(results.len(), 2);
222 }
223
224 #[test]
225 fn test_has_text() {
226 let results = has_text()(&sample_tree());
227 assert_eq!(results.len(), 3); }
229
230 #[test]
231 fn test_flatten_tree() {
232 let flat = flatten_tree(&sample_tree()[0]);
233 assert_eq!(flat.len(), 3);
234 }
235}