Skip to main content

droidrun_core/portal/
client.rs

1/// PortalClient — unified communication with DroidRun Portal app.
2///
3/// Supports two transport modes:
4/// - **TCP**: HTTP requests to Portal's embedded server (fast, needs port forward)
5/// - **Content Provider**: ADB shell `content query/insert` commands (fallback)
6use 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
15/// Portal client with automatic TCP/ContentProvider fallback.
16pub 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    /// Establish connection, trying TCP first if preferred.
45    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    // ── Public API ──────────────────────────────────────────────
57
58    /// Get device state (accessibility tree + phone state).
59    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    /// Input text via keyboard.
70    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    /// Take a screenshot.
81    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    /// Get installed apps with labels.
92    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        // Handle various response formats
103        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    /// Get Portal version.
139    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        // Content provider fallback
158        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            // parse_content_provider_output already unwraps portal envelope,
165            // so data might be a direct string value or still an object
166            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    /// Ping Portal and verify state availability.
177    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        // Content provider fallback
195        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    // ── TCP implementations ─────────────────────────────────────
213
214    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        // Check for existing forward
223        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        // Test connection
241        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        // Try enabling the HTTP server via content provider
248        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 already unwraps portal envelopes
325        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
409// ── Helper functions ────────────────────────────────────────────
410
411/// Parse raw ADB content provider output to JSON.
412///
413/// The output format is: `Row: 0 result={json}`
414pub fn parse_content_provider_output(raw: &str) -> Option<Value> {
415    for line in raw.lines() {
416        let line = line.trim();
417
418        // "Row: N result={json}" format
419        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        // Direct JSON
427        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    // Last resort: entire output
435    serde_json::from_str::<Value>(raw.trim())
436        .ok()
437        .map(unwrap_portal_response)
438}
439
440/// Unwrap Portal's `{status, result/data}` envelope.
441fn unwrap_portal_response(data: Value) -> Value {
442    if let Some(obj) = data.as_object() {
443        // Try "result" first (new format), then "data" (legacy)
444        for key in &["result", "data"] {
445            if let Some(inner) = obj.get(*key) {
446                // If the inner value is a JSON string, parse it
447                if let Some(s) = inner.as_str() {
448                    if let Ok(parsed) = serde_json::from_str::<Value>(s) {
449                        return parsed;
450                    }
451                    // Not JSON, return as-is
452                    return inner.clone();
453                }
454                return inner.clone();
455            }
456        }
457    }
458    data
459}
460
461/// Extract inner value from Portal response envelope.
462fn 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
468/// Extract packages list from various response formats.
469fn extract_packages_list(data: &Value) -> Option<&Vec<Value>> {
470    // Direct array
471    if let Some(arr) = data.as_array() {
472        return Some(arr);
473    }
474    // Wrapped in {"packages": [...]}
475    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        // Wrapped in result/data
480        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        // Direct JSON without "Row: N result=" prefix goes through
511        // the fallback path which also unwraps portal response envelope
512        let raw = r#"{"status":"success","result":"1.2.3"}"#;
513        let parsed = parse_content_provider_output(raw).unwrap();
514        // The unwrap_portal_response extracts "result" -> "1.2.3"
515        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}