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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ Minimap verifies that package against ADB's foreground activity and refuses to
record another app. Use `--allow-package-mismatch` only for an intentional
cross-app capture; `doctor` reports missing or ambiguous package configuration.

If Android CLI cannot initialize its analytics spool in a restricted filesystem,
Minimap returns `android_cli_analytics_spool_unwritable` with the blocked path
and a short permission remedy. Pass `--verbose` to include the raw subprocess
exception when debugging; normal agent output omits the Android CLI stack trace.

## Commands

The v1 command surface is intentionally small:
Expand Down
118 changes: 112 additions & 6 deletions crates/minimap-android/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use anyhow::{bail, Context, Result};
use minimap_schemas::Viewport;
use serde::Serialize;
use serde_json::{json, Value};
use std::error::Error as StdError;
use std::fmt;
use std::fs;
use std::path::Path;
use std::process::Command;
Expand All @@ -14,6 +16,79 @@ pub struct CommandResult {
pub stderr: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandFailure {
pub result: CommandResult,
}

impl fmt::Display for CommandFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"command failed: {} (status {}, stderr: {})",
self.result.args.join(" "),
self.result.status,
self.result.stderr
)
}
}

impl StdError for CommandFailure {}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AndroidAnalyticsSpoolFailure {
pub blocked_path: Option<String>,
}

pub fn android_analytics_spool_failure(
failure: &CommandFailure,
) -> Option<AndroidAnalyticsSpoolFailure> {
let program = failure.result.args.first()?;
if Path::new(program)
.file_name()
.and_then(|name| name.to_str())
!= Some("android")
{
return None;
}
let stderr = &failure.result.stderr;
if !stderr.contains("Unable to initialize first usage tracking spool file")
|| !(stderr.contains("Operation not permitted")
|| stderr.contains("Permission denied")
|| stderr.contains("Access is denied"))
{
return None;
}

Some(AndroidAnalyticsSpoolFailure {
blocked_path: analytics_blocked_path(stderr),
})
}

fn analytics_blocked_path(stderr: &str) -> Option<String> {
const EXCEPTION_MARKERS: [&str; 2] = [
"java.nio.file.FileSystemException:",
"java.nio.file.AccessDeniedException:",
];
const REASON_MARKERS: [&str; 3] = [
": Operation not permitted",
": Permission denied",
": Access is denied",
];

stderr.lines().find_map(|line| {
let path_with_reason = EXCEPTION_MARKERS
.iter()
.find_map(|marker| line.split_once(marker).map(|(_, value)| value.trim()))?;
let path = REASON_MARKERS.iter().find_map(|marker| {
path_with_reason
.split_once(marker)
.map(|(path, _)| path.trim())
})?;
(!path.is_empty()).then(|| path.to_string())
})
}

#[derive(Debug, Clone, PartialEq)]
pub struct LayoutOutput {
pub layout: Value,
Expand Down Expand Up @@ -412,12 +487,7 @@ fn run_checked<R: CommandRunner>(
) -> Result<CommandResult> {
let result = runner.run(&args, env)?;
if result.status != 0 {
bail!(
"command failed: {} (status {}, stderr: {})",
result.args.join(" "),
result.status,
result.stderr
);
return Err(CommandFailure { result }.into());
}
Ok(result)
}
Expand Down Expand Up @@ -1207,6 +1277,42 @@ mod tests {
);
}

#[test]
fn classifies_android_analytics_spool_permission_failure() {
let failure = CommandFailure {
result: CommandResult {
args: vec!["android".to_string(), "layout".to_string()],
status: 1,
stdout: String::new(),
stderr: "Exception in thread \"main\" java.lang.RuntimeException: Unable to initialize first usage tracking spool file\nCaused by: java.nio.file.FileSystemException: /Users/test/.android/cli/analytics/metrics/spool/event.trk: Operation not permitted\n".to_string(),
},
};

assert_eq!(
android_analytics_spool_failure(&failure),
Some(AndroidAnalyticsSpoolFailure {
blocked_path: Some(
"/Users/test/.android/cli/analytics/metrics/spool/event.trk".to_string()
)
})
);
}

#[test]
fn does_not_misclassify_adb_permission_errors_as_android_analytics() {
let failure = CommandFailure {
result: CommandResult {
args: vec!["adb".to_string(), "devices".to_string()],
status: 1,
stdout: String::new(),
stderr: "Unable to initialize first usage tracking spool file: Permission denied"
.to_string(),
},
};

assert_eq!(android_analytics_spool_failure(&failure), None);
}

#[test]
fn adb_foreground_package_targets_configured_serial() {
let mut runner = FakeRunner::new(vec![ok(
Expand Down
53 changes: 44 additions & 9 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, parse_layout_output, resolve_selector_point, Adb, AndroidCli, CommandRunner,
LayoutOutput, SubprocessRunner, TapPoint,
android_analytics_spool_failure, parse_input_tap, parse_layout_output, resolve_selector_point,
Adb, AndroidCli, CommandFailure, CommandRunner, LayoutOutput, SubprocessRunner, TapPoint,
};
use minimap_core::{
detect_overlay, fingerprint_layout, fingerprint_usable, match_place, normalize_label,
Expand Down Expand Up @@ -39,6 +39,9 @@ struct Cli {
json: bool,
#[arg(long)]
quiet: bool,
/// Include raw subprocess diagnostics in structured error output.
#[arg(long, global = true)]
verbose: bool,
#[arg(long = "no-color")]
no_color: bool,
/// Android device serial to target when more than one device is attached.
Expand Down Expand Up @@ -152,21 +155,53 @@ struct TapRequest<'a> {

fn main() {
let cli = Cli::parse();
let verbose = cli.verbose;
let code = match run(cli) {
Ok(code) => code,
Err(error) => {
let result = MinimapResult::new(
"config_error",
error.to_string(),
json!({"error": {"message": error.to_string()}}),
);
print_json(&serde_json::to_value(result).expect("error json"));
7
if let Some(result) = android_analytics_error_result(&error, verbose) {
print_json(&result);
6
} else {
let result = MinimapResult::new(
"config_error",
error.to_string(),
json!({"error": {"message": error.to_string()}}),
);
print_json(&serde_json::to_value(result).expect("error json"));
7
}
}
};
std::process::exit(code);
}

fn android_analytics_error_result(error: &anyhow::Error, verbose: bool) -> Option<Value> {
let failure = error.downcast_ref::<CommandFailure>()?;
let analytics = android_analytics_spool_failure(failure)?;
let mut result = json!({
"schema_version": RESULT_SCHEMA_VERSION,
"status": "environment_error",
"summary": "Android CLI analytics spool is not writable",
"error": {
"code": "android_cli_analytics_spool_unwritable",
"blocked_path": analytics.blocked_path,
"recovery": "Grant write access to the Android CLI analytics spool, or rerun Minimap in a filesystem profile where that path is writable."
},
"changed_graph": false,
"changed_files": []
});
if verbose {
result["debug"] = json!({
"command": failure.result.args,
"status": failure.result.status,
"stdout": failure.result.stdout,
"stderr": failure.result.stderr
});
}
Some(result)
}

fn run(cli: Cli) -> Result<i32> {
let root = PathBuf::from(".");
let serial = cli.serial;
Expand Down
58 changes: 58 additions & 0 deletions crates/minimap-cli/tests/cli_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,51 @@ fn layout_refuses_wrong_foreground_package_unless_overridden() {
assert!(payload["layout"].is_array());
}

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

let failure = minimap(temp.path())
.env("PATH", prepend_path(&bin))
.args(["layout"])
.assert()
.code(6);
let stdout = String::from_utf8(failure.get_output().stdout.clone()).unwrap();
let payload: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(payload["status"], "environment_error");
assert_eq!(
payload["error"]["code"],
"android_cli_analytics_spool_unwritable"
);
assert_eq!(
payload["error"]["blocked_path"],
"/restricted/.android/cli/analytics/metrics/spool/blocked.trk"
);
assert!(payload.get("debug").is_none());
assert!(!stdout.contains("JournalingUsageTracker"));
assert!(stdout.len() < 1_200, "default diagnostic must stay concise");

let verbose = minimap(temp.path())
.env("PATH", prepend_path(&bin))
.args(["--verbose", "layout"])
.assert()
.code(6);
let stdout = String::from_utf8(verbose.get_output().stdout.clone()).unwrap();
let payload: Value = serde_json::from_str(&stdout).unwrap();
assert!(payload["debug"]["stderr"]
.as_str()
.unwrap()
.contains("JournalingUsageTracker"));
assert_eq!(payload["debug"]["status"], 1);
}

#[test]
fn doctor_reports_healthy_environment() {
let temp = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -1872,6 +1917,19 @@ fn write_android_layout_script(bin: &Path, sequence: &[&str]) {
write_android_layout_script_with_guard(bin, sequence, "");
}

fn write_android_analytics_failure_script(bin: &Path) {
write_executable(
&bin.join("android"),
r#"#!/bin/sh
printf '%s\n' 'Unable to initialize metrics, ensure %s is writable, details: %s' >&2
printf '%s\n' 'Exception in thread "main" java.lang.RuntimeException: Unable to initialize first usage tracking spool file' >&2
printf '%s\n' ' at com.android.tools.analytics.JournalingUsageTracker.<init>(JournalingUsageTracker.kt:87)' >&2
printf '%s\n' 'Caused by: java.nio.file.FileSystemException: /restricted/.android/cli/analytics/metrics/spool/blocked.trk: Operation not permitted' >&2
exit 1
"#,
);
}

/// Fake `android` that exits 1 unless `layout` invocations carry
/// `--device=<serial>`, proving the configured serial reaches the layout CLI.
fn write_android_layout_script_expect_serial(bin: &Path, sequence: &[&str], serial: &str) {
Expand Down
5 changes: 5 additions & 0 deletions docs/MINIMAP_V1_LEAN_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ resumed activity before capture, preventing another foreground app from
polluting the graph. `--allow-package-mismatch` is the explicit one-command
override.

Known Android CLI analytics-spool permission failures are classified at the
subprocess boundary. Normal structured output reports the blocked path and one
remediation without embedding Java or Rust stack traces. The global `--verbose`
flag adds the untouched command status, stdout, and stderr for diagnostics.

## Graph Schema

Places use product language instead of screen language.
Expand Down
Loading