Skip to content
Open
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
12 changes: 11 additions & 1 deletion artemis/utils/ocr_xml_fusion.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,24 @@ def _calculate_overlap_ratio(ocr_bounds: dict[str, int], xml_bounds: dict[str, i

def _detect_status_bar_height(xml_hierarchy: list[dict[str, Any]], screen_height: int) -> int:
"""Try to detect status bar height from XML, fallback to 4% of screen height."""
# A status bar is a thin top-anchored strip; never taller than 12% of the
# screen (mirrors the top-bar bound in ui_filter._detect_fixed_system_bars).
max_status_bar_height = int(screen_height * 0.12)

for node in xml_hierarchy:
package = node.get("package") or node.get("packageName")
bounds_str = node.get("bounds")

# Look for systemui at the top
if package == "com.android.systemui":
bounds = _parse_bounds(bounds_str)
if bounds and bounds["top"] == 0:
# Full-screen SystemUI dialog/alert windows (USB debugging
# authorization, crash/ANR alerts, ...) also report top == 0.
# Accepting their bottom as the "status bar height" pushes every
# OCR coordinate off screen in _map_coordinates_back and silently
# drops all OCR results for the frame, so require a plausible
# thin-bar height and keep scanning for the real status bar.
if bounds and bounds["top"] == 0 and bounds["bottom"] <= max_status_bar_height:
return bounds["bottom"]

return int(screen_height * 0.04)
Expand Down
39 changes: 32 additions & 7 deletions artemis/utils/ui_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,22 +434,39 @@ def _filter_and_prune(
return [filtered_node]


#: Packages that only ever own dialogs or overlays and never own the status
#: or navigation bar. Nodes from these packages are never classified as fixed
#: bars, no matter how bar-like their bounds look (e.g. a bottom-anchored,
#: full-width runtime permission dialog).
_NEVER_FIXED_BAR_PACKAGES = frozenset({"com.android.permissioncontroller"})


def _detect_fixed_system_bars(
ui_hierarchy: list[dict[str, Any]], screen_width: int, screen_height: int
) -> list[dict[str, int]]:
"""Detect fixed, non-scrollable system or navigation bars anchored to the extreme top/bottom edges.

Enforces strict triple guardrails to guarantee near-zero false positives:
Enforces strict quadruple guardrails to guarantee near-zero false positives:

1. Edge & Width Guarantee: Must anchor to top (top==0) or bottom
(bottom==screen_height) and span >= 95% of screen width.
2. Non-Scrollable Root Branch: The candidate node and all its
descendants/ancestors must NOT be scrollable.
3. Identity & Height Bounds: High-confidence system package/IDs OR strict
navigation bar height bounds.
4. Thin Bar Guarantee: Even a high-confidence system package must be a
thin strip (<= 15% of screen height at the bottom, <= 12% at the top).
SystemUI also owns full-screen dialog/alert windows (USB debugging
authorization, crash/ANR alerts, ...); without a height cap those were
classified as bars, which made ``_clamp_by_fixed_bars`` mangle or
silently discard the dialog's neighbours and shifted tap targets.
Dialog-only packages (``_NEVER_FIXED_BAR_PACKAGES``) are never bars.
"""
fixed_bars = []

max_bottom_bar_height = int(screen_height * 0.15)
max_top_bar_height = int(screen_height * 0.12)

def check_scrollable(node: dict[str, Any]) -> bool:
if node.get("scrollable") is True or str(node.get("scrollable", "")).lower() == "true":
return True
Expand Down Expand Up @@ -487,16 +504,24 @@ def scan_for_bars(node: dict[str, Any], has_scrollable_ancestor: bool):
)

# Check bottom fixed bar (e.g., bottom navigation bar / system navigation bar)
if bounds["bottom"] >= screen_height and is_full_width and height > 0:
if is_high_conf_id or (25 <= height <= int(screen_height * 0.15)):
if (
package_name not in _NEVER_FIXED_BAR_PACKAGES
and bounds["bottom"] >= screen_height
and is_full_width
and 0 < height <= max_bottom_bar_height
):
if is_high_conf_id or height >= 25:
fixed_bars.append(bounds.copy())
return # Don't recurse into children of confirmed fixed bars

# Check top fixed bar (e.g., status bar / top action bar)
if bounds["top"] <= 0 and is_full_width and height > 0:
if package_name == "com.android.systemui" or (
15 <= height <= int(screen_height * 0.12)
):
if (
package_name not in _NEVER_FIXED_BAR_PACKAGES
and bounds["top"] <= 0
and is_full_width
and 0 < height <= max_top_bar_height
):
if package_name == "com.android.systemui" or height >= 15:
fixed_bars.append(bounds.copy())
return

Expand Down
18 changes: 18 additions & 0 deletions tests/unit/utils/test_ocr_xml_fusion.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ def test_detect_status_bar_height(self):
xml = [{"package": "com.example.app", "bounds": "[0,0][1080,2400]"}]
self.assertEqual(_detect_status_bar_height(xml, screen_height=2400), 96)

def test_detect_status_bar_height_ignores_full_screen_systemui_dialog(self):
# Issue #180: a full-screen SystemUI dialog/alert window (USB debugging
# authorization, crash/ANR alerts) also reports top == 0. Treating its
# bottom as the status bar height (2400) would shift every OCR
# coordinate off screen in _map_coordinates_back and silently drop all
# OCR results for the frame. The scan must skip it and find the real
# status bar even when the dialog window comes first in the dump.
xml = [
{"package": "com.android.systemui", "bounds": "[0,0][1080,2400]"},
{"package": "com.android.systemui", "bounds": "[0,0][1080,120]"},
{"package": "com.example.app", "bounds": "[0,0][1080,2400]"},
]
self.assertEqual(_detect_status_bar_height(xml, screen_height=2400), 120)

# Only the dialog window visible: fall back to 4% of screen height.
xml = [{"package": "com.android.systemui", "bounds": "[0,0][1080,2400]"}]
self.assertEqual(_detect_status_bar_height(xml, screen_height=2400), 96)

def test_crop_image_remove_status_bar(self):
# Create a dummy image
img = Image.new("RGB", (100, 100), color="red")
Expand Down
Loading
Loading