Skip to main content

droidrun_core/portal/
manager.rs

1/// Portal APK lifecycle management — download, install, version checks.
2use std::collections::HashMap;
3use std::path::Path;
4
5use serde::Deserialize;
6use tracing::{debug, info, warn};
7
8use droidrun_adb::AdbDevice;
9
10use super::{a11y, keyboard, PORTAL_PACKAGE, VERSION_MAP_URL};
11use crate::error::{DroidrunError, Result};
12use crate::portal::client::parse_content_provider_output;
13
14const ASSET_NAME: &str = "droidrun-portal";
15
16/// Version mapping from server.
17#[derive(Debug, Deserialize)]
18struct VersionMap {
19    mappings: HashMap<String, String>,
20    #[serde(default = "default_download_base")]
21    download_base: String,
22}
23
24fn default_download_base() -> String {
25    "https://github.com/droidrun/droidrun-portal/releases/download".into()
26}
27
28/// Manages Portal APK lifecycle on a device.
29pub struct PortalManager {
30    device: AdbDevice,
31    http: reqwest::Client,
32}
33
34impl PortalManager {
35    pub fn new(device: AdbDevice) -> Self {
36        Self {
37            device,
38            http: reqwest::Client::builder()
39                .timeout(std::time::Duration::from_secs(30))
40                .build()
41                .unwrap_or_default(),
42        }
43    }
44
45    /// Full setup: download → install → enable accessibility → setup keyboard.
46    pub async fn setup(&self, sdk_version: &str, debug_mode: bool) -> Result<()> {
47        let (portal_version, download_base) =
48            self.get_compatible_version(sdk_version, debug_mode).await?;
49
50        let apk_path = self.download_apk(&portal_version, &download_base).await?;
51        info!("installing Portal APK v{portal_version}...");
52
53        self.device
54            .install(Path::new(&apk_path), &["-g"])
55            .await
56            .map_err(DroidrunError::Adb)?;
57
58        // Cleanup temp file
59        let _ = tokio::fs::remove_file(&apk_path).await;
60
61        info!("Portal APK installed");
62
63        // Enable accessibility service
64        a11y::enable(&self.device).await?;
65        self.wait_for_service(std::time::Duration::from_secs(10))
66            .await?;
67        info!("accessibility service enabled");
68
69        // Setup keyboard
70        keyboard::setup_keyboard(&self.device).await?;
71
72        Ok(())
73    }
74
75    /// Check if Portal is ready, auto-fix if not.
76    pub async fn ensure_ready(&self, sdk_version: &str, debug_mode: bool) -> Result<()> {
77        // Parallel health checks
78        let (packages_result, version_result, a11y_result) = tokio::join!(
79            self.device.list_packages(&[]),
80            self.device
81                .shell("content query --uri content://com.droidrun.portal/version"),
82            self.device
83                .shell("settings get secure enabled_accessibility_services"),
84        );
85
86        // If all checks failed, device is likely unreachable
87        if packages_result.is_err() && version_result.is_err() && a11y_result.is_err() {
88            debug!("portal health check skipped (device unreachable)");
89            return Ok(());
90        }
91
92        let is_installed = packages_result
93            .as_ref()
94            .map(|pkgs| pkgs.iter().any(|p| p == PORTAL_PACKAGE))
95            .unwrap_or(false);
96
97        let installed_version = version_result
98            .as_ref()
99            .ok()
100            .and_then(|raw| parse_portal_version(raw));
101
102        let a11y_enabled = a11y_result
103            .as_ref()
104            .map(|s| s.contains(super::A11Y_SERVICE))
105            .unwrap_or(false);
106
107        // Check version compatibility (only upgrade, never downgrade)
108        let mut needs_upgrade = false;
109        if is_installed {
110            if let Some(ref installed_ver) = installed_version {
111                if let Ok((expected, _)) =
112                    self.get_compatible_version(sdk_version, debug_mode).await
113                {
114                    let expected_clean = expected.trim_start_matches('v');
115                    if installed_ver != expected_clean {
116                        // Only upgrade if expected version is newer than installed
117                        if is_version_newer(expected_clean, installed_ver) {
118                            info!(
119                                "portal outdated: installed={installed_ver}, expected={expected_clean}"
120                            );
121                            needs_upgrade = true;
122                        } else {
123                            debug!(
124                                "portal installed={installed_ver} >= expected={expected_clean}, skipping downgrade"
125                            );
126                        }
127                    }
128                }
129            }
130        }
131
132        // Fix if needed
133        if !is_installed || needs_upgrade {
134            let reason = if !is_installed {
135                "not installed"
136            } else {
137                "outdated"
138            };
139            info!("portal {reason}, running auto-setup...");
140            self.setup(sdk_version, debug_mode).await?;
141            return Ok(());
142        }
143
144        if !a11y_enabled {
145            info!("portal accessibility service not enabled, enabling...");
146            a11y::enable(&self.device).await?;
147            if !a11y::check(&self.device).await? {
148                return Err(DroidrunError::PortalAccessibilityDisabled);
149            }
150            self.wait_for_service(std::time::Duration::from_secs(10))
151                .await?;
152            info!("accessibility service enabled");
153        }
154
155        Ok(())
156    }
157
158    /// Get compatible Portal version for a given SDK version.
159    async fn get_compatible_version(
160        &self,
161        sdk_version: &str,
162        debug_mode: bool,
163    ) -> Result<(String, String)> {
164        let version_map = self.fetch_version_map(debug_mode).await?;
165
166        // Exact match first
167        if let Some(portal_ver) = version_map.mappings.get(sdk_version) {
168            return Ok((portal_ver.clone(), version_map.download_base));
169        }
170
171        // Range match (e.g., "0.4.0-0.4.14": "1.0.0")
172        for (key, portal_ver) in &version_map.mappings {
173            if version_in_range(sdk_version, key) {
174                return Ok((portal_ver.clone(), version_map.download_base.clone()));
175            }
176        }
177
178        // Fallback: use latest from mappings
179        if let Some((_, portal_ver)) = version_map.mappings.iter().last() {
180            warn!("no exact match for SDK {sdk_version}, using latest portal: {portal_ver}");
181            return Ok((portal_ver.clone(), version_map.download_base));
182        }
183
184        Err(DroidrunError::PortalSetupFailed(
185            "cannot determine compatible portal version".into(),
186        ))
187    }
188
189    async fn fetch_version_map(&self, _debug: bool) -> Result<VersionMap> {
190        let resp = self
191            .http
192            .get(VERSION_MAP_URL)
193            .send()
194            .await
195            .map_err(DroidrunError::Http)?;
196
197        resp.json::<VersionMap>()
198            .await
199            .map_err(|e| DroidrunError::PortalSetupFailed(format!("failed to parse version map: {e}")))
200    }
201
202    async fn download_apk(&self, version: &str, download_base: &str) -> Result<String> {
203        let url = format!("{download_base}/{version}/{ASSET_NAME}-{version}.apk");
204        info!("downloading Portal APK v{version}");
205        debug!("URL: {url}");
206
207        let resp = self
208            .http
209            .get(&url)
210            .send()
211            .await
212            .map_err(DroidrunError::Http)?;
213
214        if !resp.status().is_success() {
215            return Err(DroidrunError::PortalSetupFailed(format!(
216                "APK download failed: HTTP {}",
217                resp.status()
218            )));
219        }
220
221        let bytes = resp.bytes().await.map_err(DroidrunError::Http)?;
222
223        let tmp = tempfile::Builder::new()
224            .suffix(".apk")
225            .tempfile()
226            .map_err(DroidrunError::Io)?;
227        let path = tmp.path().to_string_lossy().to_string();
228        tokio::fs::write(&path, &bytes)
229            .await
230            .map_err(DroidrunError::Io)?;
231        // Persist the file so it survives until install() reads it.
232        // into_temp_path() still deletes on drop, so we must call keep().
233        let _ = tmp.into_temp_path().keep();
234
235        debug!("downloaded {} bytes to {path}", bytes.len());
236        Ok(path)
237    }
238
239    async fn wait_for_service(&self, timeout: std::time::Duration) -> Result<()> {
240        let start = tokio::time::Instant::now();
241        let interval = std::time::Duration::from_secs(1);
242
243        while start.elapsed() < timeout {
244            if let Ok(output) = self
245                .device
246                .shell("content query --uri content://com.droidrun.portal/state")
247                .await
248            {
249                if output.contains(r#""status":"success""#) {
250                    return Ok(());
251                }
252            }
253            tokio::time::sleep(interval).await;
254        }
255
256        warn!("portal service did not become responsive within timeout");
257        Ok(())
258    }
259}
260
261/// Check if `a` is strictly newer (greater) than `b` using semver comparison.
262fn is_version_newer(a: &str, b: &str) -> bool {
263    let parse = |s: &str| -> Option<Vec<u32>> {
264        s.trim_start_matches('v')
265            .split('.')
266            .map(|p| p.parse().ok())
267            .collect()
268    };
269
270    match (parse(a), parse(b)) {
271        (Some(va), Some(vb)) => va > vb,
272        _ => false, // If parsing fails, don't upgrade
273    }
274}
275
276/// Check if version falls within a range like "0.4.0-0.4.14".
277fn version_in_range(version: &str, range: &str) -> bool {
278    let Some((start, end)) = range.split_once('-') else {
279        return false;
280    };
281
282    let parse = |s: &str| -> Option<Vec<u32>> {
283        s.split('.').map(|p| p.parse().ok()).collect()
284    };
285
286    let Some(v) = parse(version) else {
287        return false;
288    };
289    let Some(s) = parse(start) else {
290        return false;
291    };
292    let Some(e) = parse(end) else {
293        return false;
294    };
295
296    v >= s && v <= e
297}
298
299/// Extract portal version string from content provider output.
300fn parse_portal_version(raw: &str) -> Option<String> {
301    let data = parse_content_provider_output(raw)?;
302    data.as_str().map(|s| s.to_string())
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn test_version_in_range_exact() {
311        assert!(version_in_range("0.4.5", "0.4.0-0.4.14"));
312    }
313
314    #[test]
315    fn test_version_in_range_start() {
316        assert!(version_in_range("0.4.0", "0.4.0-0.4.14"));
317    }
318
319    #[test]
320    fn test_version_in_range_end() {
321        assert!(version_in_range("0.4.14", "0.4.0-0.4.14"));
322    }
323
324    #[test]
325    fn test_version_out_of_range() {
326        assert!(!version_in_range("0.5.0", "0.4.0-0.4.14"));
327        assert!(!version_in_range("0.3.9", "0.4.0-0.4.14"));
328    }
329
330    #[test]
331    fn test_version_in_range_no_dash() {
332        assert!(!version_in_range("0.4.0", "0.4.0"));
333    }
334
335    #[test]
336    fn test_is_version_newer() {
337        assert!(is_version_newer("0.6.0", "0.4.6"));
338        assert!(is_version_newer("1.0.0", "0.9.9"));
339        assert!(is_version_newer("0.4.7", "0.4.6"));
340    }
341
342    #[test]
343    fn test_is_version_not_newer() {
344        assert!(!is_version_newer("0.4.6", "0.6.0"));
345        assert!(!is_version_newer("0.4.6", "0.4.6")); // equal = not newer
346        assert!(!is_version_newer("0.3.0", "0.4.6"));
347    }
348
349    #[test]
350    fn test_is_version_newer_with_prefix() {
351        assert!(is_version_newer("v0.6.0", "0.4.6"));
352        assert!(is_version_newer("v1.0.0", "v0.9.9"));
353    }
354}