1use base64::Engine;
7use serde_json::Value;
8use tracing::{debug, warn};
9
10use droidrun_adb::AdbDevice;
11
12use crate::driver::AppInfo;
13use crate::error::{DroidrunError, Result};
14
15pub struct PortalClient {
17 device: AdbDevice,
18 prefer_tcp: bool,
19 remote_port: u16,
20 tcp_available: bool,
21 tcp_base_url: Option<String>,
22 local_tcp_port: Option<u16>,
23 http: reqwest::Client,
24 connected: bool,
25}
26
27impl PortalClient {
28 pub fn new(device: AdbDevice, prefer_tcp: bool, remote_port: u16) -> Self {
29 Self {
30 device,
31 prefer_tcp,
32 remote_port,
33 tcp_available: false,
34 tcp_base_url: None,
35 local_tcp_port: None,
36 http: reqwest::Client::builder()
37 .timeout(std::time::Duration::from_secs(10))
38 .build()
39 .unwrap_or_default(),
40 connected: false,
41 }
42 }
43
44 pub async fn connect(&mut self) -> Result<()> {
46 if self.connected {
47 return Ok(());
48 }
49 if self.prefer_tcp {
50 self.try_enable_tcp().await;
51 }
52 self.connected = true;
53 Ok(())
54 }
55
56 pub async fn get_state(&self) -> Result<Value> {
60 if self.tcp_available {
61 match self.get_state_tcp().await {
62 Ok(state) => return Ok(state),
63 Err(e) => debug!("TCP get_state failed: {e}, using fallback"),
64 }
65 }
66 self.get_state_content_provider().await
67 }
68
69 pub async fn input_text(&self, text: &str, clear: bool) -> Result<bool> {
71 if self.tcp_available {
72 match self.input_text_tcp(text, clear).await {
73 Ok(result) => return Ok(result),
74 Err(e) => debug!("TCP input_text failed: {e}, using fallback"),
75 }
76 }
77 self.input_text_content_provider(text, clear).await
78 }
79
80 pub async fn take_screenshot(&self, hide_overlay: bool) -> Result<Vec<u8>> {
82 if self.tcp_available {
83 match self.screenshot_tcp(hide_overlay).await {
84 Ok(bytes) => return Ok(bytes),
85 Err(e) => debug!("TCP screenshot failed: {e}, using fallback"),
86 }
87 }
88 self.screenshot_adb().await
89 }
90
91 pub async fn get_apps(&self, include_system: bool) -> Result<Vec<AppInfo>> {
93 let output = self
94 .device
95 .shell("content query --uri content://com.droidrun.portal/packages")
96 .await
97 .map_err(DroidrunError::Adb)?;
98
99 let data = parse_content_provider_output(&output)
100 .ok_or_else(|| DroidrunError::Parse("cannot parse packages response".into()))?;
101
102 let packages_list = extract_packages_list(&data);
104
105 match packages_list {
106 Some(list) => {
107 let apps: Vec<AppInfo> = list
108 .iter()
109 .filter_map(|item| {
110 let obj = item.as_object()?;
111 if !include_system && obj.get("isSystemApp")?.as_bool().unwrap_or(false) {
112 return None;
113 }
114 Some(AppInfo {
115 package: obj
116 .get("packageName")
117 .and_then(|v| v.as_str())
118 .unwrap_or("")
119 .to_string(),
120 label: obj
121 .get("label")
122 .and_then(|v| v.as_str())
123 .unwrap_or("")
124 .to_string(),
125 })
126 })
127 .collect();
128 debug!("found {} apps", apps.len());
129 Ok(apps)
130 }
131 None => {
132 warn!("could not extract packages list from response");
133 Ok(vec![])
134 }
135 }
136 }
137
138 pub async fn get_version(&self) -> Result<String> {
140 if self.tcp_available {
141 if let Ok(resp) = self
142 .http
143 .get(format!("{}/version", self.base_url()))
144 .send()
145 .await
146 {
147 if resp.status().is_success() {
148 if let Ok(data) = resp.json::<Value>().await {
149 if let Some(v) = extract_inner_value(&data) {
150 return Ok(v.as_str().unwrap_or("unknown").to_string());
151 }
152 }
153 }
154 }
155 }
156
157 let output = self
159 .device
160 .shell("content query --uri content://com.droidrun.portal/version")
161 .await
162 .map_err(DroidrunError::Adb)?;
163 if let Some(data) = parse_content_provider_output(&output) {
164 if let Some(s) = data.as_str() {
167 return Ok(s.to_string());
168 }
169 if let Some(v) = extract_inner_value(&data) {
170 return Ok(v.as_str().unwrap_or("unknown").to_string());
171 }
172 }
173 Ok("unknown".to_string())
174 }
175
176 pub async fn ping(&self) -> Result<Value> {
178 if self.tcp_available {
179 let resp = self
180 .http
181 .get(format!("{}/ping", self.base_url()))
182 .send()
183 .await
184 .map_err(DroidrunError::Http)?;
185 if resp.status().is_success() {
186 return Ok(serde_json::json!({
187 "status": "success",
188 "method": "tcp",
189 "url": self.base_url(),
190 }));
191 }
192 }
193
194 let output = self
196 .device
197 .shell("content query --uri content://com.droidrun.portal/state")
198 .await
199 .map_err(DroidrunError::Adb)?;
200 if output.contains("Row: 0 result=") {
201 Ok(serde_json::json!({
202 "status": "success",
203 "method": "content_provider",
204 }))
205 } else {
206 Err(DroidrunError::PortalCommError(
207 "Portal not reachable".into(),
208 ))
209 }
210 }
211
212 async fn try_enable_tcp(&mut self) {
215 if let Err(e) = self.try_enable_tcp_inner().await {
216 warn!("TCP unavailable ({e}), using content provider fallback");
217 self.tcp_available = false;
218 }
219 }
220
221 async fn try_enable_tcp_inner(&mut self) -> Result<()> {
222 let local_port = match self.find_existing_forward().await? {
224 Some(port) => {
225 debug!("reusing existing forward: localhost:{port} -> device:{}", self.remote_port);
226 port
227 }
228 None => {
229 debug!("creating new forward for port {}", self.remote_port);
230 self.device
231 .forward(0, self.remote_port)
232 .await
233 .map_err(DroidrunError::Adb)?
234 }
235 };
236
237 self.local_tcp_port = Some(local_port);
238 self.tcp_base_url = Some(format!("http://localhost:{local_port}"));
239
240 if self.test_tcp_connection().await {
242 self.tcp_available = true;
243 debug!("TCP mode enabled: {}", self.base_url());
244 return Ok(());
245 }
246
247 debug!("TCP ping failed, trying to enable Portal HTTP server...");
249 let _ = self.device.shell(
250 r#"content insert --uri content://com.droidrun.portal/toggle_socket_server --bind enabled:b:true"#
251 ).await;
252 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
253
254 if self.test_tcp_connection().await {
255 self.tcp_available = true;
256 debug!("TCP mode enabled after starting server: {}", self.base_url());
257 Ok(())
258 } else {
259 Err(DroidrunError::PortalCommError(
260 "TCP unavailable after enabling server".into(),
261 ))
262 }
263 }
264
265 async fn find_existing_forward(&self) -> Result<Option<u16>> {
266 let forwards = self
267 .device
268 .forward_list()
269 .await
270 .map_err(DroidrunError::Adb)?;
271 let expected_remote = format!("tcp:{}", self.remote_port);
272 Ok(forwards
273 .iter()
274 .find(|f| f.remote == expected_remote)
275 .and_then(|f| f.local_port()))
276 }
277
278 async fn test_tcp_connection(&self) -> bool {
279 match self
280 .http
281 .get(format!("{}/ping", self.base_url()))
282 .timeout(std::time::Duration::from_secs(5))
283 .send()
284 .await
285 {
286 Ok(resp) => resp.status().is_success(),
287 Err(e) => {
288 debug!("TCP ping failed: {e}");
289 false
290 }
291 }
292 }
293
294 fn base_url(&self) -> &str {
295 self.tcp_base_url.as_deref().unwrap_or("http://localhost:0")
296 }
297
298 async fn get_state_tcp(&self) -> Result<Value> {
299 let resp = self
300 .http
301 .get(format!("{}/state_full", self.base_url()))
302 .send()
303 .await
304 .map_err(DroidrunError::Http)?;
305
306 if !resp.status().is_success() {
307 return Err(DroidrunError::PortalCommError(format!(
308 "HTTP {}",
309 resp.status()
310 )));
311 }
312
313 let data: Value = resp.json().await.map_err(DroidrunError::Http)?;
314 Ok(unwrap_portal_response(data))
315 }
316
317 async fn get_state_content_provider(&self) -> Result<Value> {
318 let output = self
319 .device
320 .shell("content query --uri content://com.droidrun.portal/state_full")
321 .await
322 .map_err(DroidrunError::Adb)?;
323
324 parse_content_provider_output(&output)
326 .ok_or_else(|| {
327 DroidrunError::Parse("failed to parse state data from ContentProvider".into())
328 })
329 }
330
331 async fn input_text_tcp(&self, text: &str, clear: bool) -> Result<bool> {
332 let encoded = base64::engine::general_purpose::STANDARD.encode(text);
333 let payload = serde_json::json!({
334 "base64_text": encoded,
335 "clear": clear,
336 });
337
338 let resp = self
339 .http
340 .post(format!("{}/keyboard/input", self.base_url()))
341 .json(&payload)
342 .send()
343 .await
344 .map_err(DroidrunError::Http)?;
345
346 Ok(resp.status().is_success())
347 }
348
349 async fn input_text_content_provider(&self, text: &str, clear: bool) -> Result<bool> {
350 let encoded = base64::engine::general_purpose::STANDARD.encode(text);
351 let clear_str = if clear { "true" } else { "false" };
352 let cmd = format!(
353 r#"content insert --uri "content://com.droidrun.portal/keyboard/input" --bind base64_text:s:"{encoded}" --bind clear:b:{clear_str}"#
354 );
355 self.device
356 .shell(&cmd)
357 .await
358 .map_err(DroidrunError::Adb)?;
359 Ok(true)
360 }
361
362 async fn screenshot_tcp(&self, hide_overlay: bool) -> Result<Vec<u8>> {
363 let mut url = format!("{}/screenshot", self.base_url());
364 if !hide_overlay {
365 url.push_str("?hideOverlay=false");
366 }
367
368 let resp = self
369 .http
370 .get(&url)
371 .send()
372 .await
373 .map_err(DroidrunError::Http)?;
374
375 if !resp.status().is_success() {
376 return Err(DroidrunError::PortalCommError(format!(
377 "screenshot HTTP {}",
378 resp.status()
379 )));
380 }
381
382 let data: Value = resp.json().await.map_err(DroidrunError::Http)?;
383 if data.get("status").and_then(|v| v.as_str()) == Some("success") {
384 let b64 = extract_inner_value(&data)
385 .and_then(|v| v.as_str().map(|s| s.to_string()))
386 .ok_or_else(|| DroidrunError::Parse("no screenshot data in response".into()))?;
387 let bytes = base64::engine::general_purpose::STANDARD
388 .decode(&b64)
389 .map_err(|e| DroidrunError::Parse(format!("base64 decode error: {e}")))?;
390 Ok(bytes)
391 } else {
392 Err(DroidrunError::PortalCommError(
393 "screenshot response status != success".into(),
394 ))
395 }
396 }
397
398 async fn screenshot_adb(&self) -> Result<Vec<u8>> {
399 let data = self
400 .device
401 .screencap()
402 .await
403 .map_err(DroidrunError::Adb)?;
404 debug!("screenshot taken via ADB ({} bytes)", data.len());
405 Ok(data)
406 }
407}
408
409pub fn parse_content_provider_output(raw: &str) -> Option<Value> {
415 for line in raw.lines() {
416 let line = line.trim();
417
418 if let Some(json_start) = line.find("result=") {
420 let json_str = &line[json_start + 7..];
421 if let Ok(parsed) = serde_json::from_str::<Value>(json_str) {
422 return Some(unwrap_portal_response(parsed));
423 }
424 }
425
426 if line.starts_with('{') || line.starts_with('[') {
428 if let Ok(parsed) = serde_json::from_str::<Value>(line) {
429 return Some(unwrap_portal_response(parsed));
430 }
431 }
432 }
433
434 serde_json::from_str::<Value>(raw.trim())
436 .ok()
437 .map(unwrap_portal_response)
438}
439
440fn unwrap_portal_response(data: Value) -> Value {
442 if let Some(obj) = data.as_object() {
443 for key in &["result", "data"] {
445 if let Some(inner) = obj.get(*key) {
446 if let Some(s) = inner.as_str() {
448 if let Ok(parsed) = serde_json::from_str::<Value>(s) {
449 return parsed;
450 }
451 return inner.clone();
453 }
454 return inner.clone();
455 }
456 }
457 }
458 data
459}
460
461fn extract_inner_value(data: &Value) -> Option<&Value> {
463 data.as_object().and_then(|obj| {
464 obj.get("result").or_else(|| obj.get("data"))
465 })
466}
467
468fn extract_packages_list(data: &Value) -> Option<&Vec<Value>> {
470 if let Some(arr) = data.as_array() {
472 return Some(arr);
473 }
474 if let Some(obj) = data.as_object() {
476 if let Some(pkgs) = obj.get("packages").and_then(|v| v.as_array()) {
477 return Some(pkgs);
478 }
479 for key in &["result", "data"] {
481 if let Some(inner) = obj.get(*key) {
482 if let Some(arr) = inner.as_array() {
483 return Some(arr);
484 }
485 if let Some(inner_obj) = inner.as_object() {
486 if let Some(pkgs) = inner_obj.get("packages").and_then(|v| v.as_array()) {
487 return Some(pkgs);
488 }
489 }
490 }
491 }
492 }
493 None
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn test_parse_content_provider_result_format() {
502 let raw = r#"Row: 0 result={"status":"success","result":{"a11y_tree":{},"phone_state":{}}}"#;
503 let parsed = parse_content_provider_output(raw).unwrap();
504 assert!(parsed.get("a11y_tree").is_some());
505 assert!(parsed.get("phone_state").is_some());
506 }
507
508 #[test]
509 fn test_parse_content_provider_direct_json() {
510 let raw = r#"{"status":"success","result":"1.2.3"}"#;
513 let parsed = parse_content_provider_output(raw).unwrap();
514 assert_eq!(parsed.as_str().unwrap(), "1.2.3");
516 }
517
518 #[test]
519 fn test_parse_content_provider_nested_json_string() {
520 let raw = r#"Row: 0 result={"status":"success","result":"{\"key\":\"value\"}"}"#;
521 let parsed = parse_content_provider_output(raw).unwrap();
522 assert_eq!(parsed.get("key").unwrap().as_str().unwrap(), "value");
523 }
524
525 #[test]
526 fn test_parse_content_provider_empty() {
527 let parsed = parse_content_provider_output("No result found.");
528 assert!(parsed.is_none());
529 }
530
531 #[test]
532 fn test_unwrap_portal_response_with_result() {
533 let data = serde_json::json!({"status": "success", "result": {"foo": "bar"}});
534 let unwrapped = unwrap_portal_response(data);
535 assert_eq!(unwrapped.get("foo").unwrap().as_str().unwrap(), "bar");
536 }
537
538 #[test]
539 fn test_unwrap_portal_response_with_data() {
540 let data = serde_json::json!({"status": "success", "data": [1, 2, 3]});
541 let unwrapped = unwrap_portal_response(data);
542 assert_eq!(unwrapped.as_array().unwrap().len(), 3);
543 }
544
545 #[test]
546 fn test_unwrap_portal_response_plain() {
547 let data = serde_json::json!({"foo": "bar"});
548 let unwrapped = unwrap_portal_response(data.clone());
549 assert_eq!(unwrapped, data);
550 }
551
552 #[test]
553 fn test_extract_packages_list_direct_array() {
554 let data = serde_json::json!([
555 {"packageName": "com.example", "label": "Example"}
556 ]);
557 let list = extract_packages_list(&data).unwrap();
558 assert_eq!(list.len(), 1);
559 }
560
561 #[test]
562 fn test_extract_packages_list_wrapped() {
563 let data = serde_json::json!({"packages": [
564 {"packageName": "com.test", "label": "Test"}
565 ]});
566 let list = extract_packages_list(&data).unwrap();
567 assert_eq!(list.len(), 1);
568 }
569}