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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ Unlabeled `whereami` returns compact orientation. If either immediately follows
fresh verified observation, Minimap can serve the cached session state instead
of paying for another Android layout capture.

`init` infers the debug application package from a standard Android application
Gradle module when possible. Before any device-backed navigation or capture,
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.

## Commands

The v1 command surface is intentionally small:
Expand Down
77 changes: 77 additions & 0 deletions crates/minimap-android/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,19 @@ impl<R: CommandRunner> Adb<R> {
let result = run_checked(&mut self.runner, args, &[])?;
parse_wm_size(&result.stdout)
}

pub fn foreground_package(&mut self) -> Result<String> {
let mut args = self.base_args();
args.extend([
"shell".to_string(),
"dumpsys".to_string(),
"activity".to_string(),
"activities".to_string(),
]);
let result = run_checked(&mut self.runner, args, &[])?;
parse_foreground_package(&result.stdout)
.context("could not determine Android foreground package from activity state")
}
}

pub fn parse_adb_devices(stdout: &str) -> Vec<AdbDevice> {
Expand All @@ -335,6 +348,37 @@ pub fn parse_adb_devices(stdout: &str) -> Vec<AdbDevice> {
.collect()
}

pub fn parse_foreground_package(stdout: &str) -> Option<String> {
for marker in [
"topResumedActivity=",
"mResumedActivity:",
"ResumedActivity:",
] {
for line in stdout.lines().filter(|line| line.contains(marker)) {
if let Some(package) = line.split_whitespace().find_map(component_package) {
return Some(package);
}
}
}
None
}

fn component_package(token: &str) -> Option<String> {
let token = token.trim_matches(|character: char| {
matches!(character, '{' | '}' | '[' | ']' | '(' | ')' | ',')
});
let (package, _) = token.split_once('/')?;
let package = package.trim_start_matches(|character: char| {
!character.is_ascii_alphanumeric() && character != '_'
});
(!package.is_empty()
&& package.contains('.')
&& package
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '_')))
.then(|| package.to_string())
}

/// Parse `adb shell wm size` output. When `Override size:` is present it wins
/// because that is what the device actually renders at; otherwise fall back to
/// `Physical size:`.
Expand Down Expand Up @@ -1154,6 +1198,39 @@ mod tests {
);
}

#[test]
fn parse_foreground_package_uses_top_resumed_activity() {
let output = "topResumedActivity=ActivityRecord{125305998 u0 llc.wandersail.getgoing.debug/llc.wandersail.getgoing.MainActivity t8142}\n";
assert_eq!(
parse_foreground_package(output).as_deref(),
Some("llc.wandersail.getgoing.debug")
);
}

#[test]
fn adb_foreground_package_targets_configured_serial() {
let mut runner = FakeRunner::new(vec![ok(
&["adb"],
"topResumedActivity=ActivityRecord{1 u0 com.example.app/.MainActivity t1}\n",
)]);
{
let mut adb = Adb::new(&mut runner, Some("emulator-5554".to_string()));
assert_eq!(adb.foreground_package().unwrap(), "com.example.app");
}
assert_eq!(
runner.calls[0],
vec![
"adb",
"-s",
"emulator-5554",
"shell",
"dumpsys",
"activity",
"activities"
]
);
}

#[test]
fn adb_devices_does_not_apply_configured_serial() {
let mut runner = FakeRunner::new(vec![ok(
Expand Down
124 changes: 117 additions & 7 deletions crates/minimap-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use minimap_core::{
};
use minimap_graph::{exit_code_for_status, resolve_path};
use minimap_repo::{
commit_edge, commit_place, edge_path, load_config, load_graph, remove_place_file, run_init,
validate_graph, Graph, InitOptions,
commit_edge, commit_place, edge_path, load_config, load_graph, remove_place_file,
resolve_app_package, run_init, validate_graph, AppPackageResolution, Graph, InitOptions,
};
use minimap_schemas::{
canonical_json, ActionStep, Edge, EdgeEndpoint, MinimapResult, Place, PlaceBaseline, Point,
Expand Down Expand Up @@ -44,6 +44,9 @@ struct Cli {
/// Android device serial to target when more than one device is attached.
#[arg(long, global = true, env = "ANDROID_SERIAL")]
serial: Option<String>,
/// Allow capture even when the foreground package differs from the active app profile.
#[arg(long, global = true)]
allow_package_mismatch: bool,
#[command(subcommand)]
command: Commands,
}
Expand Down Expand Up @@ -167,6 +170,7 @@ fn main() {
fn run(cli: Cli) -> Result<i32> {
let root = PathBuf::from(".");
let serial = cli.serial;
let allow_package_mismatch = cli.allow_package_mismatch;
match cli.command {
Commands::Init {
dry_run,
Expand Down Expand Up @@ -200,6 +204,9 @@ fn run(cli: Cli) -> Result<i32> {
} => {
let mut android = AndroidCli::new(SubprocessRunner, serial.clone());
let mut adb = Adb::new(SubprocessRunner, serial);
if let Some(result) = capture_guard_result(&root, &mut adb, allow_package_mismatch)? {
return emit_result(result);
}
let result = whereami_result(
&root,
&mut android,
Expand All @@ -215,6 +222,9 @@ fn run(cli: Cli) -> Result<i32> {
Commands::Go { target } => {
let mut android = AndroidCli::new(SubprocessRunner, serial.clone());
let mut adb = Adb::new(SubprocessRunner, serial);
if let Some(result) = capture_guard_result(&root, &mut adb, allow_package_mismatch)? {
return emit_result(result);
}
let result = go_result(&root, &mut android, &mut adb, &target)?;
let code = exit_code_for_status(result["status"].as_str().unwrap_or("ok"));
print_json(&result);
Expand All @@ -231,6 +241,9 @@ fn run(cli: Cli) -> Result<i32> {
} => {
let mut android = AndroidCli::new(SubprocessRunner, serial.clone());
let mut adb = Adb::new(SubprocessRunner, serial);
if let Some(result) = capture_guard_result(&root, &mut adb, allow_package_mismatch)? {
return emit_result(result);
}
let result = tap_result(
&root,
&mut android,
Expand All @@ -252,6 +265,9 @@ fn run(cli: Cli) -> Result<i32> {
Commands::Scroll { direction } => {
let mut android = AndroidCli::new(SubprocessRunner, serial.clone());
let mut adb = Adb::new(SubprocessRunner, serial);
if let Some(result) = capture_guard_result(&root, &mut adb, allow_package_mismatch)? {
return emit_result(result);
}
let result = scroll_result(&root, &mut android, &mut adb, &direction)?;
let code = exit_code_for_status(result["status"].as_str().unwrap_or("ok"));
print_json(&result);
Expand All @@ -260,6 +276,9 @@ fn run(cli: Cli) -> Result<i32> {
Commands::Back => {
let mut android = AndroidCli::new(SubprocessRunner, serial.clone());
let mut adb = Adb::new(SubprocessRunner, serial);
if let Some(result) = capture_guard_result(&root, &mut adb, allow_package_mismatch)? {
return emit_result(result);
}
let result = back_result(&root, &mut android, &mut adb)?;
let code = exit_code_for_status(result["status"].as_str().unwrap_or("ok"));
print_json(&result);
Expand All @@ -268,6 +287,9 @@ fn run(cli: Cli) -> Result<i32> {
Commands::Layout { diff } => {
let mut android = AndroidCli::new(SubprocessRunner, serial.clone());
let mut adb = Adb::new(SubprocessRunner, serial);
if let Some(result) = capture_guard_result(&root, &mut adb, allow_package_mismatch)? {
return emit_result(result);
}
let result = layout_result(&root, &mut android, &mut adb, diff)?;
let code = exit_code_for_status(result["status"].as_str().unwrap_or("ok"));
print_json(&result);
Expand Down Expand Up @@ -596,10 +618,6 @@ fn layout_result<AR: CommandRunner, DR: CommandRunner>(
adb: &mut Adb<DR>,
diff: bool,
) -> Result<Value> {
if let Some(result) = layout_device_unavailable_result(adb)? {
return Ok(result);
}

if !diff {
if let Some(session) =
load_recent_session_place(root, adb, Duration::from_secs(LAYOUT_CACHE_TTL_SECS))?
Expand Down Expand Up @@ -683,7 +701,93 @@ fn layout_result<AR: CommandRunner, DR: CommandRunner>(
}))
}

fn layout_device_unavailable_result<R: CommandRunner>(adb: &mut Adb<R>) -> Result<Option<Value>> {
fn capture_guard_result<R: CommandRunner>(
root: &Path,
adb: &mut Adb<R>,
allow_package_mismatch: bool,
) -> Result<Option<Value>> {
if let Some(result) = device_unavailable_result(adb)? {
return Ok(Some(result));
}

let resolution = resolve_app_package(root)?;
let (expected_package, source) = match resolution {
AppPackageResolution::Configured { package, .. } => (package, "config"),
AppPackageResolution::Inferred { package, .. } => (package, "gradle_debug_variant"),
AppPackageResolution::Missing { profile } => {
return Ok(Some(json!({
"schema_version": RESULT_SCHEMA_VERSION,
"status": "config_error",
"summary": "Android application package is not configured and could not be inferred",
"error": {
"code": "app_package_missing",
"profile": profile,
"recovery": "Set app_profiles.<profile>.android_package or add an inferable Android application Gradle module."
},
"changed_graph": false,
"changed_files": []
})));
}
AppPackageResolution::Ambiguous {
profile,
candidates,
} => {
return Ok(Some(json!({
"schema_version": RESULT_SCHEMA_VERSION,
"status": "config_error",
"summary": "Multiple Android application packages were inferred",
"error": {
"code": "app_package_ambiguous",
"profile": profile,
"candidates": candidates,
"recovery": "Set android_package explicitly for the active app profile."
},
"changed_graph": false,
"changed_files": []
})));
}
};

let foreground_package = match adb.foreground_package() {
Ok(package) => package,
Err(_) if allow_package_mismatch => return Ok(None),
Err(error) => {
return Ok(Some(json!({
"schema_version": RESULT_SCHEMA_VERSION,
"status": "environment_error",
"summary": "Android foreground package could not be verified",
"error": {
"code": "foreground_package_unknown",
"expected_package": expected_package,
"expected_package_source": source,
"detail": error.to_string(),
"recovery": "Launch the expected app and retry, or pass --allow-package-mismatch to override this guard."
},
"changed_graph": false,
"changed_files": []
})));
}
};
if foreground_package != expected_package && !allow_package_mismatch {
return Ok(Some(json!({
"schema_version": RESULT_SCHEMA_VERSION,
"status": "app_mismatch",
"summary": "Foreground Android app does not match the active Minimap app profile",
"error": {
"code": "foreground_package_mismatch",
"expected_package": expected_package,
"expected_package_source": source,
"foreground_package": foreground_package,
"recovery": "Bring the expected app to the foreground, or pass --allow-package-mismatch for this capture."
},
"changed_graph": false,
"changed_files": []
})));
}
Ok(None)
}

fn device_unavailable_result<R: CommandRunner>(adb: &mut Adb<R>) -> Result<Option<Value>> {
let devices = adb.devices()?;
let attempted_serial = adb.configured_serial().map(str::to_string);
let selected = attempted_serial
Expand Down Expand Up @@ -751,6 +855,12 @@ fn layout_device_unavailable_result<R: CommandRunner>(adb: &mut Adb<R>) -> Resul
})))
}

fn emit_result(result: Value) -> Result<i32> {
let code = exit_code_for_status(result["status"].as_str().unwrap_or("ok"));
print_json(&result);
Ok(code)
}

fn tap_result<AR: CommandRunner, DR: CommandRunner>(
root: &Path,
android: &mut AndroidCli<AR>,
Expand Down
Loading
Loading