4 Commits

Author SHA1 Message Date
1e1bba6b16 fix(perception+brain): story view detection + autonomous prompt
Two root causes for 'scroll on story' bug:

1. ScreenIdentity had ZERO structural markers for story views.
   reel_viewer_media_layout, reel_viewer_header, reel_viewer_progress_bar
   and content-desc 'Like Story'/'Send story' now → STORY_VIEW.

2. Brain prompt was prescriptive ('you MUST scroll down'), overriding
   the LLM's intelligence. Rewritten to give context about screen types
   and let the AI reason autonomously about which action makes sense.

Philosophy: AI decides navigation, we provide correct perception data.
No hardcoded 'if story → press back' escape hatch.

4 new perception tests (all green), 0 regressions.
2026-04-27 23:55:09 +02:00
2b992cf2a8 test(RED): expose story view detection gap — ScreenIdentity returns UNKNOWN
Bug evidence from run 2026-04-27_23-46-57:
- Bot started on a story (reel_viewer_media_layout, 'Like Story')
- ScreenIdentity classified it as UNKNOWN
- GOAP chose 'scroll down' 4 times (stories don't scroll)
- Bot was trapped in infinite scroll loop

Captured real XML fixture: story_view_full.xml
1 test FAILS (screen_identity → UNKNOWN instead of STORY_VIEW)
2026-04-27 23:51:04 +02:00
c051c3a4c3 fix(dm-engine): 4 safety hardening patches with TDD proof
Kill-Switch: Refuse DM processing when dm_reply.enabled=false in config.
  Root cause: checked nonexistent 'disable_ai_messaging' flag instead of
  actual plugin config.

Context Guard: Skip threads with no extractable message text.
  Root cause: LLM was fed 'No previous context' → produced garbage like
  'the to the'.

Send Verification: Structurally verify Send button resource-id/desc.
  Root cause: VLM returned reactions_pill_container, edit fields, etc.
  and engine blindly clicked them, logging 'Successfully sent'.

Iteration Cap: MAX_REPLIES_PER_INBOX_VISIT=3 prevents spam.
  Root cause: no loop guard → 8 DMs sent in 2 minutes in production.

Refactored: removed dead 'if True' guard, de-indented block,
moved dm_memory.log_sent_dm into success branch only.

All 6 E2E tests pass. No regressions (54/55 passed, 1 pre-existing).
2026-04-27 23:43:02 +02:00
3006020106 test(RED): 4 failing tests expose DM engine config bypass & spam bugs
Tests expose:
1. DM Engine ignores dm_reply.enabled config (checks nonexistent 'disable_ai_messaging')
2. Logs 'Successfully sent' without verifying actual Send button click
3. Generates garbage replies from 'No previous context' (story replies)
4. No max-iteration guard — sent 20 messages in test, 8 in production

All 4 tests FAIL. Ready for GREEN phase.
2026-04-27 23:36:55 +02:00
6 changed files with 825 additions and 54 deletions

View File

@@ -7,12 +7,50 @@ from GramAddict.core.session_state import SessionState
logger = logging.getLogger(__name__)
# Hard cap: maximum DM replies per inbox visit to prevent spam.
MAX_REPLIES_PER_INBOX_VISIT = 3
# Sentinel values that indicate missing message context.
_EMPTY_CONTEXT_SENTINELS = frozenset({"no previous context", "", "none", "n/a"})
# Structural resource-IDs that indicate a real "Send" button.
_SEND_BUTTON_MARKERS = frozenset({"send_button", "row_thread_composer_send"})
def _is_send_button(node: dict) -> bool:
"""Structural verification: returns True only if the node is a real Send button."""
attribs = node.get("original_attribs", {})
rid = attribs.get("resource-id", "")
desc = attribs.get("content-desc", node.get("desc", "")).lower()
# Accept if resource-id contains a known send button marker
if any(marker in rid for marker in _SEND_BUTTON_MARKERS):
return True
# Accept if content-desc is exactly "Send" (Instagram's canonical label)
if desc == "send":
return True
return False
def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_state, current_target, cognitive_stack):
"""
Executes the autonomous Direct Messaging logic in the Zero-Latency architecture.
Assumes the bot is already at the "MessageInbox" UI state.
Safety guarantees:
- Refuses to execute if dm_reply plugin is disabled in config.
- Skips threads with no extractable text context.
- Structurally verifies the Send button before logging success.
- Hard-caps replies per inbox visit to MAX_REPLIES_PER_INBOX_VISIT.
"""
# ── Kill-Switch: Respect dm_reply.enabled config ──
dm_plugin_config = configs.get_plugin_config("dm_reply")
if not dm_plugin_config.get("enabled", False):
logger.warning(
"🛑 [DM Engine] dm_reply plugin is DISABLED in config. Refusing to process inbox.",
extra={"color": f"{Fore.RED}"},
)
return "BOREDOM_CHANGE_FEED"
logger.info(
f"🧠 [DM Engine] Initiating inbox processing in {current_target}...",
extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"},
@@ -30,6 +68,7 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
session_state.totalMessages = 0
failed_attempts = 0
replies_this_visit = 0
while not dopamine.is_app_session_over():
# Limits check
@@ -92,54 +131,80 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
logger.debug(f"Last received message context: {context_text}")
# Verify we aren't at limits before sending
if not getattr(configs.args, "disable_ai_messaging", False):
# Configure models
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
# Generate response
prompt = f"You are replying to a direct message on Instagram. The last message you received was: '{context_text}'. Keep it short, casual, and friendly. Do not use hashtags."
response_dict = query_llm(
url=url,
model=model,
prompt=prompt,
format_json=False,
timeout=120,
max_tokens=100,
temperature=0.7,
# ── Context Guard: Skip threads with no extractable message ──
if context_text.strip().lower() in _EMPTY_CONTEXT_SENTINELS:
logger.warning(
"⏭️ [DM Engine] Thread has no extractable message context (story reply / media-only). Skipping."
)
device.press("back")
sleep(1.5)
continue
if response_dict and "response" in response_dict:
response_text = response_dict["response"].strip()
# Find the input field
input_nodes = telepathic._extract_semantic_nodes(
thread_xml, "find the message input text field", threshold=0.7
# Verify we aren't at limits before sending
# ── Iteration Cap: Prevent DM spam ──
if replies_this_visit >= MAX_REPLIES_PER_INBOX_VISIT:
logger.info(
f"🛑 [DM Engine] Reached max replies per inbox visit ({MAX_REPLIES_PER_INBOX_VISIT}). Exiting."
)
device.press("back")
sleep(1.0)
return "BOREDOM_CHANGE_FEED"
# Configure models
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
# Generate response
prompt = f"You are replying to a direct message on Instagram. The last message you received was: '{context_text}'. Keep it short, casual, and friendly. Do not use hashtags."
response_dict = query_llm(
url=url,
model=model,
prompt=prompt,
format_json=False,
timeout=120,
max_tokens=100,
temperature=0.7,
)
if response_dict and "response" in response_dict:
response_text = response_dict["response"].strip()
# Find the input field
input_nodes = telepathic._extract_semantic_nodes(
thread_xml, "find the message input text field", threshold=0.7
)
if input_nodes and not input_nodes[0].get("skip"):
in_node = input_nodes[0]
_humanized_click(device, in_node["x"], in_node["y"])
sleep(1.0)
# Type the message
ghost_type(device, response_text, speed="fast")
sleep(1.0)
# Find Send button
send_xml = device.dump_hierarchy()
send_nodes = telepathic._extract_semantic_nodes(
send_xml, "find the send message button", threshold=0.8
)
if input_nodes and not input_nodes[0].get("skip"):
in_node = input_nodes[0]
_humanized_click(device, in_node["x"], in_node["y"])
sleep(1.0)
# Type the message
ghost_type(device, response_text, speed="fast")
sleep(1.0)
if send_nodes and not send_nodes[0].get("skip"):
s_node = send_nodes[0]
# Find Send button
send_xml = device.dump_hierarchy()
send_nodes = telepathic._extract_semantic_nodes(
send_xml, "find the send message button", threshold=0.8
)
if send_nodes and not send_nodes[0].get("skip"):
s_node = send_nodes[0]
# ── Send Button Structural Verification ──
if not _is_send_button(s_node):
s_rid = s_node.get("original_attribs", {}).get("resource-id", "unknown")
logger.warning(
f"⚠️ [DM Engine] Refused to click non-Send element: {s_rid}. Aborting reply."
)
else:
_humanized_click(device, s_node["x"], s_node["y"])
logger.info(
"✅ [DM Engine] Successfully sent a generated reply.", extra={"color": Fore.GREEN}
"✅ [DM Engine] Successfully sent a generated reply.",
extra={"color": Fore.GREEN},
)
session_state.totalMessages += 1
replies_this_visit += 1
dm_memory = cognitive_stack.get("dm_memory")
if dm_memory:
dm_memory.log_sent_dm("unknown_target", response_text, "", [])

View File

@@ -29,11 +29,13 @@ def ask_brain_for_action(
prompt += f"Context: {context}\n"
prompt += (
"CRITICAL INSTRUCTIONS:\n"
"1. If your goal requires an element (like 'following list') that you previously tried but failed to find, it is highly likely hidden off-screen.\n"
"2. If you haven't scrolled yet, you MUST choose to 'scroll down' or 'scroll up' to reveal more of the screen.\n"
"3. Only choose 'press back' or 'tap home tab' if you are completely trapped or in the wrong section entirely.\n"
"4. DO NOT hallucinate actions. Reply ONLY with the exact string from the available actions list."
"INSTRUCTIONS:\n"
"1. Reason about where you are. Consider the screen type and what actions make sense on that screen.\n"
"2. If the goal requires navigating away from the current screen, choose the action that moves you closest to the goal.\n"
"3. 'scroll down' reveals more UI elements on scrollable screens (feeds, profiles, lists). Some screens like stories or modals are NOT scrollable.\n"
"4. 'press back' exits the current screen and returns to the previous one. Use it when you are on a screen that doesn't lead to your goal.\n"
"5. DO NOT hallucinate actions. Reply ONLY with the exact string from the available actions list.\n"
"6. Reply with ONLY the action string, nothing else."
)
try:

View File

@@ -195,6 +195,16 @@ class ScreenIdentity:
if "row_feed_button_like" in ids and "row_feed_photo_profile_name" in ids and not selected_tab:
return ScreenType.POST_DETAIL
# Story view structural markers — present in full-screen story viewer.
# Stories hide the navigation tab bar, so selected_tab is always None.
# Must be checked BEFORE tab-based fallbacks to prevent UNKNOWN classification.
STORY_MARKERS = ("reel_viewer_media_layout", "reel_viewer_header", "reel_viewer_progress_bar")
if any(marker in ids for marker in STORY_MARKERS):
return ScreenType.STORY_VIEW
# Fallback: content-desc "Like Story" or "Send story" confirms story context
if "like story" in desc_lower or "send story" in desc_lower:
return ScreenType.STORY_VIEW
if selected_tab == "feed_tab":
return ScreenType.HOME_FEED
if selected_tab == "clips_tab":

View File

@@ -1,15 +1,489 @@
"""
🔴 RED Phase — DM Engine Integrity Tests
==========================================
These tests expose 4 critical production bugs discovered in run 0f1475ff:
1. DM Engine ignores `dm_reply.enabled: false` — fires DMs anyway
2. DM Engine logs "Successfully sent" without verifying actual send
3. DM Engine generates replies with "No previous context" → garbage output
4. DM Engine lacks max-iteration guard → sent 8 DMs in 2 minutes
Each test MUST fail before any production code is touched (TDD RED).
"""
import types
from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.skip(
reason="Previous tests were 'mock-theater' using hierarchy iterators instead of real visual validation. Needs rewrite."
)
def test_e2e_dm_full_flow_success_real():
pass
# ═══════════════════════════════════════════════════════
# Helpers — Minimal realistic mocks (no lying)
# ═══════════════════════════════════════════════════════
@pytest.mark.skip(
reason="Previous tests were 'mock-theater' using hierarchy iterators instead of real visual validation. Needs rewrite."
)
def test_e2e_dm_no_messages_real():
pass
def _make_dm_inbox_xml():
"""Real-world DM inbox XML with unread thread markers."""
return """<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node resource-id="com.instagram.android:id/direct_inbox_action_bar" />
<node resource-id="com.instagram.android:id/inbox_refreshable_thread_list_recyclerview">
<node text="johndoe" content-desc="Unread. johndoe" />
<node text="janedoe" content-desc="janedoe" />
</node>
</hierarchy>"""
def _make_dm_thread_xml(last_message="Hey what's up?"):
"""Real-world DM thread XML with message content."""
return f"""<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node resource-id="com.instagram.android:id/direct_thread_header">
<node text="johndoe" />
</node>
<node resource-id="com.instagram.android:id/row_thread_composer_edittext"
text="Message…" />
<node text="{last_message}"
resource-id="com.instagram.android:id/message_text" />
<node resource-id="com.instagram.android:id/row_thread_composer_send_button"
content-desc="Send" />
</hierarchy>"""
def _make_dm_thread_xml_no_context():
"""DM thread XML with a story reply — NO extractable text message."""
return """<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node resource-id="com.instagram.android:id/direct_thread_header">
<node text="story_user" />
</node>
<node resource-id="com.instagram.android:id/row_thread_composer_edittext"
text="Message…" />
<node resource-id="com.instagram.android:id/story_reply_media_container"
content-desc="Replied to their story" />
<node resource-id="com.instagram.android:id/row_thread_composer_send_button"
content-desc="Send" />
</hierarchy>"""
def _make_configs(dm_reply_enabled=False):
"""Create a realistic Config mock that mirrors get_plugin_config behavior."""
configs = MagicMock()
configs.get_plugin_config.return_value = {"enabled": dm_reply_enabled}
configs.args = types.SimpleNamespace(
disable_ai_messaging=False,
ai_condenser_model="qwen3.5:latest",
ai_condenser_url="http://localhost:11434/api/generate",
)
return configs
def _make_session_state():
session = MagicMock()
session.totalMessages = 0
session.check_limit.return_value = (False,)
return session
def _make_dopamine(boredom_sequence=None):
"""Dopamine engine that exits after N iterations."""
dopamine = MagicMock()
if boredom_sequence is None:
# Default: 3 iterations then session over
call_count = {"n": 0}
def _is_over():
call_count["n"] += 1
return call_count["n"] > 3
dopamine.is_app_session_over.side_effect = _is_over
else:
dopamine.is_app_session_over.side_effect = boredom_sequence
dopamine.boredom = 0.0
dopamine.wants_to_change_feed.return_value = False
return dopamine
def _make_telepathic(unread_nodes=None, msg_nodes=None, input_nodes=None, send_nodes=None):
"""Telepathic engine returning controlled semantic nodes."""
telepathic = MagicMock()
default_unread = [{"x": 500, "y": 300, "text": "johndoe", "skip": False}]
default_msg = [{"x": 500, "y": 600, "text": "Hey what's up?", "skip": False}]
default_input = [{"x": 500, "y": 900, "text": "Message…", "skip": False}]
default_send = [{"x": 800, "y": 900, "text": "", "desc": "Send", "skip": False}]
def _extract(xml, intent, threshold=0.7):
if "unread" in intent.lower():
return unread_nodes if unread_nodes is not None else default_unread
elif "last received" in intent.lower():
return msg_nodes if msg_nodes is not None else default_msg
elif "input" in intent.lower():
return input_nodes if input_nodes is not None else default_input
elif "send" in intent.lower():
return send_nodes if send_nodes is not None else default_send
return []
telepathic._extract_semantic_nodes.side_effect = _extract
return telepathic
# ═══════════════════════════════════════════════════════
# Test 1: DM Engine MUST respect dm_reply.enabled config
# ═══════════════════════════════════════════════════════
class TestDMConfigGating:
"""Verifies that dm_reply.enabled=false prevents ALL DM interactions."""
def test_dm_engine_blocks_when_dm_reply_disabled(self):
"""BUG: dm_engine.py:96 checks 'disable_ai_messaging' (doesn't exist)
instead of dm_reply.enabled from config. This means DMs fire even when
config says enabled: false.
EXPECTED: DM engine should refuse to send any messages when dm_reply
is disabled in the config.
"""
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
device = MagicMock()
device.dump_hierarchy.return_value = _make_dm_inbox_xml()
configs = _make_configs(dm_reply_enabled=False)
session_state = _make_session_state()
dopamine = _make_dopamine(boredom_sequence=[False, True])
telepathic = _make_telepathic()
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm, \
patch("GramAddict.core.stealth_typing.ghost_type") as mock_type, \
patch("GramAddict.core.bot_flow._humanized_click"), \
patch("GramAddict.core.bot_flow.sleep"):
_run_zero_latency_dm_loop(
device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
)
# The LLM should NEVER be called when dm_reply is disabled
mock_llm.assert_not_called()
# Ghost typing should NEVER happen
mock_type.assert_not_called()
# No messages should be counted
assert session_state.totalMessages == 0, (
f"DM Engine sent {session_state.totalMessages} messages with dm_reply DISABLED!"
)
# ═══════════════════════════════════════════════════════
# Test 2: DM Engine MUST verify send actually happened
# ═══════════════════════════════════════════════════════
class TestDMSendVerification:
"""Verifies that 'Successfully sent' is only logged when the message was actually sent."""
def test_dm_engine_rejects_click_on_wrong_element(self):
"""BUG: dm_engine.py:138 logs success after clicking ANY element the
VLM returns — including 'Unflag', reaction containers, or input fields
themselves. There is ZERO structural verification.
Evidence from logs:
- Clicked 'message_reactions_pill_container' → logged success
- Clicked 'Unflag' button → logged success
- Clicked 'row_thread_composer_edittext' → logged success (clicked the INPUT not send!)
EXPECTED: DM engine must verify the clicked element is actually
a "Send" button (desc='Send' or id contains 'send_button').
"""
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
device = MagicMock()
inbox_xml = _make_dm_inbox_xml()
thread_xml = _make_dm_thread_xml()
# Flow: inbox → thread → send_xml (re-dump) → back → check_xml → inbox (no unread)
device.dump_hierarchy.side_effect = [
inbox_xml, # 1. inbox: find unread
thread_xml, # 2. thread: read messages
thread_xml, # 3. after typing: re-dump for send button
thread_xml, # 4. check_xml after pressing back (still in thread?)
inbox_xml, # 5. inbox again on re-loop
]
configs = _make_configs(dm_reply_enabled=True)
session_state = _make_session_state()
# Dopamine: never session-over, but wants_to_change_feed after boredom bump
dopamine = MagicMock()
dopamine.is_app_session_over.return_value = False
dopamine.boredom = 0.0
dopamine.wants_to_change_feed.side_effect = lambda: dopamine.boredom >= 4.0
# Telepathic returns WRONG element for "send button" — the reactions container
wrong_send_node = [{"x": 500, "y": 800, "text": "", "desc": "", "skip": False,
"original_attribs": {"resource-id": "com.instagram.android:id/message_reactions_pill_container"}}]
# On second unread call, return no threads (inbox clear)
unread_call_n = {"n": 0}
def _extract_nodes(xml, intent, threshold=0.7):
if "unread" in intent.lower():
unread_call_n["n"] += 1
if unread_call_n["n"] == 1:
return [{"x": 500, "y": 300, "text": "johndoe", "skip": False}]
return []
elif "last received" in intent.lower():
return [{"x": 500, "y": 600, "text": "Hey what's up?", "skip": False}]
elif "input" in intent.lower():
return [{"x": 500, "y": 900, "text": "Message…", "skip": False}]
elif "send" in intent.lower():
return wrong_send_node
return []
telepathic = MagicMock()
telepathic._extract_semantic_nodes.side_effect = _extract_nodes
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
with patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "Hey! Nice to meet you!"}), \
patch("GramAddict.core.stealth_typing.ghost_type"), \
patch("GramAddict.core.bot_flow._humanized_click"), \
patch("GramAddict.core.bot_flow.sleep"):
_run_zero_latency_dm_loop(
device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
)
# Should NOT count as a successful message
assert session_state.totalMessages == 0, (
f"DM Engine counted {session_state.totalMessages} messages after clicking "
f"'message_reactions_pill_container' instead of the Send button!"
)
# ═══════════════════════════════════════════════════════
# Test 3: DM Engine MUST NOT reply to context-less threads
# ═══════════════════════════════════════════════════════
class TestDMContextRequirement:
"""Verifies that the DM engine refuses to generate replies without context."""
def test_dm_engine_skips_thread_with_no_extractable_message(self):
"""BUG: dm_engine.py:89-93 sets context_text='No previous context'
when no message text is found (story replies, media-only threads).
Then proceeds to call the LLM with that string, producing garbage
like 'the to the'.
Evidence from logs:
7 out of 8 threads had 'Last received message context: No previous context'
All 7 were blindly replied to anyway.
EXPECTED: When context_text is 'No previous context' or empty,
the DM engine must SKIP the thread entirely (press back, continue).
"""
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
device = MagicMock()
# Flow: inbox → click unread → thread (no context) → back → continue →
# inbox (same, but telepathic returns no unread) → boredom exit
inbox_xml = _make_dm_inbox_xml()
device.dump_hierarchy.side_effect = [
inbox_xml, # 1. inbox: find unread
_make_dm_thread_xml_no_context(), # 2. thread: read messages (no text)
# after context-skip continue, back to loop:
inbox_xml, # 3. inbox again (check is_inbox)
# 4. check_xml after pressing back from thread (dm_engine L152)
]
configs = _make_configs(dm_reply_enabled=True)
session_state = _make_session_state()
# 1st call: not over (process first thread)
# 2nd call: not over (after context skip, re-loop)
# 3rd+ calls: not needed because boredom triggers exit
dopamine = MagicMock()
dopamine.is_app_session_over.return_value = False
dopamine.boredom = 0.0
# After inbox_clear, boredom jumps to 50 → wants_to_change_feed
# should return True on second check (after inbox clear)
change_feed_calls = {"n": 0}
def _wants_change():
change_feed_calls["n"] += 1
# After any boredom bump, signal exit
return dopamine.boredom >= 40.0
dopamine.wants_to_change_feed.side_effect = _wants_change
# No extractable text from thread
no_text_msg_nodes = [{"x": 500, "y": 600, "text": "", "skip": False}]
# On the second inbox visit, return NO unread threads (inbox clear)
call_count = {"n": 0}
def _extract_nodes(xml, intent, threshold=0.7):
if "unread" in intent.lower():
call_count["n"] += 1
if call_count["n"] == 1:
return [{"x": 500, "y": 300, "text": "johndoe", "skip": False}]
# Second time: no unread
return []
elif "last received" in intent.lower():
return no_text_msg_nodes
return []
telepathic = MagicMock()
telepathic._extract_semantic_nodes.side_effect = _extract_nodes
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
with patch("GramAddict.core.llm_provider.query_llm") as mock_llm, \
patch("GramAddict.core.stealth_typing.ghost_type") as mock_type, \
patch("GramAddict.core.bot_flow._humanized_click"), \
patch("GramAddict.core.bot_flow.sleep"):
_run_zero_latency_dm_loop(
device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
)
# LLM should NOT be called for a context-less thread
mock_llm.assert_not_called()
mock_type.assert_not_called()
assert session_state.totalMessages == 0, (
f"DM Engine replied to {session_state.totalMessages} threads with NO message context!"
)
# ═══════════════════════════════════════════════════════
# Test 4: DM Engine MUST have max-iteration guard
# ═══════════════════════════════════════════════════════
class TestDMIterationLimit:
"""Verifies the DM engine doesn't spam infinite replies."""
def test_dm_engine_caps_replies_per_session(self):
"""BUG: dm_engine.py:34 while loop only exits on session timeout or
boredom. With 'aggressive_growth' strategy, boredom increments are
tiny (5-15 per DM) and the engine sent 8 DMs in 2 minutes.
EXPECTED: DM engine must have an explicit max_replies_per_inbox
cap (e.g., 3) to prevent spam behavior. After reaching the cap,
it should return 'BOREDOM_CHANGE_FEED'.
"""
from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
device = MagicMock()
# Infinite supply of "unread" threads
device.dump_hierarchy.return_value = _make_dm_inbox_xml()
configs = _make_configs(dm_reply_enabled=True)
session_state = _make_session_state()
# Dopamine never gets bored (simulates aggressive_growth with low boredom)
dopamine = MagicMock()
dopamine.is_app_session_over.return_value = False
dopamine.wants_to_change_feed.return_value = False
dopamine.boredom = 0.0
telepathic = _make_telepathic()
cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
send_count = {"n": 0}
original_check_limit = session_state.check_limit
def _counting_check(*args, **kwargs):
if send_count["n"] > 20:
pytest.fail(
f"DM Engine sent {send_count['n']} messages without hitting any cap! "
f"Expected a hard limit of <= 5 replies per inbox visit."
)
return (False,)
session_state.check_limit.side_effect = _counting_check
with patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "Hey!"}), \
patch("GramAddict.core.stealth_typing.ghost_type"), \
patch("GramAddict.core.bot_flow._humanized_click"), \
patch("GramAddict.core.bot_flow.sleep"):
# Monkey-patch totalMessages tracking
original_total = 0
class CountingProxy:
def __init__(self):
self._val = 0
def __iadd__(self, other):
self._val += other
send_count["n"] = self._val
if self._val > 20:
pytest.fail(
f"DM Engine sent {self._val} messages! No iteration guard present."
)
return self
def __int__(self):
return self._val
# Force the session to never hit limits (simulating the real scenario)
result = _run_zero_latency_dm_loop(
device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
)
# The engine should have self-limited to at most 5 replies
assert session_state.totalMessages <= 5, (
f"DM Engine sent {session_state.totalMessages} messages in one inbox visit. "
f"Expected hard cap of <= 5 to prevent spam."
)
# ═══════════════════════════════════════════════════════
# Test 5: Bot Flow MUST NOT route to DM Engine when disabled
# ═══════════════════════════════════════════════════════
class TestBotFlowDMGating:
"""Verifies that bot_flow.py never calls _run_zero_latency_dm_loop
when dm_reply is disabled — even if SocialReciprocity desire fires."""
def test_social_reciprocity_never_includes_message_inbox_when_disabled(self):
"""The target_map for SocialReciprocity should NEVER contain
'MessageInbox' when dm_reply.enabled is false.
This is a defense-in-depth test: even if GrowthBrain randomly
selects SocialReciprocity 100% of the time, MessageInbox must
not appear as an option.
"""
configs = _make_configs(dm_reply_enabled=False)
# Simulate bot_flow.py target_map construction (lines 460-468)
target_map = {
"DiscoverNewContent": ["ExploreFeed", "ReelsFeed"],
"NurtureCommunity": ["HomeFeed", "StoriesFeed"],
"SocialReciprocity": ["FollowingList"],
}
dm_config = configs.get_plugin_config("dm_reply")
if dm_config.get("enabled", False):
target_map["SocialReciprocity"].append("MessageInbox")
assert "MessageInbox" not in target_map["SocialReciprocity"], (
"MessageInbox was added to SocialReciprocity targets despite dm_reply.enabled=false!"
)
def test_social_reciprocity_includes_message_inbox_when_enabled(self):
"""Positive test: When dm_reply.enabled is true, MessageInbox
SHOULD be in the target map."""
configs = _make_configs(dm_reply_enabled=True)
target_map = {
"DiscoverNewContent": ["ExploreFeed", "ReelsFeed"],
"NurtureCommunity": ["HomeFeed", "StoriesFeed"],
"SocialReciprocity": ["FollowingList"],
}
dm_config = configs.get_plugin_config("dm_reply")
if dm_config.get("enabled", False):
target_map["SocialReciprocity"].append("MessageInbox")
assert "MessageInbox" in target_map["SocialReciprocity"], (
"MessageInbox should be in SocialReciprocity when dm_reply is enabled!"
)

View File

@@ -293,3 +293,74 @@ class TestSAERealFixturePerception:
@pytest.mark.skip(reason="Lying mock tests removed: Using StatefulMockDevice with string transitions is theater.")
def test_perception_mock_theater_purged():
pass
# ─────────────────────────────────────────────────────
# STORY VIEW DETECTION TESTS (Phase 4)
# Exposes critical gap: Story screens were classified as UNKNOWN,
# causing GOAP to scroll blindly instead of pressing back.
# ─────────────────────────────────────────────────────
class TestStoryViewDetection:
"""Story views MUST be structurally detected — no LLM fallback needed.
Bug evidence from run 2026-04-27_23-46-57:
- Bot started on a Story screen (reel_viewer_media_layout, Like Story button)
- ScreenIdentity returned UNKNOWN
- GOAP chose 'scroll down' 4 times instead of 'press back'
- Bot was trapped in an infinite scroll loop on a story
"""
def test_screen_identity_classifies_story_as_story_view(self):
"""ScreenIdentity must detect reel_viewer_* markers as STORY_VIEW."""
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
si = ScreenIdentity(bot_username="marisaundmarc")
xml = _load_fixture("story_view_full.xml")
result = si.identify(xml)
assert result["screen_type"] == ScreenType.STORY_VIEW, (
f"Story view misclassified as {result['screen_type']}! "
f"Expected STORY_VIEW but ScreenIdentity returned {result['screen_type'].name}."
)
def test_sae_perceive_story_as_normal(self):
"""SAE must classify Story views as NORMAL (it's Instagram, not an obstacle).
The bot's reaction to a Story should be: press back → navigate away.
But first, SAE must NOT flag it as an obstacle.
"""
device = make_mock_device()
sae = SituationalAwarenessEngine(device)
xml = _load_fixture("story_view_full.xml")
result = sae.perceive(xml)
assert result == SituationType.NORMAL, f"Story view misclassified as {result}"
def test_story_view_available_actions_include_press_back(self):
"""On a story, 'press back' must be in available actions and 'scroll down' should NOT
be a meaningful action (stories don't scroll, they swipe)."""
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
si = ScreenIdentity(bot_username="marisaundmarc")
xml = _load_fixture("story_view_full.xml")
result = si.identify(xml)
assert "press back" in result["available_actions"], (
"'press back' must be available on Story views!"
)
def test_story_view_has_no_navigation_tabs(self):
"""Stories hide the navigation bar. The available actions must NOT
include tab navigation (tap home tab, tap explore tab, etc.)."""
from GramAddict.core.perception.screen_identity import ScreenIdentity
si = ScreenIdentity(bot_username="marisaundmarc")
xml = _load_fixture("story_view_full.xml")
result = si.identify(xml)
tab_actions = [a for a in result["available_actions"] if "tap" in a and "tab" in a]
assert len(tab_actions) == 0, (
f"Story view should have NO tab navigation, but found: {tab_actions}"
)

149
tests/fixtures/story_view_full.xml vendored Normal file
View File

@@ -0,0 +1,149 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="0" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/action_bar_root" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/layout_container_parent" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,2424]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/layout_container_main" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,2361]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_root" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,2361]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/view_pager" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,2361]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.Button" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,2361]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_content_layout" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,2361]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.View" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,223]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="" resource-id="com.instagram.android:id/reel_view_group" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2361]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_item_toolbar_container" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2311]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,2111][1080,2143]" drawing-order="6" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,2111][1080,2143]" drawing-order="1" hint="" display-id="0" />
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_item_toolbar_footer_container" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,2143][1080,2311]" drawing-order="8" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,2143][1080,2311]" drawing-order="1" hint="" display-id="0">
<node index="1" text="" resource-id="com.instagram.android:id/viewer_reel_item_toolbar_container" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,2143][1080,2311]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/message_composer_container" class="android.widget.Button" package="com.instagram.android" content-desc="Send message or reaction" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[21,2164][723,2290]" drawing-order="5" hint="" display-id="0">
<node index="0" text="Send message" resource-id="com.instagram.android:id/composer_text" class="android.widget.TextView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[21,2164][356,2290]" drawing-order="1" hint="" display-id="0" />
</node>
<node index="1" text="" resource-id="com.instagram.android:id/toolbar_buttons_container" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[744,2164][1059,2290]" drawing-order="6" hint="" display-id="0">
<node NAF="true" index="0" text="" resource-id="com.instagram.android:id/viewer_toolbar_browse_button" class="android.widget.ImageView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[744,2174][849,2279]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="" resource-id="com.instagram.android:id/toolbar_like_container" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[849,2174][954,2279]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/toolbar_like_button" class="android.widget.ImageView" package="com.instagram.android" content-desc="Like Story" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[849,2174][954,2279]" drawing-order="1" hint="" display-id="0" />
</node>
<node index="2" text="" resource-id="com.instagram.android:id/toolbar_reshare_button" class="android.widget.ImageView" package="com.instagram.android" content-desc="Send story" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[954,2174][1059,2279]" drawing-order="5" hint="" display-id="0" />
</node>
</node>
</node>
</node>
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_viewer_media_layout" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2143]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_sticker_overlay_container" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2143]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_avatar_accessibility_sticker_container" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2143]" drawing-order="1" hint="" display-id="0" />
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_viewer_media_container" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2143]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.TextureView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,2143]" drawing-order="1" hint="" display-id="0" />
</node>
<node index="2" text="" resource-id="com.instagram.android:id/reel_viewer_top_shadow" class="android.view.View" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,486]" drawing-order="4" hint="" display-id="0" />
<node index="3" text="" resource-id="com.instagram.android:id/reel_viewer_header_container" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,223][1080,411]" drawing-order="6" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_progress_bar" class="android.view.View" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,244][1080,248]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="" resource-id="com.instagram.android:id/reel_viewer_header" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,248][1080,411]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/profile_picture_container" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[32,272][116,408]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_profile_picture" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[32,272][116,356]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="Profile picture" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[32,272][116,356]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_front_avatar" class="android.widget.ImageView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[32,272][116,356]" drawing-order="2" hint="" display-id="0" />
</node>
</node>
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_viewer_text_container" class="android.widget.Button" package="com.instagram.android" content-desc="Highlight title Events, story 2 of 25, 29 May 2025" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[116,269][746,411]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[116,269][522,313]" drawing-order="1" hint="" display-id="0">
<node index="0" text="Events" resource-id="com.instagram.android:id/reel_viewer_title" class="android.widget.TextView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[116,269][282,313]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="29 May 2025" resource-id="com.instagram.android:id/reel_viewer_timestamp" class="android.widget.TextView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[282,269][522,313]" drawing-order="2" hint="" display-id="0" />
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_viewer_attribution_frame_layout" class="android.widget.FrameLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[116,313][439,411]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.Button" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[148,313][439,359]" drawing-order="6" hint="" display-id="0">
<node index="0" text="" resource-id="com.instagram.android:id/reel_app_attribution_icon" class="android.widget.ImageView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[148,324][180,356]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="Watch full reel" resource-id="com.instagram.android:id/reel_app_attribution_action_text" class="android.widget.TextView" package="com.instagram.android" content-desc="Watch full reel" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[196,321][407,359]" drawing-order="2" hint="" display-id="0" />
</node>
</node>
</node>
<node index="2" text="" resource-id="com.instagram.android:id/reel_header_extras_container" class="android.widget.LinearLayout" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[746,248][1080,374]" drawing-order="3" hint="" display-id="0">
<node index="0" text="Follow" resource-id="com.instagram.android:id/reel_header_unconnected_follow_button_stub" class="android.widget.TextView" package="com.instagram.android" content-desc="Follow Mission Green Energy" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="true" password="false" selected="false" visible-to-user="true" bounds="[778,269][975,353]" drawing-order="3" hint="" display-id="0" />
<node index="1" text="" resource-id="com.instagram.android:id/header_menu_button" class="android.widget.ImageView" package="com.instagram.android" content-desc="More actions" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[954,248][1080,374]" drawing-order="11" hint="" display-id="0" />
</node>
</node>
</node>
<node index="4" text="" resource-id="com.instagram.android:id/reel_viewer_bottom_shadow" class="android.view.View" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,1880][1080,2143]" drawing-order="11" hint="" display-id="0" />
</node>
</node>
</node>
</node>
</node>
<node index="1" text="" resource-id="com.instagram.android:id/reel_volume_indicator_litho" class="android.view.ViewGroup" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,173][1080,215]" drawing-order="8" hint="" display-id="0" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="0" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_launch_animation_container" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="" resource-id="com.android.systemui:id/container" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="3" hint="" display-id="0" />
<node index="2" text="" resource-id="com.android.systemui:id/status_bar_container" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="" class="androidx.compose.ui.platform.ComposeView" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="0" hint="" display-id="0">
<node index="0" text="" resource-id="" class="androidx.compose.ui.viewinterop.ViewFactoryHolder" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,0][1080,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_contents" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[70,0][1010,173]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_start_side_container" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[81,3][485,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_start_side_content" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[81,3][434,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_start_side_except_heads_up" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[81,3][434,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="23:46" resource-id="com.android.systemui:id/clock" class="android.widget.TextView" package="com.android.systemui" content-desc="23:46" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[81,59][202,117]" drawing-order="2" hint="" display-id="0" />
<node index="1" text="" resource-id="com.android.systemui:id/notification_icon_area" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[202,3][434,173]" drawing-order="6" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/notificationIcons" class="android.view.ViewGroup" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[202,3][434,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.ImageView" package="com.android.systemui" content-desc="Android System notification: " checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[202,3][260,173]" drawing-order="1" hint="" display-id="0" />
<node index="1" text="" resource-id="" class="android.widget.ImageView" package="com.android.systemui" content-desc="Photos notification: " checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[260,3][318,173]" drawing-order="2" hint="" display-id="0" />
<node index="2" text="" resource-id="" class="android.widget.ImageView" package="com.android.systemui" content-desc="Gotify notification: " checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[318,3][376,173]" drawing-order="3" hint="" display-id="0" />
<node index="3" text="" resource-id="" class="android.widget.ImageView" package="com.android.systemui" content-desc="Amazon Photos notification: " checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[376,3][434,173]" drawing-order="4" hint="" display-id="0" />
</node>
</node>
</node>
</node>
</node>
<node index="1" text="" resource-id="com.android.systemui:id/cutout_space_view" class="android.view.View" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[485,3][595,173]" drawing-order="2" hint="" display-id="0" />
<node index="2" text="" resource-id="com.android.systemui:id/status_bar_end_side_container" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[595,3][999,173]" drawing-order="3" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/status_bar_end_side_content" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[782,3][999,173]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/system_icons" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[782,59][999,117]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/statusIcons" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[790,59][918,117]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/mobile_combo" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="Telekom.de, three bars." checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[790,59][851,117]" drawing-order="17" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/mobile_group" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[798,59][843,117]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[798,72][843,104]" drawing-order="4" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/mobile_signal" class="android.widget.ImageView" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[798,72][843,104]" drawing-order="1" hint="" display-id="0" />
</node>
</node>
</node>
<node index="3" text="" resource-id="com.android.systemui:id/wifi_combo" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[851,59][910,117]" drawing-order="18" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/wifi_group" class="android.widget.LinearLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[859,59][902,117]" drawing-order="1" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/wifi_combo" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[859,72][902,104]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/wifi_signal" class="android.widget.ImageView" package="com.android.systemui" content-desc="Wi-Fi signal full." checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[859,72][902,104]" drawing-order="1" hint="" display-id="0" />
</node>
</node>
</node>
</node>
<node index="1" text="" resource-id="" class="androidx.compose.ui.platform.ComposeView" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[918,71][988,105]" drawing-order="2" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[918,71][988,105]" drawing-order="0" hint="" display-id="0">
<node index="0" text="" resource-id="com.android.systemui:id/battery" class="android.view.View" package="com.android.systemui" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[918,71][988,105]" drawing-order="0" hint="" display-id="0">
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.systemui" content-desc="Battery 48 per cent." checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[918,71][981,105]" drawing-order="0" hint="" display-id="0" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>