diff --git a/uraniborg/docs/automate_observation.md b/uraniborg/docs/automate_observation.md index 82f7d84..1f19f66 100644 --- a/uraniborg/docs/automate_observation.md +++ b/uraniborg/docs/automate_observation.md @@ -84,6 +84,8 @@ Optional fields are left out rather than set to `null`. | `step` | `step`, `state` (`started` / `finished` / `failed`), `device`\*, `duration_ms`\*\*, `message`\* | Around each phase (see below) | | `devices` | `devices` (list of `{serial, unauthorized, model?, product?, device?}`), `selected` (serials to observe), `missing` (requested via `--serial` but not connected) | Once, after listing devices | | `device_started` | `device` | Before processing each selected device | +| `prompt` | `device`, `kind`, `message`, `expects_input` | The script is waiting for a person (see [Manual intervention](#manual-intervention)) | +| `prompt_resolved` | `device`, `kind`, `outcome` | The wait is over, however it ended | | `device_finished` | `device`, `status`, `results_dir`\*, `error`\* | After each selected device, and once for each missing serial | | `run_finished` | `exit_code`, `ok`, `summary`, `error`\* | Last event of a run that ends normally, exits early, raises, or is stopped by Ctrl-C or `SIGTERM` (see [End of stream](#end-of-stream)) | @@ -103,6 +105,30 @@ pre-fetching is attempted) and `inclusion_proof_check`. A failed `inclusion_proof_prefetch` is not fatal: verification falls back to fetching entries on demand. +#### Manual intervention + +Two situations need a person. Each is reported as a `prompt` event, and is +always followed by a matching `prompt_resolved` (same `device` and `kind`) +once the wait ends, whether it succeeded, failed or was interrupted. + +| `kind` | When | `expects_input` | What to do | +| :--- | :--- | :--- | :--- | +| `xiaomi_manual_install` | During `install_hubble` on Xiaomi phones, where Hubble must be installed by hand | `true` | Install Hubble on the device, then write a newline to the script's stdin. If Hubble is still not installed, the script waits for another newline, without a new `prompt` | +| `adb_backup_confirm` | During `extract_results`, when results must be pulled with `adb backup` | `false` | Tap `Back up my data` on the device. Nothing is read from stdin | + +`prompt_resolved.outcome` says how the wait ended: + +| `outcome` | Meaning | +| :--- | :--- | +| `done` | Hubble is installed, or `adb backup` completed | +| `failed` | `adb backup` failed (the device then fails with `extract_failed`) | +| `stdin_closed` | stdin was closed while waiting for a newline | +| `interrupted` / `terminated` / `unexpected_error` | The wait was cut short by Ctrl-C, `SIGTERM` or an unhandled exception | + +If stdin is closed while the script waits for a newline (for example, the +wrapper exits, or stdin is `/dev/null`), that device fails with reason +`stdin_closed` and the run moves on to the next device. + **Device status.** `device_finished.status` and `summary[serial].status` use the same four outcomes as the final log summary: @@ -162,6 +188,7 @@ with code `0`, but `run_finished.error` explains why. | `launch_failed` | Hubble could not be launched | `failed` | | `no_results` | Hubble produced no results in time | `failed` | | `extract_failed` | Results could not be pulled from the device | `failed` | +| `stdin_closed` | stdin was closed while waiting for a manual Hubble install | `failed` | | `inclusion_proof_check_incomplete` | The inclusion proof check could not complete | `partial_check_incomplete` | | `unexpected_error` | An unhandled exception while processing the device | `failed` or `partial_error` | | `interrupted` | Ctrl-C while processing the device | `failed` or `partial_error` | diff --git a/uraniborg/scripts/python/automate_observation.py b/uraniborg/scripts/python/automate_observation.py index 1ec55fb..9a1b291 100644 --- a/uraniborg/scripts/python/automate_observation.py +++ b/uraniborg/scripts/python/automate_observation.py @@ -206,11 +206,23 @@ def set_up_logging(args: argparse.Namespace) -> logging.Logger: REASON_NO_RESULTS = "no_results" REASON_EXTRACT_FAILED = "extract_failed" REASON_INCLUSION_PROOF_CHECK_INCOMPLETE = "inclusion_proof_check_incomplete" +REASON_STDIN_CLOSED = "stdin_closed" # Both run_finished.error and device_finished.error: REASON_UNEXPECTED_ERROR = "unexpected_error" REASON_INTERRUPTED = "interrupted" REASON_TERMINATED = "terminated" +# Kinds of manual intervention reported by prompt / prompt_resolved events. +PROMPT_XIAOMI_MANUAL_INSTALL = "xiaomi_manual_install" +PROMPT_ADB_BACKUP_CONFIRM = "adb_backup_confirm" + +# How a prompt ended, reported by prompt_resolved.outcome. An exception during +# the wait maps to REASON_INTERRUPTED, REASON_TERMINATED or +# REASON_UNEXPECTED_ERROR. +PROMPT_OUTCOME_DONE = "done" +PROMPT_OUTCOME_FAILED = "failed" +PROMPT_OUTCOME_STDIN_CLOSED = REASON_STDIN_CLOSED + class Terminated(BaseException): """Raised by the SIGTERM handler that main() installs while --events is on. @@ -280,6 +292,13 @@ def fail(self, message: str) -> str: return message +class _Prompt: + """Handle yielded by EventEmitter.prompt() to record how the wait ended.""" + + def __init__(self): + self.outcome = PROMPT_OUTCOME_DONE + + class EventEmitter: """Writes progress events as JSON Lines. @@ -357,6 +376,30 @@ def step(self, name: str, device: Optional[str] = None): duration_ms=int((time.monotonic() - start) * 1000), message=handle.message) + @contextlib.contextmanager + def prompt(self, device: Optional[str], kind: str, message: str, + expects_input: bool): + """Brackets a wait for manual intervention with prompt/prompt_resolved. + + prompt_resolved is emitted however the body exits, including by an + exception, so every prompt is closed. Its outcome is "done" unless the + body sets another PROMPT_OUTCOME_* on the yielded handle, or raises (then + it is the matching error reason, e.g. "interrupted"). expects_input tells + the reader whether the script is blocked on stdin (write a newline to + continue) or on an action on the device. + """ + handle = _Prompt() + self.emit("prompt", device=device, kind=kind, message=message, + expects_input=expects_input) + try: + yield handle + except BaseException as e: + handle.outcome = _interruption_error(e)["reason"] + raise + finally: + self.emit("prompt_resolved", device=device, kind=kind, + outcome=handle.outcome) + def finish_run(self, exit_code: int, summary: Optional[dict] = None, error: Optional[dict] = None): """Emits run_finished. Only the first call has any effect. @@ -577,6 +620,48 @@ def launch_xiaomi_file_explorer( "com.android.fileexplorer.FileExplorerTabActivity") +def wait_for_xiaomi_manual_install(adb_wrapper: syscall_wrapper.AdbWrapper, + serial: str, + logger: logging.Logger, + events: EventEmitter) -> bool: + """Waits, via stdin, for the user to install Hubble by hand on a Xiaomi phone. + + The user is asked to press Enter after installing; this repeats until Hubble + is installed. With --events, the wait is reported as a prompt event so that + a parent process can ask its user and then write a newline to stdin. + + Args: + adb_wrapper: An AdbWrapper for the target device. + serial: The target device's serial number, for events. + logger: A logger object to log messages. + events: The EventEmitter for the run. + + Returns: + True once Hubble is installed; False if stdin was closed first (e.g. the + parent process cancelled the run, or stdin is /dev/null). + """ + if is_hubble_installed(adb_wrapper, logger): + return True + with events.prompt(serial, PROMPT_XIAOMI_MANUAL_INSTALL, + "Install Hubble manually from the \"Downloads\" folder " + "in the \"Files Manager\" app on the device, then press " + "Enter.", + expects_input=True) as prompt: + while True: + logger.warning("Please manually install Hubble by launching the " + "\"Files Manager\" app (it may have been launched " + "for you) and navigate to the \"Downloads\" folder.") + try: + input("Press [ENTER] when you are done.") + except EOFError: + logger.error("Standard input was closed while waiting for Hubble to " + "be installed manually on device %s.", serial) + prompt.outcome = PROMPT_OUTCOME_STDIN_CLOSED + return False + if is_hubble_installed(adb_wrapper, logger): + return True + + def adb_push_hubble(adb_wrapper: syscall_wrapper.AdbWrapper, hubble_path: str): """Drops the Hubble APK onto device (used when direct installation fails). @@ -850,7 +935,8 @@ def classify_dir_using_build_fingerprint( extract_apks: bool, logger: logging.Logger, pull_preinstalled_only: bool = False, - tmp_dir: str = "/tmp") -> Optional[str]: + tmp_dir: str = "/tmp", + events: Optional[EventEmitter] = None) -> Optional[str]: """Decides which directory in results/ to dump new result to. This is a renewed method that makes use of build fingerprint to do @@ -868,6 +954,8 @@ def classify_dir_using_build_fingerprint( listed in preinstalled_packages.txt. tmp_dir: Temporary directory on host used for staging build.txt and adb backup artifacts. Defaults to "/tmp". + events: An optional EventEmitter; used to report the `adb backup` + confirmation that the user must give on the device. Returns: A string representing the final directory (on host) where results are pulled @@ -888,7 +976,15 @@ def classify_dir_using_build_fingerprint( decompressed_backup_filepath = os.path.join(tmp_dir, "hubble_results.tar") logger.warning("Manual intervention required: Please select " "`Back up my data` to proceed") - if not adb_wrapper.backup(compressed_backup_filepath, HUBBLE_PACKAGE_NAME): + with (events or EventEmitter()).prompt( + adb_wrapper.device_serial_number, PROMPT_ADB_BACKUP_CONFIRM, + "Select `Back up my data` on the device to proceed.", + expects_input=False) as prompt: + backed_up = adb_wrapper.backup(compressed_backup_filepath, + HUBBLE_PACKAGE_NAME) + if not backed_up: + prompt.outcome = PROMPT_OUTCOME_FAILED + if not backed_up: logger.error("Failed to use `adb backup` to pull result files.") return None @@ -994,7 +1090,9 @@ def extract_results_and_apks(adb_wrapper: syscall_wrapper.AdbWrapper, logger: logging.Logger, extract_apks=False, pull_preinstalled_only=False, - tmp_dir: str = "/tmp") -> Optional[str]: + tmp_dir: str = "/tmp", + events: Optional[EventEmitter] = None + ) -> Optional[str]: """Extracts results (and optionally APKs) from Hubble's execution. Args: @@ -1008,6 +1106,7 @@ def extract_results_and_apks(adb_wrapper: syscall_wrapper.AdbWrapper, from preinstalled_packages.txt. tmp_dir: Temporary directory on host used for staging build.txt and adb backup artifacts. Defaults to "/tmp". + events: An optional EventEmitter, passed on to report manual intervention. Returns: A string representing the final directory (on host) where results are copied @@ -1041,7 +1140,8 @@ def extract_results_and_apks(adb_wrapper: syscall_wrapper.AdbWrapper, extract_apks, logger, pull_preinstalled_only=pull_preinstalled_only, - tmp_dir=tmp_dir) + tmp_dir=tmp_dir, + events=events) def extract_selinux_policies(adb_wrapper: syscall_wrapper.AdbWrapper, @@ -1327,11 +1427,13 @@ def early_exit(reason: str, message: str) -> int: adb_push_hubble(adb_wrapper, args.hubble) if not launch_xiaomi_file_explorer(adb_wrapper): logger.error("Failed to launch Xiaomi file explorer") - while not is_hubble_installed(adb_wrapper, logger): - logger.warning("Please manually install Hubble by launching the " - "\"Files Manager\" app (it may have been launched " - "for you) and navigate to the \"Downloads\" folder.") - input("Press [ENTER] when you are done.") + if not wait_for_xiaomi_manual_install(adb_wrapper, serial, logger, + events): + device_error = _error(REASON_STDIN_CLOSED, s.fail( + "Standard input closed while waiting for manual Hubble " + "installation.")) + has_errors = True + continue else: logger.info("This is not a Xiaomi phone. Regular workflow continues...") if not install_hubble(adb_wrapper, args, logger): @@ -1370,7 +1472,8 @@ def early_exit(reason: str, message: str) -> int: logger, extract_apks, pull_preinstalled_only=args.pull_preinstalled_apks_only, - tmp_dir=device_tmp_dir) + tmp_dir=device_tmp_dir, + events=events) if not results_dir: logger.error("Failed to extract results from target device (%s).", diff --git a/uraniborg/scripts/python/tests/test_automate_observation.py b/uraniborg/scripts/python/tests/test_automate_observation.py index 3e3541a..781603d 100644 --- a/uraniborg/scripts/python/tests/test_automate_observation.py +++ b/uraniborg/scripts/python/tests/test_automate_observation.py @@ -1321,6 +1321,7 @@ def test_extract_results_and_apks_destination_normalization_and_validation( logger, pull_preinstalled_only=True, tmp_dir=staging_dir, + events=None, ) # 2. Destination already ending with "results" + trailing slashes -> does not duplicate "/results" @@ -1342,6 +1343,7 @@ def test_extract_results_and_apks_destination_normalization_and_validation( logger, pull_preinstalled_only=False, tmp_dir="/tmp", + events=None, ) # 3. Empty destination "" -> defaults to /results @@ -1365,6 +1367,7 @@ def test_extract_results_and_apks_destination_normalization_and_validation( logger, pull_preinstalled_only=False, tmp_dir="/tmp", + events=None, ) # 4. Target results_dir exists as a regular file -> logs error and returns None diff --git a/uraniborg/scripts/python/tests/test_automate_observation_events.py b/uraniborg/scripts/python/tests/test_automate_observation_events.py index aa7e2af..6038ad5 100644 --- a/uraniborg/scripts/python/tests/test_automate_observation_events.py +++ b/uraniborg/scripts/python/tests/test_automate_observation_events.py @@ -875,5 +875,204 @@ def test_open_event_stream_dash_moves_other_stdout_writes_to_stderr(): assert "noise from child" in proc.stderr +# --- Manual-intervention prompts ------------------------------------------------ + + +def test_prompt_is_always_followed_by_prompt_resolved(): + stream = io.StringIO() + emitter = _emitter(stream) + with emitter.prompt("DEV1", "some_kind", "Do something.", + expects_input=True): + pass + with pytest.raises(KeyboardInterrupt): + with emitter.prompt(None, "other_kind", "Wait.", expects_input=False): + raise KeyboardInterrupt() + assert _parse(stream) == [ + {"v": 1, "ts": "2026-01-02T03:04:05.678Z", "type": "prompt", + "device": "DEV1", "kind": "some_kind", "message": "Do something.", + "expects_input": True}, + {"v": 1, "ts": "2026-01-02T03:04:05.678Z", "type": "prompt_resolved", + "device": "DEV1", "kind": "some_kind", "outcome": "done"}, + {"v": 1, "ts": "2026-01-02T03:04:05.678Z", "type": "prompt", + "kind": "other_kind", "message": "Wait.", "expects_input": False}, + {"v": 1, "ts": "2026-01-02T03:04:05.678Z", "type": "prompt_resolved", + "kind": "other_kind", "outcome": "interrupted"}, + ] + + +@pytest.mark.parametrize( + "body, expected_outcome", + [ + (lambda p: None, "done"), + (lambda p: setattr(p, "outcome", "failed"), "failed"), + (lambda p: setattr(p, "outcome", "stdin_closed"), "stdin_closed"), + ], + ids=["default", "failed", "stdin_closed"], +) +def test_prompt_outcome_set_by_body(body, expected_outcome): + stream = io.StringIO() + with _emitter(stream).prompt("DEV1", "k", "m", expects_input=True) as p: + body(p) + assert _parse(stream)[-1]["outcome"] == expected_outcome + + +@pytest.mark.parametrize( + "exc, expected_outcome", + [ + (KeyboardInterrupt(), "interrupted"), + (automate_observation.Terminated(), "terminated"), + (RuntimeError("boom"), "unexpected_error"), + ], + ids=["ctrl_c", "sigterm", "exception"], +) +def test_prompt_outcome_from_exception_overrides_body(exc, expected_outcome): + stream = io.StringIO() + with pytest.raises(type(exc)): + with _emitter(stream).prompt("DEV1", "k", "m", expects_input=True) as p: + p.outcome = "failed" + raise exc + assert _parse(stream)[-1] == { + "v": 1, "ts": "2026-01-02T03:04:05.678Z", "type": "prompt_resolved", + "device": "DEV1", "kind": "k", "outcome": expected_outcome} + + +def test_prompt_without_stream_is_a_noop(): + with EventEmitter().prompt("DEV1", "k", "m", expects_input=True): + pass + + +@pytest.fixture +def xiaomi_mocks(serial_main_mocks): + m = serial_main_mocks + m["AdbWrapper"].devices.return_value = [_make_mock_device("DEV1")] + m["is_xiaomi_phone"].return_value = True + with mock.patch("automate_observation.adb_push_hubble"), \ + mock.patch("automate_observation.launch_xiaomi_file_explorer", + return_value=True): + yield m + + +def test_main_events_xiaomi_manual_install_prompt( + xiaomi_mocks, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +): + m = xiaomi_mocks + # Not installed beforehand; then still missing after the first Enter, and + # installed after the second. + m["is_hubble_installed"].side_effect = [False, False, False, True] + stdin = io.StringIO("\n\n") + monkeypatch.setattr(sys, "stdin", stdin) + events_path = tmp_path / "events.jsonl" + _set_argv(monkeypatch, "--events", str(events_path)) + extract = m["extract_results_and_apks"].side_effect + emitter_enabled_at_extract = [] + + def _extract(*args, **kwargs): + emitter_enabled_at_extract.append(kwargs["events"].enabled) + return extract(*args, **kwargs) + + m["extract_results_and_apks"].side_effect = _extract + + automate_observation.main() + + assert stdin.read() == "" # both newlines consumed by the real input() + events = _read_events(events_path) + install = [e for e in events if e["type"] in ("prompt", "prompt_resolved") + or (e["type"] == "step" and e["step"] == "install_hubble")] + assert [(e["type"], e.get("state")) for e in install] == [ + ("step", "started"), ("prompt", None), ("prompt_resolved", None), + ("step", "finished")] + prompt = install[1] + assert prompt["device"] == "DEV1" + assert prompt["kind"] == "xiaomi_manual_install" + assert prompt["expects_input"] is True + assert "press Enter" in prompt["message"] + assert (install[2]["device"], install[2]["kind"], install[2]["outcome"]) == ( + "DEV1", "xiaomi_manual_install", "done") + (finished,) = _of_type(events, "device_finished") + assert finished["status"] == "success" + # The live emitter is also handed down for the adb backup prompt. + assert emitter_enabled_at_extract == [True] + + +def test_main_events_xiaomi_already_installed_after_push_has_no_prompt( + xiaomi_mocks, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +): + m = xiaomi_mocks + m["is_hubble_installed"].side_effect = [False, True] + events_path = tmp_path / "events.jsonl" + _set_argv(monkeypatch, "--events", str(events_path)) + with mock.patch("builtins.input") as fake_input: + automate_observation.main() + fake_input.assert_not_called() + assert not _of_type(_read_events(events_path), "prompt") + + +@pytest.mark.parametrize("with_events", [True, False]) +def test_main_xiaomi_stdin_closed_fails_device_and_stops_waiting( + xiaomi_mocks, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + with_events, +): + m = xiaomi_mocks + m["is_hubble_installed"].return_value = False # never installed + monkeypatch.setattr(sys, "stdin", io.StringIO("")) # EOF on first read + events_path = tmp_path / "events.jsonl" + _set_argv(monkeypatch, + *(["--events", str(events_path)] if with_events else [])) + + with pytest.raises(SystemExit) as exc_info: + automate_observation.main() + + assert exc_info.value.code == 1 + m["launch_hubble"].assert_not_called() + # Reported as a clear error, not as an unexpected exception. + m["logger"].exception.assert_not_called() + assert any("Standard input was closed" in c.args[0] + for c in m["logger"].error.call_args_list) + if not with_events: + assert not events_path.exists() + return + events = _read_events(events_path) + assert [(e["type"], e.get("outcome")) for e in events + if e["type"] in ("prompt", "prompt_resolved")] == [ + ("prompt", None), ("prompt_resolved", "stdin_closed")] + assert _steps(events, "DEV1") == [ + ("install_hubble", "started"), ("install_hubble", "failed")] + (finished,) = _of_type(events, "device_finished") + assert finished["status"] == "failed" + assert finished["error"]["reason"] == "stdin_closed" + assert events[-1]["summary"] == {"DEV1": {"status": "failed"}} + + +def test_adb_backup_confirmation_is_reported_as_prompt(tmp_path: Path): + stream = io.StringIO() + emitter = _emitter(stream) + mock_adb = mock.Mock() + mock_adb.device_serial_number = "DEV1" + mock_adb.pull.return_value = False # forces the adb backup fallback + events_during_backup = [] + + def fake_backup(ab_path, pkg_name): # pylint: disable=unused-argument + events_during_backup.extend(_parse(stream)) + return False + + mock_adb.backup.side_effect = fake_backup + + assert automate_observation.classify_dir_using_build_fingerprint( + mock_adb, "/sdcard/hubble/results", str(tmp_path / "results"), + extract_apks=False, logger=mock.Mock(), tmp_dir=str(tmp_path), + events=emitter) is None + + # The prompt is open while `adb backup` blocks, and closed afterwards even + # though the backup failed. + assert [e["type"] for e in events_during_backup] == ["prompt"] + assert events_during_backup[0]["device"] == "DEV1" + assert events_during_backup[0]["kind"] == "adb_backup_confirm" + assert events_during_backup[0]["expects_input"] is False + assert [(e["type"], e["kind"], e.get("outcome")) + for e in _parse(stream)] == [ + ("prompt", "adb_backup_confirm", None), + ("prompt_resolved", "adb_backup_confirm", "failed")] + + if __name__ == "__main__": sys.exit(pytest.main([__file__]))