Skip to content
Merged
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
27 changes: 27 additions & 0 deletions uraniborg/docs/automate_observation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)) |

Expand All @@ -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:

Expand Down Expand Up @@ -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` |
Expand Down
123 changes: 113 additions & 10 deletions uraniborg/scripts/python/automate_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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).",
Expand Down
3 changes: 3 additions & 0 deletions uraniborg/scripts/python/tests/test_automate_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 <cwd>/results
Expand All @@ -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
Expand Down
Loading
Loading