zeptoclaw 0.5.5

Ultra-lightweight personal AI assistant
Documentation
//! Android tool types.
//!
//! Data structures for UI elements, screen state, and stuck detection alerts.

use serde::{Deserialize, Serialize};

/// A parsed UI element from uiautomator XML dump.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UIElement {
    /// Visible text or content-description
    pub text: String,
    /// Center coordinates `[x, y]`
    pub center: [i32; 2],
    /// Suggested action: "tap", "type", "scroll", "long_press"
    pub action: String,
    /// Short class name (e.g. "Button", "EditText")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub class: Option<String>,
    /// Resource ID (e.g. "com.app:id/btn_ok")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    /// Hint text for input fields
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hint: Option<String>,
    /// Whether the element is enabled (omitted if true)
    #[serde(skip_serializing_if = "is_true")]
    pub enabled: bool,
    /// Whether a checkbox/toggle is checked
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    pub checked: bool,
    /// Whether the element is focused
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    pub focused: bool,
    /// Whether the element is editable
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    pub editable: bool,
    /// Whether the element is scrollable
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    pub scrollable: bool,
    /// Internal relevance score (not serialized to JSON output)
    #[serde(skip)]
    pub score: i32,
}

fn is_true(v: &bool) -> bool {
    *v
}

impl UIElement {
    /// Build a hash string for stuck detection.
    pub fn hash_key(&self) -> String {
        format!(
            "{}|{}|{},{}|{}|{}",
            self.id.as_deref().unwrap_or(""),
            self.text,
            self.center[0],
            self.center[1],
            self.enabled,
            self.checked,
        )
    }
}

/// Parsed screen state from a uiautomator dump.
#[derive(Debug, Clone, Serialize)]
pub struct ScreenState {
    /// Current foreground package
    pub package: String,
    /// Screen dimensions `[width, height]`
    pub screen_size: [i32; 2],
    /// Filtered/scored UI elements
    pub elements: Vec<UIElement>,
}

/// Alerts generated by the stuck detector.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", content = "message")]
pub enum StuckAlert {
    /// Screen unchanged for N consecutive observations.
    ScreenUnchanged(String),
    /// Same action repeated N times in recent window.
    ActionRepeated(String),
    /// Too many navigation actions (back/home) in recent window.
    NavigationDrift(String),
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_element() -> UIElement {
        UIElement {
            text: "OK".into(),
            center: [540, 1200],
            action: "tap".into(),
            class: Some("Button".into()),
            id: Some("com.app:id/btn_ok".into()),
            hint: None,
            enabled: true,
            checked: false,
            focused: false,
            editable: false,
            scrollable: false,
            score: 15,
        }
    }

    #[test]
    fn test_ui_element_hash_key() {
        let elem = sample_element();
        let hash = elem.hash_key();
        assert!(hash.contains("com.app:id/btn_ok"));
        assert!(hash.contains("OK"));
        assert!(hash.contains("540,1200"));
    }

    #[test]
    fn test_ui_element_serialize_compact() {
        let elem = sample_element();
        let json = serde_json::to_string(&elem).unwrap();
        // enabled=true should be omitted (is_true skip)
        assert!(!json.contains("\"enabled\""));
        // checked=false should be omitted (Not::not skip)
        assert!(!json.contains("\"checked\""));
        // focused=false should be omitted
        assert!(!json.contains("\"focused\""));
        // score should be skipped
        assert!(!json.contains("\"score\""));
        // text and center should be present
        assert!(json.contains("\"text\""));
        assert!(json.contains("\"center\""));
    }

    #[test]
    fn test_ui_element_serialize_disabled() {
        let mut elem = sample_element();
        elem.enabled = false;
        let json = serde_json::to_string(&elem).unwrap();
        assert!(json.contains("\"enabled\":false"));
    }

    #[test]
    fn test_ui_element_serialize_checked() {
        let mut elem = sample_element();
        elem.checked = true;
        let json = serde_json::to_string(&elem).unwrap();
        assert!(json.contains("\"checked\":true"));
    }

    #[test]
    fn test_ui_element_serialize_editable() {
        let mut elem = sample_element();
        elem.editable = true;
        let json = serde_json::to_string(&elem).unwrap();
        assert!(json.contains("\"editable\":true"));
    }

    #[test]
    fn test_screen_state_serialize() {
        let state = ScreenState {
            package: "com.example.app".into(),
            screen_size: [1080, 2400],
            elements: vec![sample_element()],
        };
        let json = serde_json::to_string(&state).unwrap();
        assert!(json.contains("\"package\":\"com.example.app\""));
        assert!(json.contains("\"screen_size\":[1080,2400]"));
    }

    #[test]
    fn test_stuck_alert_serialize() {
        let alert = StuckAlert::ScreenUnchanged("Screen unchanged for 3 steps".into());
        let json = serde_json::to_string(&alert).unwrap();
        assert!(json.contains("\"type\":\"ScreenUnchanged\""));
    }

    #[test]
    fn test_ui_element_no_id() {
        let mut elem = sample_element();
        elem.id = None;
        let hash = elem.hash_key();
        assert!(hash.starts_with('|'));
    }

    #[test]
    fn test_ui_element_hint_omitted_when_none() {
        let elem = sample_element();
        let json = serde_json::to_string(&elem).unwrap();
        assert!(!json.contains("\"hint\""));
    }

    #[test]
    fn test_ui_element_hint_present_when_some() {
        let mut elem = sample_element();
        elem.hint = Some("Enter name".into());
        let json = serde_json::to_string(&elem).unwrap();
        assert!(json.contains("\"hint\":\"Enter name\""));
    }
}