droidrun_core/portal/
manager.rs1use 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#[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
28pub 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 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 let _ = tokio::fs::remove_file(&apk_path).await;
60
61 info!("Portal APK installed");
62
63 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 keyboard::setup_keyboard(&self.device).await?;
71
72 Ok(())
73 }
74
75 pub async fn ensure_ready(&self, sdk_version: &str, debug_mode: bool) -> Result<()> {
77 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 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 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 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 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 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 if let Some(portal_ver) = version_map.mappings.get(sdk_version) {
168 return Ok((portal_ver.clone(), version_map.download_base));
169 }
170
171 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 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 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
261fn 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, }
274}
275
276fn 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
299fn 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")); 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}