Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 105 additions & 4 deletions crates/minimap-android/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,74 @@ pub struct CommandResult {
pub stderr: String,
}

#[derive(Debug, Clone, PartialEq)]
pub struct LayoutOutput {
pub layout: Value,
pub notices: Vec<String>,
}

/// Normalize the Android CLI's layout stdout into one stable contract.
///
/// Current Android CLI releases normally emit a flat JSON array. Some releases
/// have emitted a JSON-encoded string instead, and update notices may be written
/// before or after the JSON value. Minimap must not leak either representation
/// detail to callers.
pub fn parse_layout_output(stdout: &str) -> Result<LayoutOutput> {
parse_layout_output_inner(stdout, 0)
.context("Android CLI layout output did not contain a JSON object array")
}

fn parse_layout_output_inner(stdout: &str, depth: usize) -> Result<LayoutOutput> {
if depth > 3 {
bail!("Android CLI layout output was encoded too many times");
}

for (start, first) in stdout.char_indices() {
if !matches!(first, '[' | '{' | '"') {
continue;
}

let candidate = &stdout[start..];
let mut values = serde_json::Deserializer::from_str(candidate).into_iter::<Value>();
let Some(Ok(value)) = values.next() else {
continue;
};
let end = start + values.byte_offset();

let mut output = match value {
Value::Array(nodes) if nodes.iter().all(Value::is_object) => LayoutOutput {
layout: Value::Array(nodes),
notices: Vec::new(),
},
// Legacy/fake Android clients have returned one hierarchy root.
// Wrapping it preserves recursive traversal while stabilizing the
// public result as an array of element objects.
Value::Object(node) => LayoutOutput {
layout: Value::Array(vec![Value::Object(node)]),
notices: Vec::new(),
},
Value::String(encoded) => match parse_layout_output_inner(&encoded, depth + 1) {
Ok(output) => output,
Err(_) => continue,
},
_ => continue,
};

output.notices.splice(0..0, notice_lines(&stdout[..start]));
output.notices.extend(notice_lines(&stdout[end..]));
return Ok(output);
}

bail!("no JSON object array found in Android CLI layout output")
}

fn notice_lines(text: &str) -> impl Iterator<Item = String> + '_ {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
}

pub trait CommandRunner {
fn run(&mut self, args: &[String], env: &[(String, String)]) -> Result<CommandResult>;
}
Expand Down Expand Up @@ -274,12 +342,12 @@ fn run_checked<R: CommandRunner>(

pub fn layout_result<R: CommandRunner>(android: &mut AndroidCli<R>, diff: bool) -> Result<Value> {
let command = android.layout(diff)?;
let layout =
serde_json::from_str::<Value>(&command.stdout).unwrap_or(Value::String(command.stdout));
let output = parse_layout_output(&command.stdout)?;
let mut result = json!({
"status": "ok",
"kind": if diff { "android_layout_diff" } else { "android_layout" },
"layout": layout,
"layout": output.layout,
"android_cli_notices": output.notices,
"metrics": {
"layout_calls_total": 1,
"layout_json_returned_to_agent": true,
Expand All @@ -299,7 +367,7 @@ pub fn tap_selector_result<AR: CommandRunner, DR: CommandRunner>(
reason: Option<&str>,
) -> Result<Value> {
let command = android.layout(false)?;
let layout: Value = serde_json::from_str(&command.stdout)?;
let layout = parse_layout_output(&command.stdout)?.layout;
let point = resolve_selector_point(&layout, selector)?;
adb.tap(point)?;
let viewport = capture_viewport(adb);
Expand Down Expand Up @@ -579,6 +647,39 @@ mod tests {
let result = layout_result(&mut android, true).unwrap();
assert_eq!(result["kind"], "android_layout_diff");
assert_eq!(result["diff_scope"], "android_in_session");
assert!(result["layout"].is_array());
assert_eq!(result["android_cli_notices"], json!([]));
}

#[test]
fn parse_layout_output_accepts_flat_object_array() {
let output = parse_layout_output(r#"[{"text":"Home"},{"text":"Settings"}]"#).unwrap();
assert_eq!(output.layout[0]["text"], "Home");
assert_eq!(output.layout[1]["text"], "Settings");
assert!(output.notices.is_empty());
}

#[test]
fn parse_layout_output_decodes_string_and_separates_notice() {
let stdout = "\"[{\\\"text\\\":\\\"Home\\\"}]\"\nA newer Android CLI is available.\n";
let output = parse_layout_output(stdout).unwrap();
assert_eq!(output.layout, json!([{"text": "Home"}]));
assert_eq!(
output.notices,
vec!["A newer Android CLI is available.".to_string()]
);
}

#[test]
fn parse_layout_output_wraps_legacy_root_object() {
let output = parse_layout_output(r#"{"class":"Column","children":[]}"#).unwrap();
assert_eq!(output.layout, json!([{"class": "Column", "children": []}]));
}

#[test]
fn parse_layout_output_rejects_non_element_arrays() {
let error = parse_layout_output(r#"["not-an-element"]"#).unwrap_err();
assert!(error.to_string().contains("JSON object array"));
}

#[test]
Expand Down
24 changes: 19 additions & 5 deletions crates/minimap-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use minimap_android::{
parse_input_tap, resolve_selector_point, Adb, AndroidCli, CommandRunner, SubprocessRunner,
TapPoint,
parse_input_tap, parse_layout_output, resolve_selector_point, Adb, AndroidCli, CommandRunner,
LayoutOutput, SubprocessRunner, TapPoint,
};
use minimap_core::{
detect_overlay, fingerprint_layout, fingerprint_usable, match_place, normalize_label,
Expand Down Expand Up @@ -276,8 +276,15 @@ fn run(cli: Cli) -> Result<i32> {
}

fn observe_layout<R: CommandRunner>(android: &mut AndroidCli<R>, diff: bool) -> Result<Value> {
Ok(observe_layout_output(android, diff)?.layout)
}

fn observe_layout_output<R: CommandRunner>(
android: &mut AndroidCli<R>,
diff: bool,
) -> Result<LayoutOutput> {
let command = android.layout(diff)?;
Ok(serde_json::from_str::<Value>(&command.stdout).unwrap_or(Value::String(command.stdout)))
parse_layout_output(&command.stdout)
}

fn observe_after_action<R: CommandRunner>(
Expand Down Expand Up @@ -597,11 +604,16 @@ fn layout_result<AR: CommandRunner, DR: CommandRunner>(
edges: Default::default(),
});
if let Some(place) = graph_place_for_session(&graph, &session) {
// Session files written by older Minimap versions may contain
// the raw Android stdout as a JSON string. Normalize on read so
// a cache hit cannot reintroduce the unstable public contract.
let cached_output = parse_layout_output(&serde_json::to_string(&session.layout)?)?;
return Ok(json!({
"schema_version": RESULT_SCHEMA_VERSION,
"status": "ok",
"kind": "android_layout",
"layout": session.layout,
"layout": cached_output.layout,
"android_cli_notices": cached_output.notices,
"minimap": layout_minimap_json(
&graph,
&session.baseline,
Expand All @@ -626,7 +638,8 @@ fn layout_result<AR: CommandRunner, DR: CommandRunner>(
}
}

let layout = observe_layout(android, diff)?;
let output = observe_layout_output(android, diff)?;
let layout = output.layout;
let (minimap, cache_hit) = if diff {
(json!({"orientation": "unavailable_for_diff"}), false)
} else {
Expand All @@ -653,6 +666,7 @@ fn layout_result<AR: CommandRunner, DR: CommandRunner>(
"status": "ok",
"kind": if diff { "android_layout_diff" } else { "android_layout" },
"layout": redact_layout(&layout),
"android_cli_notices": output.notices,
"minimap": minimap,
"cache": {"hit": cache_hit},
"metrics": {
Expand Down
36 changes: 36 additions & 0 deletions crates/minimap-cli/tests/cli_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,39 @@ fn layout_returns_redacted_layout_and_minimap_metadata() {
assert!(!serialized.contains("alice@example.com"));
}

#[test]
fn layout_normalizes_encoded_array_and_reports_android_notices() {
let temp = tempfile::tempdir().unwrap();
minimap(temp.path())
.args(["init", "--agents", "codex"])
.assert()
.success();
let bin = fake_bin(temp.path());
write_android_layout_script(&bin, &["noisy_home"]);
write_adb_script(&bin);

let output = minimap(temp.path())
.env("PATH", prepend_path(&bin))
.args(["layout"])
.assert()
.success()
.get_output()
.stdout
.clone();
let payload: Value = serde_json::from_slice(&output).unwrap();

let layout = payload["layout"]
.as_array()
.expect("layout must be a JSON array, never an encoded string");
assert_eq!(layout.len(), 2);
assert_eq!(layout[0]["text"], "HOME");
assert_eq!(layout[1]["content-desc"], "Settings");
assert_eq!(
payload["android_cli_notices"],
json!(["A newer version of Android CLI is available."])
);
}

#[test]
fn doctor_reports_healthy_environment() {
let temp = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -1619,6 +1652,9 @@ if [ "$1" = "layout" ]; then
home)
printf '{{"class":"Column","children":[{{"class":"Text","text":"HOME"}},{{"class":"Button","text":"SEARCH","bounds":{{"left":100,"top":200,"right":300,"bottom":400}}}}]}}'
;;
noisy_home)
printf '%s\n%s\n' '"[{{\"text\":\"HOME\"}},{{\"content-desc\":\"Settings\",\"center\":\"[1006,147]\"}}]"' 'A newer version of Android CLI is available.'
;;
search)
printf '{{"class":"Column","children":[{{"class":"Button","text":"HOME","bounds":{{"left":10,"top":20,"right":100,"bottom":120}}}},{{"class":"Text","text":"Categories"}},{{"class":"Text","text":"Lifestyles"}}]}}'
;;
Expand Down
Loading