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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ notes, so you can check for a newer version — and read what changed — withou

## [Unreleased]

### Fixed

- `aua record stop` no longer fails a recording whose screen never changed at all. A wholly
static window makes `screenrecord` emit a single frame, so its media length is exactly 0.0s,
and the aggregate "captured nothing at all" guard fired underneath the per-segment rule that
already excuses stillness: an 8.02s idle recording exited 3 with `recording_coverage_failed`
while holding a valid 37 KB MP4. A partly idle window passed and a wholly idle one did not.
Zero media now fails only where no `static_screen_no_frames` gap accounts for it. No segments,
a missing supervisor completion, an unreadable or unfinished segment, a non-zero recorder exit,
and stretches where the encoder was not running all still fail.

## [0.27.5] - 2026-09-16

### Fixed
Expand Down
10 changes: 7 additions & 3 deletions docs/recording.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,13 @@ positive check that the directory is absent. Unverified ownership retains the pe
Inspect `segments`, `gaps`, `finish`, and `duration_check`. Lifecycle timestamps use target
boot uptime, so changing the device wall clock cannot shorten a measured pause. The native
movie duration is compared with the encoder process window and the requested recording
window, with a two-second tolerance. A duration shortfall or missing supervisor completion
returns `ok=false` after evidence collection. After encoder failure or incomplete supervisor
lifecycle, stop exports all finalized segments in order into the requested playable MP4.
window, with a two-second tolerance. Missing supervisor completion, an unreadable or unfinished
segment, and stretches where the encoder was not running return `ok=false` after evidence
collection. A shortfall while the encoder was running is reported as a `static_screen_no_frames`
gap and `coverage_shortfall_s` instead: `screenrecord` emits a frame when the screen changes, so
a window whose screen never changed is 0.0 seconds of media rather than lost footage. After
encoder failure or incomplete supervisor lifecycle, stop exports all finalized segments in
order into the requested playable MP4.
Every lifecycle segment has a `status` (`finalized`, `missing`, or `corrupt`) and `exported`
flag. Corrupt/partial files are copied unchanged into the segment directory. Missing footage
is explicitly unexported; absence requires a successful inspection with a known missing-file
Expand Down
15 changes: 12 additions & 3 deletions src/android_ui_analyser/platforms/android_recording.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,13 +544,22 @@ def event_time(raw: str) -> float:
if stop_uptime_s - previous > 0:
gaps.append({"start_uptime_s": previous, "end_uptime_s": stop_uptime_s, "reason": "recording_ended_before_stop"})
requested = max(0.0, stop_uptime_s - first)
# Two coverage results a static screen cannot explain, and only these still fail: capturing
# nothing at all, and stretches where the encoder was not running. Short media while the
# Only stretches where the encoder was not running still fail here. Short media while the
# encoder WAS running is the static-screen case and is reported instead.
not_running = {"encoder_startup", "segment_rotation", "recording_ended_before_stop"}
dark = [gap for gap in gaps if gap["reason"] in not_running
and (gap["end_uptime_s"] or 0) - (gap["start_uptime_s"] or 0) > _COVERAGE_TOLERANCE_S]
failed = failed or bool(dark) or (requested > _COVERAGE_TOLERANCE_S and media_total <= 0)
# "Captured nothing at all" is not a separate result from stillness. A window whose screen
# never changed once encodes a single frame, so its media length is exactly 0.0: a 2026-09-17
# idle run wrote a valid 37,320-byte MP4 with nb_frames=1 over 8.02s and was failed by this
# guard -- the very recording the per-segment rule above was written to keep. A partly idle
# window was tolerated and a wholly idle one was not. So zero media only fails where no
# static_screen_no_frames gap already accounts for the stillness. A recorder that really
# captured nothing is caught before this line: no segments, no finish event, an encoder
# failure, a missing or unreadable segment file, or a non-zero recorder exit.
stillness = any(gap["reason"] == "static_screen_no_frames" for gap in gaps)
captured_nothing = requested > _COVERAGE_TOLERANCE_S and media_total <= 0 and not stillness
failed = failed or bool(dark) or captured_nothing
return {
"mode": "native_segments", "state": "finalized", "segments": list(segments.values()),
"requested_duration_s": requested, "media_duration_s": media_total,
Expand Down
77 changes: 74 additions & 3 deletions tests/test_recording_coverage_is_judged_in_one_direction.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ def test_a_shortfall_is_never_negative() -> None:
# idle at home, so the check was guaranteed to fail exactly the runs it was meant to protect.
#
# Only a recording that captured nothing can still fail on coverage.
#
# --- 2026-09-17, third correction --------------------------------------------------------
#
# "Captured nothing" and "nothing happened" turned out to be the same number. A screen that
# never changes at all makes `screenrecord` emit ONE frame, and one frame is 0.0 seconds of
# media. An 8.02s recording of an idle screen wrote a valid 37,320-byte MP4 (`nb_frames=1`)
# and still exited 3 with `recording_coverage_failed`, because the aggregate "media_total <= 0"
# guard fired underneath the per-segment rule that had just excused the same stillness.
#
# So a partly idle window passed and a wholly idle one failed -- exactly inverted. Zero media
# now fails only where no `static_screen_no_frames` gap accounts for it. Everything a dead
# recorder actually produces (no segments, no finish event, an unreadable file, a non-zero
# exit, an encoder that was not running) is judged before that point and still fails.

IDLE_RUN = {"wall_s": 8.33, "media_s": 0.0}

A1_RUN = {"wall_s": 168.17, "media_s": 130.001, "frames": 126}

Expand All @@ -103,10 +118,66 @@ def test_the_shortfall_is_still_reported_rather_than_hidden(monkeypatch):
assert "static_screen_no_frames" in reasons


def test_capturing_nothing_at_all_still_fails(monkeypatch):
# The one coverage result a screen that did not change cannot explain.
report = _timeline(media_s=0.0, monkeypatch=monkeypatch)
def test_a_wholly_static_window_is_zero_media_and_still_passes(monkeypatch):
"""The reported bug: one frame over the whole window is 0.0s of media, not lost evidence."""
report = _timeline(media_s=IDLE_RUN["media_s"], wall_s=IDLE_RUN["wall_s"], monkeypatch=monkeypatch)

assert report["media_duration_s"] == 0.0
assert report["duration_check"] == "passed"


def test_a_wholly_static_window_still_reports_its_shortfall(monkeypatch):
"""Passing is not hiding: the whole window is still named as stillness, not as coverage."""
report = _timeline(media_s=IDLE_RUN["media_s"], wall_s=IDLE_RUN["wall_s"], monkeypatch=monkeypatch)

assert report["coverage_shortfall_s"] == pytest.approx(IDLE_RUN["wall_s"], abs=0.05)
assert [gap["reason"] for gap in report["gaps"]] == ["static_screen_no_frames"]
assert report["encoder_idle_gaps"] == []


def test_a_static_window_that_rotated_segments_also_passes(monkeypatch):
"""Rotation is why the excuse is keyed to stillness found, not to stillness alone.

A window longer than the native segment limit rotates, and rotation adds its own gap. If a
zero-media recording were only excused when *every* gap is stillness, the same idle screen
would pass at 8 seconds and fail at 400.
"""
from android_ui_analyser.platforms import android_recording as rec

monkeypatch.setattr(rec, "media_duration", lambda path: 0.0)
events = ("begin 0 100\nend 0 280 0\nbegin 1 280.4\nend 1 460.4 0\n"
"begin 2 460.9\nend 2 500.0 0\nfinish 500.0 stopped\n")
report = rec.timeline(events, [Path(f"segment-{i}.mp4") for i in range(3)],
stop_uptime_s=500.0, start_uptime_s=100.0)

assert report["media_duration_s"] == 0.0
assert "segment_rotation" in {gap["reason"] for gap in report["gaps"]}
assert report["duration_check"] == "passed"


def test_capturing_nothing_that_stillness_cannot_explain_still_fails(monkeypatch):
"""Zero media across segments too short to raise a stillness gap keeps failing."""
from android_ui_analyser.platforms import android_recording as rec

monkeypatch.setattr(rec, "media_duration", lambda path: 0.0)
events = "begin 0 100\nend 0 101.2 0\nbegin 1 101.4\nend 1 102.6 0\nfinish 102.6 stopped\n"
report = rec.timeline(events, [Path("segment-0.mp4"), Path("segment-1.mp4")],
stop_uptime_s=102.6, start_uptime_s=100.0)

assert not any(gap["reason"] == "static_screen_no_frames" for gap in report["gaps"])
assert report["duration_check"] == "failed"


def test_a_recorder_that_produced_no_segment_at_all_still_fails():
"""The dead recorder: the window was requested, nothing ever began, nothing can excuse it."""
from android_ui_analyser.platforms import android_recording as rec

report = rec.timeline("", [], stop_uptime_s=1000.0 + A1_RUN["wall_s"], start_uptime_s=1000.0)

assert report["segments"] == []
assert report["media_duration_s"] == 0.0
assert report["duration_check"] == "failed"
assert report["encoder_idle_gaps"][0]["reason"] == "encoder_startup"


def test_a_recorder_that_died_still_fails(monkeypatch):
Expand Down