Compare commits
21 Commits
6cd068f951
...
fix/keyboa
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f778d46c9 | |||
| 49f82d467f | |||
| a67072eec4 | |||
| 59ba330029 | |||
| 6f9da50ae2 | |||
| 720841103d | |||
| c98e2caaa1 | |||
| 32731ed7ec | |||
| 8a6c8a2249 | |||
| f384fbb749 | |||
| 565bdaa568 | |||
| c641204a6b | |||
| 4b645c6fb2 | |||
| c7c7ce29f8 | |||
| b36dde77d8 | |||
| 93b2140844 | |||
| 67c3d464e0 | |||
| d298f03891 | |||
| 604f2d7341 | |||
| cd8f35056c | |||
| 800fb1da98 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -31,8 +31,14 @@ Pipfile.lock
|
||||
*.ini
|
||||
*.db
|
||||
|
||||
# Debug artifacts
|
||||
# Debug artifacts & garbage scripts (Rule 5: KRIEG DEM MÜLL)
|
||||
scratch*.py
|
||||
rewrite_*.py
|
||||
test_*.py
|
||||
!tests/**
|
||||
update_*.py
|
||||
profile_dump.*
|
||||
resp_dump.*
|
||||
test_compress.py
|
||||
test_fixtures.py
|
||||
output.txt
|
||||
|
||||
@@ -29,6 +29,15 @@ Found in `sensors/honeypot_radome.py`.
|
||||
- **Ghost Engagement Guard**: Strips DOM nodes explicitly tagged with `visible-to-user="false"` to prevent triggering Accessibility Hooks.
|
||||
- **VLM Sanity Guard**: Woven into `telepathic_engine.py`, it sends semantic matches for destructive actions (Like/Follow) through a Vision Language Model step to prevent executing semantic "Bait and Switch" tricks.
|
||||
|
||||
### 🧠 Situational Awareness Engine (SAE)
|
||||
Found in `situational_awareness.py`. Handles autonomous obstacle detection, recovery, and learning with **ZERO hardcoded UI element identifiers**.
|
||||
- **Autonomous 3-Layer Classification** (zero maintenance — no resource-ids, no button text, no localized strings):
|
||||
1. **Package-Based Foreign App Detection**: If our app's package is absent from the XML hierarchy, it's a foreign app. Uses Android package names (not Instagram UI elements).
|
||||
2. **Qdrant Semantic Cache**: Previously classified screens are instantly recalled from the vector database (O(1) latency). The bot learns from every first-encounter.
|
||||
3. **ScreenIdentity Structural Delegation**: The `ScreenIdentity` module classifies known screen types via its own structural logic. If it identifies a MODAL, the SAE trusts it.
|
||||
4. **LLM Autonomous Classification**: Unknown screens are classified by the LLM (OBSTACLE_MODAL, DANGER_ACTION_BLOCKED, or NORMAL). Results are cached in Qdrant — so each screen type is learned exactly once.
|
||||
- **Zero-Maintenance Guarantee**: When Instagram updates its UI (changes resource-ids, adds new modals), the bot discovers and learns the new patterns autonomously via the LLM. No code changes required.
|
||||
|
||||
### 🦾 Biometric Facade (Gaussian Clicks)
|
||||
Found in `device_facade.py`.
|
||||
- Human touches do not follow a flat mathematical uniform grid. The GramPilot simulates genuine **biometric dispersion** using `random.gauss(mu, sigma)`, strictly centering clicks inside a thumb-bias radius (bottom-left skew for right-handers). In tests, this hits a 68% standard deviation precision.
|
||||
|
||||
@@ -35,7 +35,7 @@ class CloseFriendsGuardPlugin(BehaviorPlugin):
|
||||
return False
|
||||
|
||||
xml = ctx.context_xml if ctx.context_xml else ctx.device.dump_hierarchy()
|
||||
return "enge freunde" in xml.lower() or "close friend" in xml.lower()
|
||||
return "close friend" in xml.lower()
|
||||
|
||||
def execute(self, ctx: BehaviorContext) -> BehaviorResult:
|
||||
logger.info("💚 [CloseFriendsGuard] Close friends post detected. Skipping...")
|
||||
|
||||
@@ -40,7 +40,15 @@ class DarwinDwellPlugin(BehaviorPlugin):
|
||||
logger.info("🐢 [DarwinDwell] Executing organic dwell behaviors...")
|
||||
darwin.execute_micro_wobble(ctx.device)
|
||||
res_score = ctx.shared_state.get("res_score", 1.0)
|
||||
darwin.execute_proof_of_resonance(ctx.device, res_score)
|
||||
darwin.execute_proof_of_resonance(
|
||||
ctx.device,
|
||||
res_score,
|
||||
nav_graph=ctx.cognitive_stack.get("nav_graph"),
|
||||
configs=ctx.configs,
|
||||
resonance_oracle=ctx.cognitive_stack.get("oracle"),
|
||||
username=ctx.username,
|
||||
context_xml=ctx.context_xml or ctx.device.dump_hierarchy(),
|
||||
)
|
||||
else:
|
||||
logger.info("🐢 [DarwinDwell] Darwin engine missing. Falling back to static sleep.")
|
||||
sleep(2.5 * ctx.sleep_mod)
|
||||
|
||||
@@ -29,9 +29,11 @@ class PerfectSnappingPlugin(BehaviorPlugin):
|
||||
if not getattr(self, "_enabled", True):
|
||||
return False
|
||||
|
||||
xml_lower = ctx.context_xml.lower()
|
||||
# Do not snap if we are on a profile page or grid, it's meant for posts.
|
||||
if "profile_tabs_container" in xml_lower or "explore_grid" in xml_lower:
|
||||
# Perfect snapping is only for feed posts.
|
||||
# Do not snap if we are on a profile page, explore grid, or modal.
|
||||
from GramAddict.core.perception.feed_analysis import has_feed_markers
|
||||
|
||||
if not has_feed_markers(ctx.context_xml):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -68,7 +68,7 @@ class ProfileGuardPlugin(BehaviorPlugin):
|
||||
|
||||
# Close friends guard
|
||||
if getattr(ctx.configs.args, "ignore_close_friends", False):
|
||||
if "enge freunde" in xml_check_lower or "close friend" in xml_check_lower:
|
||||
if "close friend" in xml_check_lower:
|
||||
logger.info(
|
||||
f"💚 [Profile Guard] @{ctx.username} is a Close Friend. Ignoring.", extra={"color": "\033[32m"}
|
||||
)
|
||||
|
||||
@@ -50,8 +50,11 @@ class RepostPlugin(BehaviorPlugin):
|
||||
|
||||
nav_graph = QNavGraph(ctx.device)
|
||||
|
||||
if nav_graph.do("share to story"):
|
||||
logger.info(f"📤 [Repost] Shared post by @{ctx.username} to story ✓")
|
||||
return BehaviorResult(executed=True, interactions=1)
|
||||
# We must click the send post button first
|
||||
if nav_graph.do("tap send post button"):
|
||||
# A modal should appear, now click add to story
|
||||
if nav_graph.do("tap add to story"):
|
||||
logger.info(f"📤 [Repost] Shared post by @{ctx.username} to story ✓")
|
||||
return BehaviorResult(executed=True, interactions=1)
|
||||
|
||||
return BehaviorResult(executed=False)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
|
||||
try:
|
||||
import psutil
|
||||
@@ -443,8 +444,8 @@ def start_bot(**kwargs):
|
||||
|
||||
from GramAddict.core.llm_provider import query_llm
|
||||
|
||||
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
|
||||
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
|
||||
model = getattr(configs.args, "ai_condenser_model")
|
||||
url = getattr(configs.args, "ai_condenser_url")
|
||||
|
||||
response_dict = query_llm(url=url, model=model, prompt=prompt, format_json=True, timeout=120)
|
||||
if response_dict and isinstance(response_dict, dict) and "persona" in response_dict:
|
||||
@@ -557,7 +558,9 @@ def start_bot(**kwargs):
|
||||
continue
|
||||
elif current_target == "StoriesFeed":
|
||||
logger.info("📱 Locating story tray on HomeFeed...")
|
||||
nav_graph.do("tap story ring avatar")
|
||||
if not nav_graph.do("tap story ring avatar"):
|
||||
logger.warning("❌ Failed to tap story ring avatar. Retrying next loop.")
|
||||
continue
|
||||
post_loaded = _wait_for_story_loaded(device, timeout=5)
|
||||
if not post_loaded:
|
||||
logger.warning("❌ Stories failed to open from HomeFeed. Retrying next loop.")
|
||||
@@ -682,6 +685,7 @@ def _interact_with_profile(device, configs, username, session_state, sleep_mod,
|
||||
|
||||
if cognitive_stack is None:
|
||||
cognitive_stack = {}
|
||||
_validate_cognitive_stack(cognitive_stack, "ProfileInteraction")
|
||||
|
||||
if hasattr(session_state, "my_username") and username == session_state.my_username:
|
||||
logger.info(f"🤝 [Deep Interaction] Skipping own profile @{username} to prevent self-interactions.")
|
||||
@@ -711,7 +715,7 @@ def _interact_with_profile(device, configs, username, session_state, sleep_mod,
|
||||
return
|
||||
|
||||
if getattr(configs.args, "ignore_close_friends", False):
|
||||
if "enge freunde" in xml_check_lower or "close friend" in xml_check_lower:
|
||||
if "close friend" in xml_check_lower:
|
||||
logger.info(
|
||||
f"💚 [Profile Guard] @{username} is a Close Friend. Ignoring completely.", extra={"color": "\\033[32m"}
|
||||
)
|
||||
@@ -821,6 +825,7 @@ def _run_zero_latency_stories_loop(device, configs, session_state, cognitive_sta
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
_validate_cognitive_stack(cognitive_stack, "StoriesLoop")
|
||||
logger.info("🎬 [StoriesFeed] Starting native story binging loop...", extra={"color": f"{Fore.CYAN}"})
|
||||
|
||||
dopamine = cognitive_stack.get("dopamine")
|
||||
@@ -855,8 +860,22 @@ def _run_zero_latency_stories_loop(device, configs, session_state, cognitive_sta
|
||||
logger.warning("Failed to dump UI hierarchy in StoriesFeed.")
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
# ── Perimeter Guard: Verify we're still inside Instagram ──
|
||||
# Production bug 2026-05-03: A story's swipe-up link opened the Play Store,
|
||||
# and the loop kept tapping blindly on com.android.vending for 5+ iterations.
|
||||
packages = set(re.findall(r'package="([^"]+)"', xml_dump))
|
||||
app_id = getattr(device, "app_id", "com.instagram.android")
|
||||
if packages and app_id not in packages:
|
||||
logger.error(
|
||||
f"🚨 [StoriesFeed] FOREIGN APP DETECTED! Packages: {packages}. "
|
||||
f"A story link likely opened an external app. Aborting loop."
|
||||
)
|
||||
device.press("back")
|
||||
sleep(1.5)
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
if getattr(configs.args, "ignore_close_friends", False):
|
||||
if "enge freunde" in xml_dump.lower() or "close friend" in xml_dump.lower():
|
||||
if "close friend" in xml_dump.lower():
|
||||
logger.info(
|
||||
"💚 [Anti-Friend] Story is from a Close Friend. Swiping horizontally to skip User.",
|
||||
extra={"color": "\\033[32m"},
|
||||
@@ -876,6 +895,36 @@ def _run_zero_latency_stories_loop(device, configs, session_state, cognitive_sta
|
||||
return "FEED_EXHAUSTED"
|
||||
|
||||
|
||||
def _validate_cognitive_stack(cognitive_stack, context_name):
|
||||
"""
|
||||
Validates that the cognitive stack has all required engine dependencies
|
||||
injected properly. This is the ultimate zero-trust guard for plugins.
|
||||
"""
|
||||
if not isinstance(cognitive_stack, dict):
|
||||
raise TypeError(f"[{context_name}] CognitiveStack must be a dict, got {type(cognitive_stack)}")
|
||||
|
||||
required_engines = [
|
||||
"dopamine",
|
||||
"darwin",
|
||||
"resonance",
|
||||
"active_inference",
|
||||
"growth_brain",
|
||||
"swarm",
|
||||
"writer",
|
||||
"nav_graph",
|
||||
"zero_engine",
|
||||
"telepathic",
|
||||
]
|
||||
|
||||
missing = [eng for eng in required_engines if eng not in cognitive_stack or cognitive_stack[eng] is None]
|
||||
if missing:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"🚨 [{context_name}] CognitiveStack missing required engines: {missing}")
|
||||
raise ValueError(f"[{context_name}] CognitiveStack missing required engines: {missing}")
|
||||
|
||||
|
||||
def _run_zero_latency_feed_loop(
|
||||
device, zero_engine, nav_graph, configs, session_state, job_target, cognitive_stack, is_reels=False
|
||||
):
|
||||
@@ -889,6 +938,7 @@ def _run_zero_latency_feed_loop(
|
||||
- Darwin is the SOLE dwell controller → no duplicate sleep calls
|
||||
- SwarmProtocol emits pheromones after successful interactions
|
||||
"""
|
||||
_validate_cognitive_stack(cognitive_stack, "FeedLoop")
|
||||
logger.info(f"🔄 Entering Zero-Latency Interaction Pool. Feed: {job_target}")
|
||||
|
||||
dopamine = cognitive_stack.get("dopamine")
|
||||
@@ -927,7 +977,9 @@ def _run_zero_latency_feed_loop(
|
||||
|
||||
# 🛡️ Structural Guard: Curiosity targets (DMs, Notifications) are ONLY available on HomeFeed.
|
||||
# We must navigate there first, breaking current context.
|
||||
nav_graph.navigate_to("HomeFeed", zero_engine)
|
||||
if not nav_graph.navigate_to("HomeFeed", zero_engine):
|
||||
logger.warning("❌ [Curiosity] Failed to navigate to HomeFeed. Aborting curiosity check.")
|
||||
continue
|
||||
sleep(random.uniform(1.0, 2.5))
|
||||
|
||||
dm_config = configs.get_plugin_config("dm_reply")
|
||||
@@ -966,6 +1018,17 @@ def _run_zero_latency_feed_loop(
|
||||
if cognitive_stack.get("radome"):
|
||||
context_xml = cognitive_stack.get("radome").sanitize_xml(context_xml)
|
||||
|
||||
# ── Perimeter Guard: Verify we're still inside Instagram ──
|
||||
# Parity with story loop guard (production bug 2026-05-03).
|
||||
if context_xml:
|
||||
feed_packages = set(re.findall(r'package="([^"]+)"', context_xml))
|
||||
feed_app_id = getattr(device, "app_id", "com.instagram.android")
|
||||
if feed_packages and feed_app_id not in feed_packages:
|
||||
logger.error(f"🚨 [FeedLoop] FOREIGN APP DETECTED! Packages: {feed_packages}. Aborting loop.")
|
||||
device.press("back")
|
||||
sleep(1.5)
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
# ── Execute Plugin Registry Behaviors (Feed Level) ──
|
||||
from GramAddict.core.behaviors import BehaviorContext, PluginRegistry
|
||||
|
||||
@@ -1025,6 +1088,7 @@ def _run_zero_latency_search_loop(
|
||||
"""
|
||||
Executes the autonomous Search & Interact logic.
|
||||
"""
|
||||
_validate_cognitive_stack(cognitive_stack, "SearchLoop")
|
||||
logger.info("🧠 [Search Engine] Initiating keyword discovery...", extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"})
|
||||
|
||||
import random
|
||||
@@ -1050,6 +1114,16 @@ def _run_zero_latency_search_loop(
|
||||
xml = device.dump_hierarchy()
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
|
||||
# ── Perimeter Guard: Verify we're still inside Instagram ──
|
||||
if xml:
|
||||
search_packages = set(re.findall(r'package="([^"]+)"', xml))
|
||||
search_app_id = getattr(device, "app_id", "com.instagram.android")
|
||||
if search_packages and search_app_id not in search_packages:
|
||||
logger.error(f"🚨 [SearchLoop] FOREIGN APP DETECTED! Packages: {search_packages}. Aborting loop.")
|
||||
device.press("back")
|
||||
sleep(1.5)
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
# Find search bar
|
||||
search_bar = telepathic.find_best_node(xml, "Search edit text box or magnifying glass input", device=device)
|
||||
if search_bar:
|
||||
|
||||
@@ -30,13 +30,13 @@ class VLMCompilerEngine:
|
||||
extra={"color": "\x1b[1m\x1b[35m"},
|
||||
)
|
||||
|
||||
args = getattr(self.device, "args", None)
|
||||
model = getattr(args, "ai_telepathic_model", "llama3.2:1b") if args else "llama3.2:1b"
|
||||
url = (
|
||||
getattr(args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
if args
|
||||
else "http://localhost:11434/api/generate"
|
||||
)
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
cfg = Config()
|
||||
if not hasattr(cfg, "args"):
|
||||
raise RuntimeError("Config().args not initialized — cannot resolve AI model. Fail Fast.")
|
||||
model = getattr(cfg.args, "ai_telepathic_model")
|
||||
url = getattr(cfg.args, "ai_telepathic_url")
|
||||
use_local = "11434" in url or "localhost" in url
|
||||
|
||||
simplified_xml = self._simplify_xml(context_xml)
|
||||
|
||||
@@ -84,7 +84,6 @@ class DarwinEngine(QdrantBase):
|
||||
resonance: float,
|
||||
text_length: int = 0,
|
||||
nav_graph=None,
|
||||
zero_engine=None,
|
||||
configs=None,
|
||||
resonance_oracle=None,
|
||||
username=None,
|
||||
@@ -142,12 +141,22 @@ class DarwinEngine(QdrantBase):
|
||||
cy = h // 2
|
||||
|
||||
dur_ms = int(random.uniform(200, 500))
|
||||
device.shell(f"input swipe {int(cx)} {int(cy)} {int(cx + noise_x)} {int(cy + slip_distance)} {dur_ms}")
|
||||
|
||||
# Use physics-based injector instead of algorithmic 'input swipe'
|
||||
body = PhysicsBody.get_session_instance(device)
|
||||
injector = SendEventInjector.get_instance(device)
|
||||
start_pt = (int(cx), int(cy))
|
||||
end_pt = (int(cx + noise_x), int(cy + slip_distance))
|
||||
|
||||
points = BezierGesture.scroll_curve(start_pt, end_pt, body, n_points=5)
|
||||
timing = BezierGesture.compute_sigmoid_timing(len(points), dur_ms)
|
||||
injector.inject_gesture(points, timing, touch_major=body.get_touch_major())
|
||||
|
||||
time.sleep(random.uniform(0.5, 1.2))
|
||||
|
||||
# 4. Comment depth simulation (probabilistic & resonance-correlated)
|
||||
if profile["comment_read_dwell"] > 1.0 and resonance > 0.4 and random.random() < 0.3:
|
||||
if nav_graph and zero_engine:
|
||||
if nav_graph:
|
||||
if not self._has_comments(context_xml):
|
||||
logger.debug(" -> 🚫 [Darwin Engine] Skipping comment depth simulation (Post has 0 comments).")
|
||||
else:
|
||||
@@ -334,27 +343,34 @@ class DarwinEngine(QdrantBase):
|
||||
"""
|
||||
Heuristic to check if a post actually has comments to read.
|
||||
If it has 0 comments, checking them is suspicious bot behavior.
|
||||
|
||||
Zero-Maintenance: Uses only English text and resource_id patterns.
|
||||
Resource IDs are locale-invariant. English text in content_desc
|
||||
is used by Instagram internally and is reliable.
|
||||
"""
|
||||
low_xml = xml_string.lower()
|
||||
|
||||
# 1. Explicit zero comments checks
|
||||
if re.search(r"\b0\s*kommentare?\b", low_xml) or re.search(r"\b0\s*comment(?:s)?\b", low_xml):
|
||||
# 1. Explicit zero comments check (resource_id based + English fallback)
|
||||
if re.search(r"\b0\s*comment(?:s)?\b", low_xml):
|
||||
return False
|
||||
|
||||
# 2. Check for "view all" or similar prominent comment link texts
|
||||
if "view all" in low_xml or ("alle " in low_xml and "kommentare ansehen" in low_xml):
|
||||
if "view all" in low_xml:
|
||||
return True
|
||||
if "view 1 comment" in low_xml or "1 kommentar ansehen" in low_xml:
|
||||
if "view 1 comment" in low_xml:
|
||||
return True
|
||||
if "comment number is" in low_xml:
|
||||
return True
|
||||
|
||||
# 3. Check for specific counter elements > 0 in content descriptors
|
||||
# e.g. "by username, 23 comments" or "1,234 comments"
|
||||
has_number_of_comments = re.search(r"\b([1-9][0-9.,]*)\s*(?:comment(?:s)?|kommentare?)\b", low_xml)
|
||||
# 3. Structural: comment_textview_layout is present with a count > 0
|
||||
has_number_of_comments = re.search(r"\b([1-9][0-9.,]*)\s*comment(?:s)?\b", low_xml)
|
||||
if has_number_of_comments:
|
||||
return True
|
||||
|
||||
# 4. Structural: The comment button resource_id exists and has content
|
||||
if "row_feed_comment_textview_layout" in low_xml:
|
||||
return True
|
||||
|
||||
# If no indicators are found, assume the post has 0 comments.
|
||||
# The comment button exists, but there are no comments to read.
|
||||
return False
|
||||
|
||||
|
||||
@@ -14,20 +14,23 @@ MAX_REPLIES_PER_INBOX_VISIT = 3
|
||||
_EMPTY_CONTEXT_SENTINELS = frozenset({"no previous context", "", "none", "n/a"})
|
||||
|
||||
|
||||
# Structural resource-IDs that indicate a real "Send" button.
|
||||
def _is_send_button(node: dict) -> bool:
|
||||
"""Semantic verification: returns True if the node is identified as a Send button."""
|
||||
desc = (node.get("description") or node.get("desc", "")).lower()
|
||||
text = (node.get("text") or "").lower()
|
||||
"""
|
||||
Structural verification: returns True if the node is identified as a Send button.
|
||||
NO hardcoded UI strings (no localized 'send' or 'absenden').
|
||||
"""
|
||||
rid = (node.get("id") or node.get("resource_id", "")).lower()
|
||||
|
||||
# Accept if semantic markers indicate sending
|
||||
if any(m in rid for m in ["send", "composer_button"]):
|
||||
# 1. Structural Resource IDs (Language agnostic)
|
||||
if "send" in rid or "composer_button" in rid or "direct_send_button_text" in rid:
|
||||
return True
|
||||
if any(m in desc for m in ["send", "absenden"]):
|
||||
return True
|
||||
if text == "send" or text == "absenden":
|
||||
|
||||
# 2. Spatial Guard: The send button in a DM thread is ALWAYS on the far right side of the screen
|
||||
# next to the composer input.
|
||||
x = node.get("x", 0)
|
||||
if int(x) > (1080 * 0.75):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -152,8 +155,8 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
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")
|
||||
model = getattr(configs.args, "ai_condenser_model")
|
||||
url = getattr(configs.args, "ai_condenser_url")
|
||||
|
||||
# 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."
|
||||
|
||||
@@ -176,11 +176,21 @@ class GoalExecutor:
|
||||
if last_action and last_screen_type:
|
||||
self.action_failures[(last_screen_type, last_action)] = (
|
||||
self.action_failures.get((last_screen_type, last_action), 0) + MAX_RETRIES
|
||||
) # Instantly mask it
|
||||
self.planner.knowledge.learn_trap(last_screen_type, last_action, f"caused_obstacle_{obstacle_name}")
|
||||
logger.warning(
|
||||
f"🛡️ [SAE Feedback] Action '{last_action}' caused an obstacle. Masking aggressively and learned trap."
|
||||
)
|
||||
) # Instantly mask it for this session
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
if ScreenTopology.is_structural_action(last_screen_type, last_action):
|
||||
logger.warning(
|
||||
f"🛡️ [SAE Feedback] Structural action '{last_action}' caused an obstacle. "
|
||||
f"Masking for this session. (Never burned permanently)"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"🛡️ [SAE Feedback] Content action '{last_action}' caused an obstacle. "
|
||||
f"Masking for this session to break loop, but preventing permanent Qdrant poisoning."
|
||||
)
|
||||
# We specifically DO NOT call self.planner.knowledge.learn_trap here anymore!
|
||||
# Burning dynamic actions like "tap follow button" permanently destroys the bot's capabilities across sessions.
|
||||
|
||||
if not self._get_sae().ensure_clear_screen():
|
||||
if screen_type == ScreenType.FOREIGN_APP:
|
||||
@@ -247,34 +257,37 @@ class GoalExecutor:
|
||||
for k in keys_to_clear:
|
||||
del self.action_failures[k]
|
||||
|
||||
# ── Back-Press Circuit Breaker ──
|
||||
# ── Back-Press Circuit Breaker → Escalation ──
|
||||
if action == "press back":
|
||||
consecutive_back_presses += 1
|
||||
if consecutive_back_presses >= MAX_CONSECUTIVE_BACK:
|
||||
logger.error(
|
||||
logger.warning(
|
||||
f"🛑 [GOAP] Back-pressed {MAX_CONSECUTIVE_BACK} times with no screen transition. "
|
||||
f"Aborting goal '{goal}' to prevent app exit."
|
||||
f"Escalating to force restart."
|
||||
)
|
||||
self.path_memory.learn_path(goal, start_screen, steps_taken, False)
|
||||
|
||||
# Phase 3 GREEN: Unlearn the trap path
|
||||
# Unlearn the trap path
|
||||
from GramAddict.core.qdrant_memory import NavigationMemoryDB
|
||||
|
||||
# We don't know the exact action that got us here easily without analyzing steps_taken,
|
||||
# but we can grab the first action taken from start_screen in this chain if available.
|
||||
if len(steps_taken) > consecutive_back_presses:
|
||||
last_real_action = steps_taken[-consecutive_back_presses - 1]["action"]
|
||||
logger.debug(
|
||||
f"[GOAP Unlearn] last_real_action={last_real_action}, " f"start_screen={start_screen}"
|
||||
)
|
||||
NavigationMemoryDB().unlearn_transition(start_screen, last_real_action)
|
||||
else:
|
||||
logger.debug(
|
||||
f"[GOAP Unlearn] No real action to unlearn. "
|
||||
f"steps={len(steps_taken)}, back_presses={consecutive_back_presses}"
|
||||
)
|
||||
|
||||
return False
|
||||
# ── ESCALATION: Force restart instead of aborting ──
|
||||
app_id = getattr(self.device, "app_id", "com.instagram.android")
|
||||
self.device.app_start(app_id, use_monkey=True)
|
||||
random_sleep(2.0, 3.5)
|
||||
steps_taken.append({"action": "force start instagram"})
|
||||
|
||||
logger.info("🔄 [GOAP Escalation] App restarted. Purging all failure state for fresh attempt.")
|
||||
self.action_failures.clear()
|
||||
explored_nav_actions.clear()
|
||||
visited_screens.clear()
|
||||
consecutive_back_presses = 0
|
||||
continue
|
||||
else:
|
||||
consecutive_back_presses = 0
|
||||
else:
|
||||
@@ -316,7 +329,7 @@ class GoalExecutor:
|
||||
|
||||
return False
|
||||
|
||||
def _execute_action(self, action: str, goal: str = None) -> bool:
|
||||
def _execute_action(self, action: str, goal: str = None, **kwargs) -> bool:
|
||||
"""Execute a single natural-language action using the TelepathicEngine."""
|
||||
|
||||
if action == "press back":
|
||||
@@ -336,6 +349,9 @@ class GoalExecutor:
|
||||
random_sleep(2.0, 3.5)
|
||||
return True
|
||||
|
||||
if action == "type and post comment":
|
||||
return self._execute_type_and_post_comment(kwargs.get("text", ""))
|
||||
|
||||
# Use TelepathicEngine for any semantic click
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
@@ -375,19 +391,33 @@ class GoalExecutor:
|
||||
|
||||
# Execute click
|
||||
self.device.click(obj=best_node)
|
||||
import random
|
||||
|
||||
time.sleep(random.uniform(1.6, 2.8))
|
||||
|
||||
# Verify success via Goal Context + Screen Feedback
|
||||
post_xml = self.device.dump_hierarchy()
|
||||
# ── Smart UI Stabilization Poll ──
|
||||
# Instead of a static sleep (which is either too long on fast devices or
|
||||
# too short on slow ones), we poll dump_hierarchy multiple times.
|
||||
# As soon as the XML changes, we know the UI has transitioned.
|
||||
MAX_POLLS = 5
|
||||
POLL_INTERVAL = 0.5 # seconds between polls
|
||||
post_xml = xml_dump # Start with pre-click state
|
||||
for poll in range(MAX_POLLS):
|
||||
time.sleep(POLL_INTERVAL)
|
||||
post_xml = self.device.dump_hierarchy()
|
||||
if post_xml != xml_dump:
|
||||
logger.debug(f"[GOAP Poll] UI change detected on poll {poll + 1}/{MAX_POLLS}.")
|
||||
break
|
||||
else:
|
||||
logger.debug(f"[GOAP Poll] No UI change after {MAX_POLLS} polls ({MAX_POLLS * POLL_INTERVAL}s).")
|
||||
pre_action_screen = self.perceive(xml_dump) # Screen state BEFORE the click
|
||||
post_screen = self.perceive(post_xml)
|
||||
post_screen_type = post_screen["screen_type"]
|
||||
pre_action_screen_type = pre_action_screen["screen_type"]
|
||||
|
||||
# Determine if this was a navigation or an interaction
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
is_navigation = any(k in action.lower() for k in ["tab", "open", "go to", "navigate", "following list"])
|
||||
if not is_navigation:
|
||||
is_navigation = ScreenTopology.is_structural_action(pre_action_screen_type, action)
|
||||
action_success = False
|
||||
|
||||
# ── UI Change Detection with Noise Threshold ──
|
||||
@@ -528,6 +558,83 @@ class GoalExecutor:
|
||||
achieved = self.planner.plan_next_step(goal, screen) is None
|
||||
return achieved
|
||||
|
||||
def _execute_type_and_post_comment(self, fallback_text: str) -> bool:
|
||||
"""Handles the specific typing interaction, prioritizing Meta AI chips if available."""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
logger.info("💬 [GOAP] Executing 'type and post comment'...")
|
||||
engine = TelepathicEngine.get_instance()
|
||||
|
||||
# 1. Tap the comment input field to open the keyboard
|
||||
xml_dump = self.device.dump_hierarchy()
|
||||
if "com.google.android.inputmethod.latin" not in xml_dump:
|
||||
logger.info("⌨️ [GOAP] Tapping comment composer to open keyboard...")
|
||||
best_node = engine.find_best_node(
|
||||
xml_dump, "tap comment input field", min_confidence=0.7, device=self.device
|
||||
)
|
||||
if best_node:
|
||||
self.device.click(obj=best_node)
|
||||
random_sleep(1.5, 2.5)
|
||||
xml_dump = self.device.dump_hierarchy()
|
||||
else:
|
||||
logger.warning("⚠️ [GOAP] Could not find comment input field.")
|
||||
return False
|
||||
|
||||
# 2. Check for Meta AI chips (Supportive, Funny, etc.)
|
||||
meta_ai_chips = []
|
||||
try:
|
||||
root = ET.fromstring(xml_dump.encode("utf-8"))
|
||||
for node in root.iter("node"):
|
||||
node_text = node.attrib.get("text", "")
|
||||
if node_text in ["Supportive", "Rewrite", "Absurd", "Casual", "Funny", "Heartfelt", "Professional"]:
|
||||
meta_ai_chips.append(node_text)
|
||||
except Exception as e:
|
||||
logger.debug(f"XML parse error for Meta AI chips: {e}")
|
||||
|
||||
if meta_ai_chips:
|
||||
logger.info(f"✨ [Meta AI] Detected Meta AI chips: {meta_ai_chips}")
|
||||
logger.info("🧠 [Meta AI] Asking VLM to select the best tone...")
|
||||
|
||||
# Use Telepathic Engine to pick the best chip via VLM
|
||||
chip_node = engine.find_best_node(
|
||||
xml_dump,
|
||||
"tap the best Meta AI tone chip to generate a comment (e.g. Supportive, Funny, Casual)",
|
||||
min_confidence=0.6,
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
if chip_node:
|
||||
logger.info(f"✨ [Meta AI] VLM selected chip: '{chip_node.get('text', 'Unknown')}'")
|
||||
self.device.click(obj=chip_node)
|
||||
random_sleep(3.0, 4.5) # Wait for Meta AI to generate the text
|
||||
else:
|
||||
logger.warning("⚠️ [Meta AI] VLM failed to pick a chip, falling back to manual typing.")
|
||||
from GramAddict.core.stealth_typing import ghost_type
|
||||
|
||||
ghost_type(self.device, fallback_text)
|
||||
random_sleep(1.0, 2.0)
|
||||
else:
|
||||
# 3. Fallback: Type the text provided by our own VLM writer
|
||||
logger.info(f"⌨️ [GOAP] Typing comment manually: {fallback_text}")
|
||||
from GramAddict.core.stealth_typing import ghost_type
|
||||
|
||||
ghost_type(self.device, fallback_text)
|
||||
random_sleep(1.0, 2.0)
|
||||
|
||||
# 4. Click the Post button
|
||||
xml_dump = self.device.dump_hierarchy()
|
||||
post_btn = engine.find_best_node(xml_dump, "tap post comment button", min_confidence=0.7, device=self.device)
|
||||
if post_btn:
|
||||
self.device.click(obj=post_btn)
|
||||
random_sleep(1.5, 2.5)
|
||||
logger.info("✅ [GOAP] Comment posted successfully.")
|
||||
return True
|
||||
else:
|
||||
logger.warning("⚠️ [GOAP] Could not find post button after typing.")
|
||||
return False
|
||||
|
||||
# ── Convenience methods (backward compatibility with navigate_to) ──
|
||||
|
||||
def navigate_to_screen(self, target: str) -> bool:
|
||||
|
||||
@@ -54,9 +54,9 @@ class LLMWriter:
|
||||
f"6. Reply with ONLY the comment text."
|
||||
)
|
||||
|
||||
model = getattr(self.args, "ai_writer_model", getattr(self.args, "ai_model", "llama3.2:1b"))
|
||||
model = getattr(self.args, "ai_writer_model", getattr(self.args, "ai_model"))
|
||||
url = getattr(
|
||||
self.args, "ai_writer_url", getattr(self.args, "ai_model_url", "http://localhost:11434/api/generate")
|
||||
self.args, "ai_writer_url", getattr(self.args, "ai_model_url")
|
||||
)
|
||||
|
||||
logger.info(f"✍️ [Writer] Generating comment for @{target_username} using {model}...")
|
||||
|
||||
@@ -395,14 +395,11 @@ def query_llm(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Last resort defaults
|
||||
# Last resort: If no fallback config exists, don't silently use hardcoded defaults.
|
||||
# Config() defines these via argparse defaults — if they're missing, there's nothing to fallback to.
|
||||
if not f_model or not f_url:
|
||||
if is_openai_compat:
|
||||
f_model = f_model or "llama3.2:1b"
|
||||
f_url = f_url or "http://localhost:11434/api/generate"
|
||||
else:
|
||||
f_model = f_model or "llama3.2:1b"
|
||||
f_url = f_url or "http://localhost:11434/api/generate"
|
||||
logger.warning("⚠️ [Circuit Breaker] No fallback model/URL configured. Cannot retry.")
|
||||
return None
|
||||
|
||||
# Circuit Breaker: If fallback is identical to primary, don't waste time retrying
|
||||
if f_model == model and f_url == url:
|
||||
@@ -458,11 +455,12 @@ def query_telepathic_llm(
|
||||
|
||||
try:
|
||||
args = Config().args
|
||||
target_url = getattr(args, "ai_fallback_url", "http://localhost:11434/api/generate")
|
||||
target_model = getattr(args, "ai_fallback_model", "llama3.2:1b")
|
||||
target_url = getattr(args, "ai_fallback_url")
|
||||
target_model = getattr(args, "ai_fallback_model")
|
||||
except Exception:
|
||||
target_url = "http://localhost:11434/api/generate"
|
||||
target_model = "llama3.2:1b"
|
||||
raise RuntimeError(
|
||||
"Config().args not initialized — cannot resolve fallback AI model. Fail Fast."
|
||||
)
|
||||
|
||||
is_local = "localhost" in target_url or "127.0.0.1" in target_url
|
||||
calc_timeout = 180 if is_local else 45
|
||||
|
||||
@@ -15,12 +15,10 @@ def ask_brain_for_action(
|
||||
return None
|
||||
|
||||
cfg = Config()
|
||||
url = (
|
||||
getattr(cfg.args, "ai_model_url", "http://localhost:11434/api/generate")
|
||||
if hasattr(cfg, "args")
|
||||
else "http://localhost:11434/api/generate"
|
||||
)
|
||||
model = getattr(cfg.args, "ai_model", "qwen3.5:latest") if hasattr(cfg, "args") else "qwen3.5:latest"
|
||||
if not hasattr(cfg, "args"):
|
||||
raise RuntimeError("Config().args not initialized — cannot resolve AI model. Fail Fast.")
|
||||
url = getattr(cfg.args, "ai_model_url")
|
||||
model = getattr(cfg.args, "ai_model")
|
||||
|
||||
prompt = (
|
||||
f"You are an autonomous Instagram agent. Your ultimate goal is: '{goal}'.\n"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
@@ -21,8 +20,15 @@ def _parse_yes_no(response: str) -> Optional[bool]:
|
||||
return True
|
||||
if str(k).strip().upper() == "NO" or str(v).strip().upper() == "NO":
|
||||
return False
|
||||
if str(k).strip().lower() == "success" and isinstance(v, bool):
|
||||
return v
|
||||
|
||||
# If it is valid JSON but we couldn't definitively find YES/NO,
|
||||
# do NOT fall through to text matching
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
# Prevent JSON parsing fall-throughs
|
||||
return None
|
||||
|
||||
text_lower = text.lower()
|
||||
if text_lower.startswith("yes"):
|
||||
@@ -30,14 +36,6 @@ def _parse_yes_no(response: str) -> Optional[bool]:
|
||||
if text_lower.startswith("no") and not text_lower.startswith("now") and not text_lower.startswith("not"):
|
||||
return False
|
||||
|
||||
has_yes = re.search(r"\byes\b", text_lower) is not None
|
||||
has_no = re.search(r"\bno\b", text_lower) is not None
|
||||
|
||||
if has_yes and not has_no:
|
||||
return True
|
||||
if has_no and not has_yes:
|
||||
return False
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -49,10 +47,12 @@ def _parse_yes_no(response: str) -> Optional[bool]:
|
||||
# If the intent contains the key, the clicked element MUST
|
||||
# contain at least one of the corresponding markers in its
|
||||
# text, content_desc, or resource_id.
|
||||
# ZERO MAINTENANCE: Only English words and resource_id fragments allowed.
|
||||
# No localized strings — the bot must work on any device language.
|
||||
TOGGLE_INTENT_MARKERS = {
|
||||
"follow": ["follow", "gefolgt", "abonnieren"],
|
||||
"like": ["like", "heart", "gefällt"],
|
||||
"save": ["save", "saved", "bookmark", "speichern"],
|
||||
"follow": ["follow", "button_follow"],
|
||||
"like": ["like", "heart", "button_like"],
|
||||
"save": ["save", "saved", "bookmark"],
|
||||
}
|
||||
|
||||
|
||||
@@ -170,19 +170,33 @@ class ActionMemory:
|
||||
logger.warning(f"⚠️ [ActionMemory] Still on grid after trying to '{intent}'. Verification FAIL.")
|
||||
return False # Still on grid, definitely failed
|
||||
|
||||
# Specific check for opening a profile
|
||||
if "profile" in intent_lower or "author" in intent_lower or "username" in intent_lower:
|
||||
if "profile_header_container" in post_xml_lower:
|
||||
logger.info("✅ [ActionMemory] Structural check confirmed profile navigation success.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ [ActionMemory] Profile header NOT found after trying to '{intent}'. Verification FAIL."
|
||||
)
|
||||
return False
|
||||
|
||||
# Specific check for navigating to Home Feed
|
||||
if "home feed" in intent_lower or "home tab" in intent_lower:
|
||||
if "main_feed_action_bar" in post_xml_lower:
|
||||
logger.info("✅ [ActionMemory] Structural check confirmed Home Feed navigation success.")
|
||||
return True
|
||||
|
||||
# Specific check for navigating to Explore Feed
|
||||
if "explore feed" in intent_lower or "explore tab" in intent_lower or "search" in intent_lower:
|
||||
if "explore_action_bar" in post_xml_lower or "action_bar_search_edit_text" in post_xml_lower:
|
||||
logger.info("✅ [ActionMemory] Structural check confirmed Explore Feed navigation success.")
|
||||
return True
|
||||
|
||||
state_toggles = ["like", "save", "follow", "heart"]
|
||||
is_toggle = any(t in intent_lower for t in state_toggles)
|
||||
|
||||
# ── State-Specific Structural Verification ──
|
||||
# If it was a follow, the resulting XML MUST contain "Following", "Requested", "Abonniert" or "Angefragt"
|
||||
if "follow" in intent_lower:
|
||||
FOLLOW_SUCCESS_MARKERS = ["following", "requested", "abonniert", "angefragt", "gefolgt"]
|
||||
if any(m in post_xml_lower for m in FOLLOW_SUCCESS_MARKERS):
|
||||
logger.info("✅ [ActionMemory] Structural check confirmed follow success.")
|
||||
return True
|
||||
else:
|
||||
logger.warning("⚠️ [ActionMemory] Follow success markers NOT found in post-click XML.")
|
||||
# We don't return False immediately because it might take a second to update
|
||||
# ── VLM Verification Fallback ──
|
||||
|
||||
# If we are highly confident (e.g. pulled from Qdrant memory), bypass heavy VLM
|
||||
if device and confidence < 0.95:
|
||||
@@ -206,8 +220,8 @@ class ActionMemory:
|
||||
)
|
||||
if is_toggle:
|
||||
prompt += (
|
||||
"If the intent was 'follow', does the button now indicate 'Following' or 'Requested'? "
|
||||
"If it was 'like', is the heart icon clearly active/red? "
|
||||
"If the intent was 'follow', did the button change its visual state to indicate an active subscription? "
|
||||
"If it was 'like', is the heart icon clearly active/filled? "
|
||||
"If the screen shifted completely to a profile when you just wanted to like/follow from a feed, it FAILED. "
|
||||
"If the tapped element does NOT sound like a like/follow button (e.g. it's a caption, comment field, or post content), it FAILED. "
|
||||
)
|
||||
|
||||
@@ -30,6 +30,11 @@ class IntentResolver:
|
||||
bounding boxes around clickable candidates, sends the annotated image to the VLM,
|
||||
and lets the VLM visually decide which box to tap.
|
||||
|
||||
CRITICAL ARCHITECTURE RULE: Language Agnosticism & Structural Determinism
|
||||
- NO hardcoded localized UI strings (e.g. "follow", "message", "like") are permitted.
|
||||
- All non-VLM resolution logic MUST rely strictly on spatial coordinates (e.g. center_x bounds)
|
||||
or structural Android resource IDs.
|
||||
|
||||
Architecture:
|
||||
1. Navigation tabs → structural zone guard (bottom 15%, resource-id)
|
||||
2. Everything else → Visual Discovery (screenshot + numbered boxes + VLM)
|
||||
@@ -135,23 +140,25 @@ class IntentResolver:
|
||||
|
||||
# NEW REGRESSION FIX: Exclude interaction buttons (comment, like, share) when looking for author or media
|
||||
# This prevents the weak VLM from hallucinating bounding box numbers that point to "Comment".
|
||||
interaction_suffixes = ["comment", "like", "share", "send", "save", "button_icon"]
|
||||
interaction_suffixes = ["comment", "like", "share", "send", "save", "button_icon", "scrubber"]
|
||||
is_interaction = any(s in rid for s in interaction_suffixes) or any(s in desc for s in interaction_suffixes)
|
||||
node_text_lower = (node.text or "").lower()
|
||||
is_follow = "follow" in rid or "follow" in node_text_lower or "follow" in desc
|
||||
is_media_intent = "media content" in intent_lower or "image" in intent_lower or "video" in intent_lower
|
||||
|
||||
if is_author_intent and (is_interaction or is_follow):
|
||||
# SPATIAL GUARD (NO HARDCODED STRINGS): The post author (avatar/name) is always strictly on the left side of the screen.
|
||||
# The "More options" (Report menu) button is strictly on the far right.
|
||||
# We enforce a mathematical boundary to prevent LLM hallucinations from clicking the report menu.
|
||||
if is_author_intent and node.center_x > (1080 * 0.75):
|
||||
logger.debug(
|
||||
f"🛡️ [Author Interaction Guard] Excluded interaction/follow button '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}', text='{node.text}') for author intent '{intent_description}'"
|
||||
f"🛡️ [Author Spatial Guard] Excluded far-right element '{node.resource_id}' "
|
||||
f"(x={node.center_x}) for author intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
is_follow = "button_follow" in rid or "ufi_follow" in rid
|
||||
is_media_intent = "media content" in intent_lower or "image" in intent_lower or "video" in intent_lower
|
||||
|
||||
if is_media_intent and (is_interaction or is_follow):
|
||||
if (is_author_intent or is_media_intent) and (is_interaction or is_follow):
|
||||
logger.debug(
|
||||
f"🛡️ [Media Interaction Guard] Excluded interaction/follow button '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}') for media intent '{intent_description}'"
|
||||
f"🛡️ [Interaction Guard] Excluded non-target element '{node.resource_id}' "
|
||||
f"(desc='{node.content_desc}', text='{node.text}') for intent '{intent_description}'"
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -187,6 +194,31 @@ class IntentResolver:
|
||||
|
||||
return filtered
|
||||
|
||||
def pre_filter_candidates(self, candidates: List[SpatialNode]) -> List[SpatialNode]:
|
||||
"""
|
||||
Filters candidates by area, system UI, keyboard packages, and notifications.
|
||||
|
||||
Production Bug 2026-05-04: The Android soft keyboard (com.google.android.inputmethod.*)
|
||||
flooded the candidate pool with 30+ single-letter nodes (A, B, C, N, ...),
|
||||
causing the VLM to hallucinate keyboard keys as valid UI targets.
|
||||
"""
|
||||
return [
|
||||
n
|
||||
for n in candidates
|
||||
if 200 < n.area < 400000
|
||||
and "com.android.systemui" not in (n.resource_id or "")
|
||||
and "inputmethod" not in (n.resource_id or "")
|
||||
and "notification:" not in (n.content_desc or "").lower()
|
||||
and "per cent" not in (n.content_desc or "").lower()
|
||||
]
|
||||
|
||||
def has_keyboard_open(self, candidates: List[SpatialNode]) -> bool:
|
||||
"""
|
||||
Detects if the Android soft keyboard is currently visible
|
||||
by checking for input method package nodes in the candidate list.
|
||||
"""
|
||||
return any("inputmethod" in (n.resource_id or "") for n in candidates)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Public API
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -228,22 +260,32 @@ class IntentResolver:
|
||||
rid = (node.resource_id or "").lower()
|
||||
desc = (node.content_desc or "").lower()
|
||||
text = (node.text or "").lower()
|
||||
if (
|
||||
"send" in rid
|
||||
or "composer_button" in rid
|
||||
or "send" in desc
|
||||
or "absenden" in desc
|
||||
or text in ["send", "absenden"]
|
||||
):
|
||||
if "send" in rid or "composer_button" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found send button: {rid or desc or text}")
|
||||
return node
|
||||
|
||||
if "post author username" in intent_lower or "tap post username" in intent_lower:
|
||||
# 1. Feed Posts
|
||||
for node in candidates:
|
||||
if "row_feed_photo_profile_name" in (node.resource_id or "").lower():
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_photo_profile_imageview" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found post author avatar image: {node.content_desc}")
|
||||
return node
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_photo_profile_name" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found post author username text: {node.text}")
|
||||
return node
|
||||
|
||||
# 2. Reels (Clips)
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "clips_author_username" in rid or "clips_author_container" in rid:
|
||||
logger.info(
|
||||
f"🎯 [Structural Fast-Path] Found Reel author username: {node.text or node.content_desc}"
|
||||
)
|
||||
return node
|
||||
|
||||
if "feed post content" in intent_lower or "post media content" in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
@@ -271,6 +313,46 @@ class IntentResolver:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found comment button: {node.resource_id}")
|
||||
return node
|
||||
|
||||
if "like" in intent_lower and ("button" in intent_lower or "post" in intent_lower):
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_button_like" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found like button: {rid}")
|
||||
return node
|
||||
|
||||
if ("send" in intent_lower or "share" in intent_lower) and "post" in intent_lower and "button" in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_button_share" in rid or "direct_share_button" in rid or "button_share" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found send/share post button: {rid}")
|
||||
return node
|
||||
|
||||
if "add to story" in intent_lower:
|
||||
# We skip structural fast-path for 'add to story' since it relies heavily on language/text strings
|
||||
# and let the VLM figure it out or rely on purely visual indicators.
|
||||
pass
|
||||
|
||||
if "share" in intent_lower and ("button" in intent_lower or "post" in intent_lower):
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_button_share" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found share button: {rid}")
|
||||
return node
|
||||
|
||||
if "save" in intent_lower and ("button" in intent_lower or "post" in intent_lower):
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "row_feed_button_save" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found save button: {rid}")
|
||||
return node
|
||||
|
||||
if "follow" in intent_lower and "button" in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
if "profile_header_follow_button" in rid or "inline_follow_button" in rid:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found follow/following button: {rid}")
|
||||
return node
|
||||
|
||||
if "first post" in intent_lower or "first item" in intent_lower or "first search result" in intent_lower:
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
@@ -278,6 +360,42 @@ class IntentResolver:
|
||||
logger.info(f"🎯 [Structural Fast-Path] Found first post/item: {rid}")
|
||||
return node
|
||||
|
||||
if "story ring" in intent_lower or "story tray" in intent_lower:
|
||||
story_nodes = []
|
||||
for node in candidates:
|
||||
rid = (node.resource_id or "").lower()
|
||||
desc = (node.content_desc or "").lower()
|
||||
text = (node.text or "").lower()
|
||||
# Instagram story tray avatars usually have this resource id and 'story' in the content description
|
||||
if ("avatar_image_view" in rid or "row_profile_header_imageview" in rid) and "story" in desc:
|
||||
# Ignore the user's explicit "Add to story" ring
|
||||
if "add to story" not in desc and "your story" not in text:
|
||||
story_nodes.append(node)
|
||||
|
||||
if story_nodes:
|
||||
# Sort horizontally (left-to-right)
|
||||
story_nodes.sort(key=lambda n: n.x1)
|
||||
|
||||
# Check if this is the home feed story tray (avatar_image_view without 'highlight' in desc)
|
||||
is_highlight = any("highlight" in (n.content_desc or "").lower() for n in story_nodes)
|
||||
if "avatar_image_view" in (story_nodes[0].resource_id or "").lower() and not is_highlight:
|
||||
if len(story_nodes) > 1:
|
||||
logger.info(
|
||||
f"🎯 [Structural Fast-Path] Found {len(story_nodes)} story rings. Skipping own profile. Picking second: '{story_nodes[1].content_desc}'"
|
||||
)
|
||||
return story_nodes[1]
|
||||
else:
|
||||
logger.warning(
|
||||
"🎯 [Structural Fast-Path] Only 1 story ring found on feed (likely own profile). Skipping to avoid modal trap."
|
||||
)
|
||||
return None
|
||||
else:
|
||||
# Profile header or other single-story views
|
||||
logger.info(
|
||||
f"🎯 [Structural Fast-Path] Found story ring avatar: {story_nodes[0].resource_id} (desc: '{story_nodes[0].content_desc}')"
|
||||
)
|
||||
return story_nodes[0]
|
||||
|
||||
# --- Navigation Tab Fast-Paths ---
|
||||
# Deterministically identify bottom navigation tabs to prevent VLM confusion
|
||||
tab_map = {
|
||||
@@ -307,13 +425,21 @@ class IntentResolver:
|
||||
quotes = re.findall(r"['\"](.*?)['\"]", intent_description)
|
||||
if quotes:
|
||||
target_text = quotes[0].lower()
|
||||
pattern = r"\b" + re.escape(target_text) + r"\b"
|
||||
|
||||
# Only use the exact target string (no manual localized translation dictionaries!)
|
||||
localized_targets = [target_text]
|
||||
|
||||
semantic_candidates = []
|
||||
for node in candidates:
|
||||
n_text = (node.text or "").lower()
|
||||
n_desc = (node.content_desc or "").lower()
|
||||
if re.search(pattern, n_text) or re.search(pattern, n_desc):
|
||||
semantic_candidates.append(node)
|
||||
|
||||
# Check if any of the localized targets match
|
||||
for loc_target in localized_targets:
|
||||
pattern = r"\b" + re.escape(loc_target) + r"\b"
|
||||
if re.search(pattern, n_text) or re.search(pattern, n_desc):
|
||||
semantic_candidates.append(node)
|
||||
break # Found a match, no need to check other localized targets
|
||||
|
||||
if semantic_candidates:
|
||||
if len(semantic_candidates) == 1:
|
||||
@@ -494,15 +620,8 @@ class IntentResolver:
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
# Pre-filter candidates by area and system UI before any semantic matching
|
||||
candidates = [
|
||||
n
|
||||
for n in candidates
|
||||
if 200 < n.area < 400000
|
||||
and "com.android.systemui" not in (n.resource_id or "")
|
||||
and "notification:" not in (n.content_desc or "").lower()
|
||||
and "per cent" not in (n.content_desc or "").lower()
|
||||
]
|
||||
# Pre-filter candidates by area, system UI, and keyboard packages
|
||||
candidates = self.pre_filter_candidates(candidates)
|
||||
|
||||
# --- Navigation Conflict Guard ---
|
||||
# Prevents VLM from confusing Back buttons with tab buttons
|
||||
@@ -573,8 +692,8 @@ class IntentResolver:
|
||||
self.last_box_map = box_map
|
||||
|
||||
cfg = Config()
|
||||
model = getattr(cfg.args, "ai_telepathic_model", "llava:latest")
|
||||
url = getattr(cfg.args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
model = getattr(cfg.args, "ai_telepathic_model")
|
||||
url = getattr(cfg.args, "ai_telepathic_url")
|
||||
|
||||
# Build a compact legend of what each box contains
|
||||
box_legend_lines = []
|
||||
@@ -601,14 +720,14 @@ class IntentResolver:
|
||||
f"CRITICAL RULES:\n"
|
||||
f"1. If the intent contains a word in quotes (e.g., 'Search', 'New Message'), you MUST look at the Box legend and pick the box that contains that word (case-insensitive). Do not pick anything else.\n"
|
||||
f"2. For icons without text:\n"
|
||||
f" - 'like button' = HEART-SHAPED ICON (♡/❤), usually has desc='Like'.\n"
|
||||
f" - 'comment button' = SPEECH BUBBLE ICON, usually has desc='Comment'.\n"
|
||||
f" - 'like button' = HEART-SHAPED ICON (♡/❤), look for id containing 'button_like' or 'ufi_heart'.\n"
|
||||
f" - 'comment button' = SPEECH BUBBLE ICON, look for id containing 'button_comment' or 'ufi_comment'.\n"
|
||||
f"3. Do NOT select text, captions, or view counts if looking for an icon.\n"
|
||||
f"4. Ignore numbers inside the text itself. Do not confuse the text '19' with Box [19].\n"
|
||||
f"5. If the intent contains 'following', you MUST pick the box containing 'following'. Do NOT pick 'followers' or 'Follow'.\n"
|
||||
f"5. If the intent contains 'following', you MUST pick the box containing id 'button_following' or 'profile_header_following'. Do NOT pick 'followers' or 'Follow'.\n"
|
||||
f"6. If the intent is to tap a 'post', 'first post', or 'grid item':\n"
|
||||
f" - Look for boxes with descriptions containing 'photos by', 'Reel by', or 'row 1, column 1'.\n"
|
||||
f" - Pick the FIRST matching box index (e.g. if [0] says '6 photos...', return 0, NOT 6).\n"
|
||||
f" - Look for boxes with ids like 'image_button' inside a grid, or visual grid thumbnails.\n"
|
||||
f" - Pick the FIRST matching box index.\n"
|
||||
f" - Do NOT pick navigation buttons like 'Search'.\n"
|
||||
f"7. If the intent is a bottom navigation tab (e.g. 'profile tab', 'home tab'):\n"
|
||||
f" - These are always at the BOTTOM edge of the screen.\n"
|
||||
@@ -617,17 +736,17 @@ class IntentResolver:
|
||||
f" - 'explore tab' is the magnifying glass.\n"
|
||||
f" - 'reels tab' is the video clapperboard.\n"
|
||||
f"8. If the intent involves 'author username' or 'author profile':\n"
|
||||
f" - Pick the profile picture (e.g. 'Profile picture of <username>') or the username text.\n"
|
||||
f" - NEVER pick a 'Follow' button. Do NOT pick 'Follow <username>'.\n"
|
||||
f" - Pick the profile picture or the username text.\n"
|
||||
f" - NEVER pick a 'Follow' button. Do NOT pick 'button_follow'.\n"
|
||||
f"9. If the intent is 'save post':\n"
|
||||
f" - The save icon is the bookmark icon on the bottom right of the post image/video.\n"
|
||||
f" - Usually has desc='Add to Saved' or 'Save'. Do NOT pick the post text or other action buttons.\n"
|
||||
f" - Look for id containing 'button_save' or 'ufi_save'. Do NOT pick the post text or other action buttons.\n"
|
||||
f"10. DISTINGUISHING BOTTOM TABS vs CONTENT BUTTONS:\n"
|
||||
f" - Bottom Navigation Tabs (Home, Search, Reels, Profile) are ALWAYS at the very bottom (y > 2100).\n"
|
||||
f" - Content Interaction Buttons (Like, Comment, Share, Reactions, Message Input) are attached to posts or threads, NOT the bottom nav bar.\n"
|
||||
f" - If looking for 'message input' or 'type message', do NOT select 'reactions' or emoji icons. Look for an empty text box or 'Message...'.\n"
|
||||
f" - If looking for 'message input' or 'type message', do NOT select 'reactions' or emoji icons. Look for an empty text box or id 'message_content'.\n"
|
||||
f"11. If the intent is 'feed post content' or 'post media content':\n"
|
||||
f" - Pick the largest box that contains the actual image or video, usually described as 'Photo', 'Video', or 'Carousel'.\n"
|
||||
f" - Pick the largest box that contains the actual image or video. Look for id 'zoomable_view_container' or 'media_frame'.\n"
|
||||
f"12. If the exact control is NOT visible, return null. Do NOT guess.\n\n"
|
||||
f'Reply ONLY with a valid JSON object: {{"box": <number>}} or {{"box": null}}'
|
||||
)
|
||||
@@ -697,8 +816,8 @@ class IntentResolver:
|
||||
filtered_candidates = [n for n in candidates if n.area < 500000]
|
||||
|
||||
cfg = Config()
|
||||
model = getattr(cfg.args, "ai_telepathic_model", "qwen3.5:latest")
|
||||
url = getattr(cfg.args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
model = getattr(cfg.args, "ai_telepathic_model")
|
||||
url = getattr(cfg.args, "ai_telepathic_url")
|
||||
|
||||
node_context = []
|
||||
for i, node in enumerate(filtered_candidates):
|
||||
|
||||
@@ -22,6 +22,7 @@ class ScreenType(Enum):
|
||||
FOLLOW_LIST = "follow_list"
|
||||
COMMENTS = "comments"
|
||||
MODAL = "modal"
|
||||
DANGER_ACTION_BLOCKED = "danger_action_blocked"
|
||||
FOREIGN_APP = "foreign_app"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@@ -162,37 +163,68 @@ class ScreenIdentity:
|
||||
"""
|
||||
Classify screen type using Semantic Memory with LLM fallback — NO hardcoded states."""
|
||||
|
||||
# Priority 0: Check Qdrant Semantic Cache (Learned Truth/LLM Overrides)
|
||||
# This MUST be checked first. If the LLM declared this specific layout a "false positive"
|
||||
# and cached it as NORMAL, it must override any rigid structural heuristics below to prevent
|
||||
# infinite loops.
|
||||
is_normal_override = False
|
||||
# Priority 0: Fetch Qdrant Semantic Cache
|
||||
# We fetch this early to see if there is a 'NORMAL' override for the MODAL check.
|
||||
# We DO NOT let this override deterministic structural heuristics! Fuzzy vector matching
|
||||
# can easily confuse HOME_FEED and OWN_PROFILE if the bottom navigation bar is identical.
|
||||
cached_type_str = None
|
||||
if signature and self.screen_memory and self.screen_memory.is_connected:
|
||||
cached_type_str = self.screen_memory.get_screen_type(signature, similarity_threshold=0.92)
|
||||
if cached_type_str:
|
||||
if cached_type_str == "NORMAL":
|
||||
is_normal_override = True
|
||||
else:
|
||||
try:
|
||||
return ScreenType[cached_type_str]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
is_normal_override = cached_type_str == "NORMAL"
|
||||
|
||||
# Priority 1: Content-creation overlays that block ALL navigation.
|
||||
# These full-screen Instagram UIs have no navigation tabs and trap the bot.
|
||||
# Structural detection is O(1), zero LLM calls, and cannot be fooled.
|
||||
if not is_normal_override:
|
||||
creation_flow_markers = ("quick_capture", "gallery_cancel_button", "creation_flow", "reel_camera")
|
||||
if any(marker in ids_str for marker in creation_flow_markers):
|
||||
logger.info("🛡️ [ScreenIdentity] Content-creation overlay detected → MODAL")
|
||||
modal_markers = (
|
||||
"quick_capture",
|
||||
"gallery_cancel_button",
|
||||
"creation_flow",
|
||||
"reel_camera",
|
||||
"survey_overlay_container",
|
||||
"interstitial_container",
|
||||
"nux_overlay",
|
||||
"rating_prompt",
|
||||
"feedback_dialog",
|
||||
"action_bar_browser_container",
|
||||
)
|
||||
if any(marker in ids_str for marker in modal_markers):
|
||||
logger.info("🛡️ [ScreenIdentity] Modal/Interstitial overlay detected → MODAL")
|
||||
return ScreenType.MODAL
|
||||
|
||||
# Priority 1: Structural Heuristics (100% Deterministic)
|
||||
# Action Blocked Detection (O(1) fast-path)
|
||||
# Prevents LLM hallucinations for system-level traps that block the entire flow.
|
||||
danger_markers = (
|
||||
"try again later",
|
||||
"action blocked",
|
||||
"we restrict certain activity",
|
||||
"to protect our community",
|
||||
)
|
||||
if "bottom_sheet_container" in ids and any(d in text_lower or d in desc_lower for d in danger_markers):
|
||||
logger.info("🛡️ [ScreenIdentity] Critical obstacle detected → DANGER_ACTION_BLOCKED")
|
||||
return ScreenType.DANGER_ACTION_BLOCKED
|
||||
|
||||
# Menu Trap Detection (O(1) fast-path)
|
||||
# Overrides hallucinated LLM cache using STRICTLY STRUCTURAL IDs (no localized strings).
|
||||
if "bottom_sheet_container" in ids and "action_sheet_row_text_view" in ids:
|
||||
logger.info("🛡️ [ScreenIdentity] Options/Report menu trap detected → MODAL")
|
||||
return ScreenType.MODAL
|
||||
|
||||
# Priority 2: Structural Heuristics (100% Deterministic)
|
||||
if "unified_follow_list_tab_layout" in ids or "follow_list_container" in ids:
|
||||
return ScreenType.FOLLOW_LIST
|
||||
|
||||
if "profile_header_container" in ids:
|
||||
if selected_tab == "profile_tab":
|
||||
# Profile structural markers
|
||||
PROFILE_MARKERS = (
|
||||
"profile_header_container",
|
||||
"row_profile_header_imageview",
|
||||
"profile_tabs_container",
|
||||
"profile_header_name",
|
||||
)
|
||||
if any(marker in ids for marker in PROFILE_MARKERS):
|
||||
own_profile_texts = ("edit profile", "share profile")
|
||||
if selected_tab == "profile_tab" or any(m in desc_lower or m in text_lower for m in own_profile_texts):
|
||||
return ScreenType.OWN_PROFILE
|
||||
return ScreenType.OTHER_PROFILE
|
||||
|
||||
@@ -250,7 +282,23 @@ class ScreenIdentity:
|
||||
|
||||
# End of structural heuristics
|
||||
|
||||
# Priority 3: Semantic VLM Classification Fallback
|
||||
# Priority 3: Cached Semantic Type (If deterministic heuristics failed)
|
||||
if cached_type_str and cached_type_str != "NORMAL":
|
||||
try:
|
||||
cached_type = ScreenType[cached_type_str]
|
||||
# Enforce absolute structural parity: Story and Reels must have their structural markers.
|
||||
# If they reached Priority 3, it means Priority 2 failed to find their markers.
|
||||
# Therefore, any cache telling us this is a Story/Reel without those markers is hallucinating.
|
||||
if cached_type in (ScreenType.STORY_VIEW, ScreenType.REELS_FEED):
|
||||
logger.warning(
|
||||
f"⚠️ [ScreenIdentity] Rejecting cached {cached_type.name} due to missing structural markers."
|
||||
)
|
||||
else:
|
||||
return cached_type
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Priority 4: Semantic VLM Classification Fallback
|
||||
if not screenshot_b64 and getattr(self, "device", None) is not None:
|
||||
screenshot_b64 = self.device.get_screenshot_b64()
|
||||
|
||||
@@ -258,12 +306,10 @@ class ScreenIdentity:
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
cfg = Config()
|
||||
url = (
|
||||
getattr(cfg.args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
if hasattr(cfg, "args")
|
||||
else "http://localhost:11434/api/generate"
|
||||
)
|
||||
model = getattr(cfg.args, "ai_telepathic_model", "llava:latest") if hasattr(cfg, "args") else "llava:latest"
|
||||
if not hasattr(cfg, "args"):
|
||||
raise RuntimeError("Config().args not initialized — cannot resolve AI model. Fail Fast.")
|
||||
url = getattr(cfg.args, "ai_telepathic_url")
|
||||
model = getattr(cfg.args, "ai_telepathic_model")
|
||||
|
||||
layout_context = (
|
||||
f"Selected Tab: {selected_tab}\nResource IDs: {list(ids)}\nVisible Texts context: {texts[:10]}\n"
|
||||
@@ -294,6 +340,13 @@ class ScreenIdentity:
|
||||
# Prevent the LLM from hallucinating an obstacle if explicitly verified as NORMAL
|
||||
return ScreenType.UNKNOWN
|
||||
|
||||
# Enforce absolute structural parity: Story and Reels must have their structural markers.
|
||||
if t in (ScreenType.STORY_VIEW, ScreenType.REELS_FEED):
|
||||
logger.warning(
|
||||
f"⚠️ [ScreenIdentity] Rejecting VLM hallucinated {t.name} due to missing structural markers."
|
||||
)
|
||||
return ScreenType.UNKNOWN
|
||||
|
||||
if signature and self.screen_memory:
|
||||
self.screen_memory.store_screen(signature, t.name)
|
||||
return t
|
||||
@@ -320,43 +373,30 @@ class ScreenIdentity:
|
||||
if tab_id in resource_ids:
|
||||
actions.append(action)
|
||||
|
||||
# Screen-specific actions
|
||||
desc_lower = " ".join(content_descs).lower()
|
||||
text_lower = " ".join(texts).lower()
|
||||
# Screen-specific actions (Purely structural, NO localized strings)
|
||||
ids_str = " ".join(resource_ids).lower()
|
||||
|
||||
if "like" in desc_lower:
|
||||
if "button_like" in ids_str or "ufi_heart" in ids_str:
|
||||
actions.append("tap like button")
|
||||
if "comment" in desc_lower:
|
||||
if "button_comment" in ids_str or "ufi_comment" in ids_str:
|
||||
actions.append("tap comment button")
|
||||
if "share" in desc_lower:
|
||||
if "button_share" in ids_str or "ufi_share" in ids_str or "direct_share" in ids_str:
|
||||
actions.append("tap share button")
|
||||
if "save" in desc_lower or "bookmark" in desc_lower:
|
||||
if "button_save" in ids_str or "ufi_save" in ids_str:
|
||||
actions.append("tap save button")
|
||||
if "back" in desc_lower:
|
||||
if "back" in ids_str or "action_bar_button_back" in ids_str:
|
||||
actions.append("tap back button")
|
||||
has_following = any(
|
||||
"following" in e.get("text", "").lower() or "following" in e.get("desc", "").lower()
|
||||
for e in clickable_elements
|
||||
)
|
||||
|
||||
has_following = "button_following" in ids_str or "profile_header_following" in ids_str
|
||||
if has_following:
|
||||
actions.append("tap following button")
|
||||
elif any(
|
||||
"follow" in e.get("text", "").lower()
|
||||
or "follow" in e.get("desc", "").lower()
|
||||
or "follow" in e.get("id", "").lower()
|
||||
for e in clickable_elements
|
||||
):
|
||||
elif "button_follow" in ids_str:
|
||||
actions.append("tap follow button")
|
||||
|
||||
if screen_type == ScreenType.OWN_PROFILE or screen_type == ScreenType.OTHER_PROFILE:
|
||||
if "message" in desc_lower or "nachricht" in desc_lower:
|
||||
if "button_message" in ids_str or "direct_message" in ids_str:
|
||||
actions.append("tap message button")
|
||||
if (
|
||||
"following" in desc_lower
|
||||
or "abonniert" in desc_lower
|
||||
or "following" in text_lower
|
||||
or "profile_header_following" in " ".join(resource_ids).lower()
|
||||
):
|
||||
if "profile_header_following" in ids_str:
|
||||
actions.append("tap following list")
|
||||
|
||||
# Grid items
|
||||
|
||||
@@ -28,8 +28,8 @@ class SemanticEvaluator:
|
||||
logger.warning("👁️ [Vision Core] No config available. Cannot query VLM.")
|
||||
return None
|
||||
|
||||
model = getattr(self.args, "ai_telepathic_model", "llama3.2-vision")
|
||||
url = getattr(self.args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
model = getattr(self.args, "ai_telepathic_model")
|
||||
url = getattr(self.args, "ai_telepathic_url")
|
||||
|
||||
try:
|
||||
res = query_telepathic_llm(
|
||||
@@ -123,8 +123,7 @@ class SemanticEvaluator:
|
||||
{{
|
||||
"should_like": true/false,
|
||||
"should_comment": true/false,
|
||||
"is_ad": true/false,
|
||||
"reasoning": "brief explanation"
|
||||
"is_ad": true/false
|
||||
}}
|
||||
"""
|
||||
response = self._query_vlm(prompt, screenshot_b64)
|
||||
@@ -133,7 +132,17 @@ class SemanticEvaluator:
|
||||
json_str = response.split("```json")[1].split("```")[0].strip()
|
||||
else:
|
||||
json_str = response.strip()
|
||||
return json.loads(json_str)
|
||||
try:
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
# Try to close potential unclosed JSON strings
|
||||
if not json_str.endswith("}"):
|
||||
json_str += "}"
|
||||
try:
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
logger.warning(f"👁️ [Vision Core] VLM returned malformed JSON: {response}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to evaluate post vibe: {e}")
|
||||
return None
|
||||
|
||||
@@ -121,34 +121,11 @@ class QNavGraph:
|
||||
GOAP-powered action execution.
|
||||
Replaces _execute_transition() for post interactions.
|
||||
|
||||
Screen-aware: refuses to attempt actions that don't exist on the current screen.
|
||||
|
||||
Usage:
|
||||
nav_graph.do("like this post") # instead of _execute_transition("tap_like_button")
|
||||
nav_graph.do("follow this user") # instead of _execute_transition("tap_follow_button")
|
||||
nav_graph.do("tap first grid item") # instead of _execute_transition("tap_explore_grid_item")
|
||||
"""
|
||||
# ── Screen sanity check: is this action possible here? ──
|
||||
screen = self.goap.perceive()
|
||||
available = screen.get("available_actions", [])
|
||||
screen_type = screen["screen_type"]
|
||||
|
||||
# Map goal to the action that should be available
|
||||
action_checks = {
|
||||
"like": ["tap like button"],
|
||||
"comment": ["tap comment button"],
|
||||
"share": ["tap share button"],
|
||||
"follow": ["tap follow button", "tap following button"],
|
||||
}
|
||||
for keyword, required_actions in action_checks.items():
|
||||
if keyword in goal.lower():
|
||||
# If ANY of the required actions are available, it's valid
|
||||
if not any(req in available for req in required_actions):
|
||||
logger.warning(
|
||||
f"🚫 [GOAP] Cannot '{goal}' on {screen_type.value} "
|
||||
f"({required_actions} not available on this screen)"
|
||||
)
|
||||
return False
|
||||
|
||||
return self.goap._execute_action(goal)
|
||||
|
||||
|
||||
@@ -29,7 +29,10 @@ class QdrantBase:
|
||||
|
||||
try:
|
||||
qdrant_url = os.environ.get("QDRANT_URL", "http://localhost:6344")
|
||||
self.client = QdrantClient(url=qdrant_url, timeout=10.0)
|
||||
if qdrant_url == ":memory:":
|
||||
self.client = QdrantClient(location=":memory:")
|
||||
else:
|
||||
self.client = QdrantClient(url=qdrant_url, timeout=10.0)
|
||||
|
||||
if self.client:
|
||||
if self.client.collection_exists(collection_name):
|
||||
@@ -112,8 +115,8 @@ class QdrantBase:
|
||||
args = self._cached_args
|
||||
|
||||
# Pull specific embedding config or fallback to defaults
|
||||
model = getattr(args, "ai_embedding_model", "nomic-embed-text")
|
||||
url = getattr(args, "ai_embedding_url", "http://localhost:11434/api/embeddings")
|
||||
model = getattr(args, "ai_embedding_model")
|
||||
url = getattr(args, "ai_embedding_url")
|
||||
|
||||
try:
|
||||
# Generate embeddings
|
||||
@@ -141,7 +144,7 @@ class QdrantBase:
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=12,
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.debug(f"Embedding API Error {resp.status_code}: {resp.text}")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from colorama import Fore
|
||||
@@ -331,14 +332,32 @@ class ResonanceEngine:
|
||||
is_comment_node = "comment" in res_id or "textview" in res_id
|
||||
|
||||
# 3. Block accessibility garbage & UI labels
|
||||
# Zero-Maintenance: Only structural patterns. Short strings
|
||||
# (< 5 chars) from UI buttons are blocked by length, not by
|
||||
# translating every possible language.
|
||||
is_ui_junk = (
|
||||
val.lower().startswith("go to")
|
||||
or val.lower().startswith("tap to")
|
||||
or "actions for this post" in val.lower()
|
||||
or len(val.strip()) < 3
|
||||
)
|
||||
|
||||
# Block known English UI action labels.
|
||||
# We intentionally do NOT add German/Spanish/etc translations.
|
||||
# Instead, we rely on the structural `is_comment_node` filter
|
||||
# above + length heuristic to catch non-comment UI elements.
|
||||
blocked_exact = [
|
||||
"reply",
|
||||
"like",
|
||||
"view replies",
|
||||
"see translation",
|
||||
"hide replies",
|
||||
"view all comments",
|
||||
"send",
|
||||
]
|
||||
|
||||
if val and len(val) > 2 and is_comment_node and not is_ui_junk:
|
||||
if val.lower() not in ["reply", "like", "view replies", "see translation", "hide replies"]:
|
||||
if val.lower() not in blocked_exact:
|
||||
raw_comments.append(val)
|
||||
except Exception as e:
|
||||
logger.error(f"🧠 [Comment Learning] Failed to parse XML: {e}")
|
||||
@@ -367,8 +386,8 @@ class ResonanceEngine:
|
||||
"Set 'keep' to false only for clear spam, bots, UI buttons, or blacklist violations.\n"
|
||||
)
|
||||
|
||||
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
|
||||
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
|
||||
model = getattr(configs.args, "ai_condenser_model")
|
||||
url = getattr(configs.args, "ai_condenser_url")
|
||||
|
||||
try:
|
||||
import json
|
||||
@@ -393,7 +412,7 @@ class ResonanceEngine:
|
||||
logger.debug(f"DEBUG CONDENSER RAW: {response_text}")
|
||||
|
||||
# Parse json gracefully
|
||||
if type(response_text) is str:
|
||||
if isinstance(response_text, str):
|
||||
clean_json = response_text.strip()
|
||||
if clean_json.startswith("```json"):
|
||||
clean_json = clean_json[7:]
|
||||
|
||||
@@ -62,7 +62,13 @@ class ScreenTopology:
|
||||
"press back": ScreenType.HOME_FEED,
|
||||
},
|
||||
ScreenType.OTHER_PROFILE: {
|
||||
"press back": ScreenType.HOME_FEED,
|
||||
},
|
||||
ScreenType.POST_DETAIL: {
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
"tap explore tab": ScreenType.EXPLORE_GRID,
|
||||
"tap profile tab": ScreenType.OWN_PROFILE,
|
||||
"tap reels tab": ScreenType.REELS_FEED,
|
||||
},
|
||||
ScreenType.UNKNOWN: {
|
||||
"tap home tab": ScreenType.HOME_FEED,
|
||||
|
||||
@@ -8,6 +8,11 @@ and learns from every episode — positive AND negative.
|
||||
|
||||
After initial learning, 95%+ of situations are handled from memory
|
||||
alone with ZERO LLM calls. This is "Tesla fleet learning" for bots.
|
||||
|
||||
CRITICAL ARCHITECTURE RULE: Language Agnosticism & Structural Determinism
|
||||
NO hardcoded localized UI strings (e.g. "Report", "OK", "Deny") are permitted.
|
||||
All structural heuristic fallbacks must use O(1) Android `resource-id` bounds
|
||||
to ensure the bot functions flawlessly regardless of the device language.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -291,47 +296,53 @@ class SituationalAwarenessEngine:
|
||||
stable = re.sub(r"Battery \d+ per cent", "Battery NN per cent", stable)
|
||||
return hashlib.sha256(stable.encode()).hexdigest()[:32]
|
||||
|
||||
def _get_model_config(self):
|
||||
"""
|
||||
Get model/URL config from Config() — the SINGLE source of truth.
|
||||
Crashes loudly if config is unavailable (Fail Fast, no silent defaults).
|
||||
"""
|
||||
from GramAddict.core.config import Config
|
||||
|
||||
cfg = Config()
|
||||
args = cfg.args
|
||||
return {
|
||||
"model": getattr(args, "ai_model", None),
|
||||
"url": getattr(args, "ai_model_url", None),
|
||||
"telepathic_model": getattr(args, "ai_telepathic_model", None),
|
||||
"telepathic_url": getattr(args, "ai_telepathic_url", None),
|
||||
}
|
||||
|
||||
def perceive(self, xml_dump: str) -> SituationType:
|
||||
"""
|
||||
Fast structural classification — NO LLM needed for perception.
|
||||
Uses package names + structural markers to classify.
|
||||
Autonomous situation classification — ZERO hardcoded UI element identifiers.
|
||||
|
||||
Flow:
|
||||
1. Empty/invalid XML → FOREIGN_APP
|
||||
2. Hardware check (screen off) → LOCKED_SCREEN
|
||||
3. Package-based detection (app-agnostic, zero maintenance):
|
||||
- Permission controller packages → OBSTACLE_SYSTEM
|
||||
- App package missing → OBSTACLE_FOREIGN_APP
|
||||
4. Qdrant semantic cache → instant recall of learned screen types
|
||||
5. ScreenIdentity structural delegation → if MODAL, return OBSTACLE_MODAL
|
||||
6. LLM classification fallback → autonomous discovery of new obstacle types
|
||||
|
||||
NO hardcoded resource-ids, NO hardcoded button texts, NO localized strings.
|
||||
The bot discovers and learns ALL obstacles autonomously via the LLM + Qdrant loop.
|
||||
"""
|
||||
if not xml_dump or not isinstance(xml_dump, str):
|
||||
return SituationType.OBSTACLE_FOREIGN_APP
|
||||
|
||||
xml_dump.lower()
|
||||
|
||||
blocked_markers = [
|
||||
"try again later",
|
||||
"action blocked",
|
||||
"restrict certain activity",
|
||||
"help us confirm you own",
|
||||
"confirm it's you",
|
||||
"später erneut versuchen",
|
||||
"bestätige, dass du es bist",
|
||||
"handlung blockiert",
|
||||
"eingeschränkt",
|
||||
]
|
||||
|
||||
# Guard: Check if the text matches are relatively isolated (e.g. short strings).
|
||||
# If the string is buried inside a 200-character caption, it's a false positive.
|
||||
# We can regex match text="..." attributes that are less than 60 characters total,
|
||||
# OR just use the compressed string where text is capped at 60 chars anyway.
|
||||
compressed_lower = self._compress_xml(xml_dump).lower()
|
||||
if any(re.search(rf"(?:text|desc)='[^']*?{m}[^']*?'", compressed_lower) for m in blocked_markers):
|
||||
# To be extra safe against false positives, check if there's a dialog/modal container
|
||||
if "dialog" in compressed_lower or "bottom_sheet" in compressed_lower or "alert" in compressed_lower:
|
||||
return SituationType.DANGER_ACTION_BLOCKED
|
||||
|
||||
# ── Hardware Guard: Screen Off / Locked ──
|
||||
if not getattr(self.device.deviceV2, "info", {}).get("screenOn", True):
|
||||
logger.info("📱 [SAE Perceive] Screen is physically OFF.")
|
||||
return SituationType.OBSTACLE_LOCKED_SCREEN
|
||||
|
||||
# ── System Dialog / Permission Detect (Fast Path) ──
|
||||
packages = set(re.findall(r'package=["\']([^"\']+)["\']', xml_dump))
|
||||
# ── System Dialog / Permission Detect (package-based, app-agnostic) ──
|
||||
packages = set(re.findall(r'package=["\'](.[^"\']+)["\']+', xml_dump))
|
||||
app_id = getattr(self.device, "app_id", "com.instagram.android")
|
||||
|
||||
# Permission controller packages are Android system-level — not app-specific.
|
||||
# These will NEVER change with an Instagram update. Completely safe to check.
|
||||
system_dialog_pkgs = {
|
||||
"com.google.android.permissioncontroller",
|
||||
"com.android.permissioncontroller",
|
||||
@@ -341,23 +352,24 @@ class SituationalAwarenessEngine:
|
||||
logger.info("📱 [SAE Perceive] System permission dialog explicitly detected.")
|
||||
return SituationType.OBSTACLE_SYSTEM
|
||||
|
||||
# ── Foreign Environment Detection (package-based) ──
|
||||
# If the main app package is completely absent from the UI hierarchy,
|
||||
# OR if there's a dominant foreign package and no app package, we might have lost the app.
|
||||
|
||||
# If our app is on screen, we trust we are in the app (even if a custom keyboard is open).
|
||||
# We only trigger foreign app classification if our app is completely missing from the screen.
|
||||
is_foreign = False
|
||||
if packages and app_id not in packages:
|
||||
is_foreign = True
|
||||
# ── Foreign Environment Detection (package-based, zero maintenance) ──
|
||||
# If our app package is completely absent from the screen → foreign app.
|
||||
# Package names are Android-level identifiers, NOT Instagram UI elements.
|
||||
is_foreign = bool(packages) and app_id not in packages
|
||||
|
||||
if is_foreign:
|
||||
# We explicitly ask the TelepathicEngine to classify this to avoid writing brittle substring hacks
|
||||
# for Android System UI variations across different device manufacturers.
|
||||
# Any package that isn't our app AND isn't just systemui = foreign
|
||||
dominant_pkgs = packages - {"com.android.systemui"}
|
||||
if dominant_pkgs:
|
||||
logger.info(f"🚨 [SAE Perceive] Foreign package detected: {dominant_pkgs} → OBSTACLE_FOREIGN_APP")
|
||||
return SituationType.OBSTACLE_FOREIGN_APP
|
||||
|
||||
# SystemUI-only edge case: could be lock screen, notification shade, etc.
|
||||
# Use LLM for disambiguation.
|
||||
try:
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
cfg = self._get_model_config()
|
||||
screen_off = not getattr(self.device.deviceV2, "info", {}).get("screenOn", True)
|
||||
|
||||
prompt = (
|
||||
@@ -370,17 +382,9 @@ class SituationalAwarenessEngine:
|
||||
f"XML:\n{self._compress_xml(xml_dump)[:2500]}"
|
||||
)
|
||||
|
||||
args = {}
|
||||
try:
|
||||
args = Config().args
|
||||
except Exception:
|
||||
pass
|
||||
model = getattr(args, "ai_model", "qwen3.5:latest")
|
||||
url = getattr(args, "ai_model_url", "http://localhost:11434/api/generate")
|
||||
|
||||
res = query_telepathic_llm(
|
||||
model=model,
|
||||
url=url,
|
||||
model=cfg["model"],
|
||||
url=cfg["url"],
|
||||
system_prompt="Strict JSON classifier.",
|
||||
user_prompt=prompt,
|
||||
use_local_edge=True,
|
||||
@@ -391,89 +395,91 @@ class SituationalAwarenessEngine:
|
||||
situ_str = data.get("situation", "")
|
||||
|
||||
if situ_str == "OBSTACLE_LOCKED_SCREEN":
|
||||
logger.info("🧠 [Smart Perceive] SystemUI definitively classified as: LOCKED_SCREEN.")
|
||||
logger.info("🧠 [Smart Perceive] SystemUI classified as: LOCKED_SCREEN.")
|
||||
return SituationType.OBSTACLE_LOCKED_SCREEN
|
||||
elif situ_str == "OBSTACLE_SYSTEM":
|
||||
logger.info("🧠 [Smart Perceive] SystemUI definitively classified as: SYSTEM_DIALOG.")
|
||||
logger.info("🧠 [Smart Perceive] SystemUI classified as: SYSTEM_DIALOG.")
|
||||
return SituationType.OBSTACLE_SYSTEM
|
||||
else:
|
||||
logger.info("🧠 [Smart Perceive] SystemUI classified as: FOREIGN_APP / NOTIFICATION.")
|
||||
logger.info("🧠 [Smart Perceive] SystemUI classified as: FOREIGN_APP.")
|
||||
return SituationType.OBSTACLE_FOREIGN_APP
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Smart Perceive] LLM Classification failed ({e}). Defaulting to FOREIGN_APP.")
|
||||
return SituationType.OBSTACLE_FOREIGN_APP
|
||||
|
||||
# ── Modal/Obstacle Detection (Autonomous LLM + Memory) ──
|
||||
# We explicitly query ScreenMemoryDB. If unknown, we ask the LLM.
|
||||
# This replaces ALL brittle string/ID matching for modals.
|
||||
# ── In-App Obstacle Detection (100% autonomous, ZERO hardcoded UI identifiers) ──
|
||||
# The bot learns ALL obstacle types via the LLM + Qdrant feedback loop.
|
||||
# First encounter: LLM classifies → result is cached in Qdrant.
|
||||
# All subsequent encounters: instant O(1) recall from cache.
|
||||
from GramAddict.core.qdrant_memory import ScreenMemoryDB
|
||||
|
||||
screen_memory = ScreenMemoryDB()
|
||||
|
||||
compressed = self._compress_xml(xml_dump)
|
||||
|
||||
# ── Priority 1: Qdrant Semantic Cache (O(1), zero LLM calls) ──
|
||||
cached_type = screen_memory.get_screen_type(compressed)
|
||||
|
||||
if cached_type:
|
||||
if cached_type == "OBSTACLE_MODAL":
|
||||
return SituationType.OBSTACLE_MODAL
|
||||
elif cached_type == "DANGER_ACTION_BLOCKED":
|
||||
return SituationType.DANGER_ACTION_BLOCKED
|
||||
elif cached_type == "NORMAL":
|
||||
return SituationType.NORMAL
|
||||
|
||||
# ── Structural Fast-Check: Content-Creation Overlays ──
|
||||
# These full-screen overlays live INSIDE Instagram's package but block
|
||||
# all normal navigation. They are invisible to the foreign-app detector
|
||||
# and frequently fool the LLM into thinking they are "normal" browsing.
|
||||
# Detecting them structurally is O(1) and requires ZERO LLM calls.
|
||||
# This is checked AFTER Qdrant to ensure that if the LLM unlearned a false positive,
|
||||
# we respect the learned NORMAL state and don't infinite-loop.
|
||||
creation_flow_markers = (
|
||||
"quick_capture", # Camera / story capture overlay
|
||||
"gallery_cancel_button", # Story gallery "Back to Home" button
|
||||
"creation_flow", # Post creation wizard
|
||||
"reel_camera", # Reel recording interface
|
||||
)
|
||||
# ── Priority 2: ScreenIdentity structural delegation ──
|
||||
# ScreenIdentity classifies the screen using its own structural logic.
|
||||
# If it says MODAL → trust it (it uses the same zero-trust approach).
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
# Guard: Use the RAW xml_dump to avoid truncation of root containers (Z-index filtering),
|
||||
# but ensure we only match inside resource-id attributes to prevent false positives from user text.
|
||||
if any(
|
||||
re.search(rf'resource-id="[^"]*{marker}[^"]*"', xml_dump, re.IGNORECASE) for marker in creation_flow_markers
|
||||
):
|
||||
logger.info("🧠 [SAE Perceive] Content-creation overlay detected structurally → OBSTACLE_MODAL")
|
||||
screen_id = ScreenIdentity(getattr(self.device, "bot_username", ""))
|
||||
screen_result = screen_id.identify(xml_dump)
|
||||
screen_type = screen_result.get("screen_type", ScreenType.UNKNOWN)
|
||||
|
||||
if screen_type == ScreenType.MODAL:
|
||||
logger.info("🧠 [SAE Perceive] ScreenIdentity classified as MODAL → OBSTACLE_MODAL")
|
||||
screen_memory.store_screen(compressed, "OBSTACLE_MODAL")
|
||||
return SituationType.OBSTACLE_MODAL
|
||||
|
||||
# If not cached, query LLM for autonomous structural classification
|
||||
if screen_type == ScreenType.FOREIGN_APP:
|
||||
logger.info("🧠 [SAE Perceive] ScreenIdentity classified as FOREIGN_APP → OBSTACLE_FOREIGN_APP")
|
||||
return SituationType.OBSTACLE_FOREIGN_APP
|
||||
|
||||
if screen_type == ScreenType.DANGER_ACTION_BLOCKED:
|
||||
logger.info("🧠 [SAE Perceive] ScreenIdentity classified as DANGER_ACTION_BLOCKED")
|
||||
screen_memory.store_screen(compressed, "DANGER_ACTION_BLOCKED")
|
||||
return SituationType.DANGER_ACTION_BLOCKED
|
||||
|
||||
# If ScreenIdentity positively identified a known screen type (not UNKNOWN),
|
||||
# we trust it as NORMAL — no LLM needed.
|
||||
if screen_type != ScreenType.UNKNOWN:
|
||||
screen_memory.store_screen(compressed, "NORMAL")
|
||||
return SituationType.NORMAL
|
||||
|
||||
# ── Priority 3: LLM autonomous classification (first-encounter learning) ──
|
||||
# This is the ONLY path that reaches the LLM. After classification,
|
||||
# the result is cached in Qdrant — so this screen type is learned forever.
|
||||
try:
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
cfg = self._get_model_config()
|
||||
|
||||
prompt = (
|
||||
"You are a Situation Classifier for a mobile automation agent.\n"
|
||||
"Analyze the given Android UI XML dump AND screenshot. Is there a blocking MODAL, DIALOG, or POPUP "
|
||||
"covering the screen that needs to be dismissed, or is this a NORMAL usable screen?\n"
|
||||
"A 'clean_sheet_container' with standard Instagram feed content is NORMAL.\n"
|
||||
"A survey, rating prompt, 'not now' prompt, or permission dialog is an OBSTACLE_MODAL.\n"
|
||||
"An 'Add to story' screen, camera interface, 'quick_capture' layout, gallery picker, "
|
||||
"or ANY content-creation flow (reel recording, post editor, live mode) is an OBSTACLE_MODAL — "
|
||||
"it blocks normal navigation and must be dismissed.\n"
|
||||
"Respond ONLY with a valid JSON object strictly matching this schema: "
|
||||
'{"situation": "OBSTACLE_MODAL" | "NORMAL"}\n\n'
|
||||
"Analyze the given Android UI XML dump AND screenshot. Classify the screen into one of:\n"
|
||||
"- OBSTACLE_MODAL: Any blocking overlay, dialog, popup, survey, rating prompt, "
|
||||
"browser window, camera/creation flow, or any UI that blocks normal feed browsing.\n"
|
||||
"- DANGER_ACTION_BLOCKED: Instagram's rate-limit or action-block warning "
|
||||
"(e.g. 'Try Again Later', 'Action Blocked', or any restriction/verification screen).\n"
|
||||
"- NORMAL: A standard usable screen (feed, explore, profile, DM, etc.)\n\n"
|
||||
"Respond ONLY with a valid JSON object: "
|
||||
'{"situation": "OBSTACLE_MODAL" | "DANGER_ACTION_BLOCKED" | "NORMAL"}\n\n'
|
||||
f"XML:\n{compressed[:2500]}"
|
||||
)
|
||||
|
||||
args = {}
|
||||
try:
|
||||
args = Config().args
|
||||
except Exception:
|
||||
pass
|
||||
model = getattr(args, "ai_telepathic_model", "llava:latest")
|
||||
url = getattr(args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
|
||||
screenshot_b64 = getattr(self.device, "get_screenshot_b64", lambda: None)()
|
||||
res = query_telepathic_llm(
|
||||
model=model,
|
||||
url=url,
|
||||
model=cfg["telepathic_model"],
|
||||
url=cfg["telepathic_url"],
|
||||
system_prompt="Strict JSON classifier.",
|
||||
user_prompt=prompt,
|
||||
images_b64=[screenshot_b64] if screenshot_b64 else None,
|
||||
@@ -488,6 +494,10 @@ class SituationalAwarenessEngine:
|
||||
logger.info("🧠 [Smart Perceive] Screen classified as: OBSTACLE_MODAL.")
|
||||
screen_memory.store_screen(compressed, "OBSTACLE_MODAL")
|
||||
return SituationType.OBSTACLE_MODAL
|
||||
elif situ_str == "DANGER_ACTION_BLOCKED":
|
||||
logger.info("🧠 [Smart Perceive] Screen classified as: DANGER_ACTION_BLOCKED.")
|
||||
screen_memory.store_screen(compressed, "DANGER_ACTION_BLOCKED")
|
||||
return SituationType.DANGER_ACTION_BLOCKED
|
||||
else:
|
||||
logger.info("🧠 [Smart Perceive] Screen classified as: NORMAL.")
|
||||
screen_memory.store_screen(compressed, "NORMAL")
|
||||
@@ -517,16 +527,11 @@ class SituationalAwarenessEngine:
|
||||
LLM-powered escape planning for situations where structural scan fails.
|
||||
Called ONLY when recall AND structural planning both miss.
|
||||
"""
|
||||
from GramAddict.core.config import Config
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
try:
|
||||
args = Config().args
|
||||
model = getattr(args, "ai_telepathic_model", "llava:latest")
|
||||
url = getattr(args, "ai_telepathic_url", "http://localhost:11434/api/generate")
|
||||
except Exception:
|
||||
model = "llava:latest"
|
||||
url = "http://localhost:11434/api/generate"
|
||||
cfg = self._get_model_config()
|
||||
model = cfg["telepathic_model"]
|
||||
url = cfg["telepathic_url"]
|
||||
|
||||
system_prompt = (
|
||||
"You are an Android UI navigation agent. Your job is to escape obstacles "
|
||||
@@ -536,12 +541,12 @@ class SituationalAwarenessEngine:
|
||||
"- If you see a dismiss/close/cancel/skip/not now button, click it\n"
|
||||
"- If the Situation type is obstacle_locked_screen, action must be 'unlock'\n"
|
||||
"- If the Situation type is obstacle_foreign_app, action must be 'kill_foreign_apps'\n"
|
||||
"- If the Situation type is obstacle_system, you MUST look for 'Deny', 'Don't allow', or 'Cancel' and click it. \n"
|
||||
" NEVER click 'Allow', 'OK', or 'Confirm' on system permissions.\n"
|
||||
"- If the Situation type is obstacle_system, you MUST look for the negative action (e.g. deny, block, do not allow, cancel) and click it. \n"
|
||||
" NEVER click the positive action (allow, accept, confirm) on system permissions.\n"
|
||||
" If no negative action button exists, action must be 'back'\n"
|
||||
"- If there is NO obstacle and the screen is a normal Instagram view (false positive), action must be 'false_positive'\n"
|
||||
"- If nothing else works, suggest 'app_start' to force-reopen Instagram\n"
|
||||
"- NEVER click 'OK'/'Confirm'/'Accept' on surveys or prompts\n"
|
||||
"- NEVER click the positive/accept button on surveys or prompts\n"
|
||||
"- When you choose to click, you MUST use the EXACT coordinates provided in `center=(x,y)` for that element in the XML\n"
|
||||
'- Return ONLY valid JSON: {"action": "click"|"back"|"app_start"|"unlock"|"kill_foreign_apps"|"false_positive", "x": N, "y": N, "reason": "..."}'
|
||||
)
|
||||
@@ -689,6 +694,24 @@ class SituationalAwarenessEngine:
|
||||
|
||||
logger.warning(f"🔍 [SAE] Obstacle detected: {situation.value} (attempt {attempt + 1}/{max_attempts})")
|
||||
|
||||
# ── O(1) Fast-Path for Foreign Apps ──
|
||||
if situation == SituationType.OBSTACLE_FOREIGN_APP:
|
||||
logger.warning("⚡ [SAE Fast-Path] Foreign App detected. Bypassing LLM and killing immediately.")
|
||||
action = EscapeAction("kill_foreign_apps", reason="O(1) fast-path to eliminate foreign app")
|
||||
self._execute_escape(action)
|
||||
|
||||
# Check if we recovered
|
||||
post_xml = self.device.dump_hierarchy()
|
||||
if self.perceive(post_xml) == SituationType.NORMAL:
|
||||
logger.info("✅ [SAE Fast-Path] Foreign App cleared successfully!")
|
||||
self._consecutive_failures = 0
|
||||
return True
|
||||
|
||||
# If we didn't recover, log it and let the loop continue
|
||||
logger.warning("⚠️ [SAE Fast-Path] kill_foreign_apps did not return to NORMAL. Retrying...")
|
||||
self._consecutive_failures += 1
|
||||
continue
|
||||
|
||||
# ── COMPRESS for memory lookup ──
|
||||
compressed = self._compress_xml(xml_dump)
|
||||
|
||||
|
||||
@@ -85,6 +85,24 @@ class TelepathicEngine:
|
||||
filtered_candidates.append(c)
|
||||
candidates = filtered_candidates
|
||||
|
||||
# --- Active Keyboard Dismiss Guard ---
|
||||
# If keyboard is open but intent is NOT typing related -> dismiss it!
|
||||
intent_lower = intent_description.lower()
|
||||
if device and self._resolver.has_keyboard_open(candidates):
|
||||
typing_keywords = ["type", "message", "comment", "search", "write"]
|
||||
if not any(k in intent_lower for k in typing_keywords):
|
||||
logger.warning("⌨️ [TelepathicEngine] Keyboard detected during non-typing intent! Auto-dismissing.")
|
||||
import time
|
||||
|
||||
device.back()
|
||||
time.sleep(1.0)
|
||||
# Re-fetch UI state
|
||||
xml_string = device.dump_hierarchy()
|
||||
if xml_string:
|
||||
root = self._parser.parse(xml_string)
|
||||
if root:
|
||||
candidates = self._parser.get_clickable_nodes(root)
|
||||
|
||||
# 3. Resolve intent against candidates
|
||||
best_node = self._resolver.resolve(intent_description, candidates, device=device)
|
||||
|
||||
@@ -112,7 +130,8 @@ class TelepathicEngine:
|
||||
(best_node.text or "") + " " + (best_node.content_desc or "") + " " + (best_node.resource_id or "")
|
||||
)
|
||||
semantic = semantic.lower()
|
||||
if "following" in semantic or "gefolgt" in semantic or "requested" in semantic or "angefragt" in semantic:
|
||||
# Zero-Maintenance: Strictly structural IDs, no localized strings.
|
||||
if "button_following" in semantic or "profile_header_following" in semantic:
|
||||
return {"skip": True, "semantic": "already_followed"}
|
||||
|
||||
# 4. Track action
|
||||
@@ -132,6 +151,7 @@ class TelepathicEngine:
|
||||
"description": node.content_desc,
|
||||
"id": node.resource_id,
|
||||
"class": node.class_name,
|
||||
"semantic": node.content_desc or node.text or node.resource_id,
|
||||
"original_attribs": node.to_dict(),
|
||||
}
|
||||
|
||||
@@ -182,13 +202,19 @@ class TelepathicEngine:
|
||||
if not root:
|
||||
return None
|
||||
|
||||
# Find grid-like visual nodes
|
||||
# Find grid-like visual nodes using structural markers
|
||||
all_nodes = self._parser.get_all_nodes(root)
|
||||
grid_candidates = [
|
||||
n
|
||||
for n in all_nodes
|
||||
if not n.text and ("photo" in n.content_desc.lower() or "video" in n.content_desc.lower() or n.area > 50000)
|
||||
]
|
||||
grid_candidates = []
|
||||
for n in all_nodes:
|
||||
rid = (n.resource_id or "").lower()
|
||||
desc = (n.content_desc or "").lower()
|
||||
|
||||
# Structural anchor for Instagram grid items
|
||||
if "grid_card_layout_container" in rid:
|
||||
grid_candidates.append(n)
|
||||
# Fallback for older versions or profile grids if resource-id is missing
|
||||
elif not n.text and ("photo" in desc or "video" in desc) and (50000 < n.area < 400000):
|
||||
grid_candidates.append(n)
|
||||
|
||||
best = self._evaluator.evaluate_grid_visuals(device, persona_interests, grid_candidates)
|
||||
if best:
|
||||
|
||||
@@ -65,6 +65,20 @@ def _run_zero_latency_unfollow_loop(
|
||||
try:
|
||||
xml_dump = device.dump_hierarchy()
|
||||
|
||||
# ── Perimeter Guard: Verify we're still inside Instagram ──
|
||||
if xml_dump:
|
||||
import re
|
||||
|
||||
unfollow_packages = set(re.findall(r'package="([^"]+)"', xml_dump))
|
||||
unfollow_app_id = getattr(device, "app_id", "com.instagram.android")
|
||||
if unfollow_packages and unfollow_app_id not in unfollow_packages:
|
||||
logger.error(
|
||||
f"🚨 [UnfollowLoop] FOREIGN APP DETECTED! Packages: {unfollow_packages}. Aborting loop."
|
||||
)
|
||||
device.press("back")
|
||||
random_sleep(1.0, 1.5)
|
||||
return "CONTEXT_LOST"
|
||||
|
||||
# Autonomously identify user rows via Semantic Extraction
|
||||
telepathic = cognitive_stack.get("telepathic")
|
||||
nodes = []
|
||||
@@ -91,7 +105,7 @@ def _run_zero_latency_unfollow_loop(
|
||||
|
||||
# 2. Close Friend Guard
|
||||
profile_text = profile_xml.lower()
|
||||
if "enge freunde" in profile_text or "close friend" in profile_text:
|
||||
if "close friend" in profile_text:
|
||||
logger.info(
|
||||
"💚 [Anti-Friend] Profile is a Close Friend. Skipping unfollow.", extra={"color": Fore.GREEN}
|
||||
)
|
||||
@@ -113,19 +127,23 @@ def _run_zero_latency_unfollow_loop(
|
||||
extra={"color": Fore.YELLOW},
|
||||
)
|
||||
|
||||
# Find 'Following' button on their profile
|
||||
# Find the button that indicates active subscription (Following)
|
||||
following_nodes = telepathic._extract_semantic_nodes(
|
||||
profile_xml, "find 'Following' button", threshold=0.7
|
||||
profile_xml,
|
||||
"find the button indicating active subscription, look for id 'button_following' or 'profile_header_following'",
|
||||
threshold=0.7,
|
||||
)
|
||||
if following_nodes and not following_nodes[0].get("skip"):
|
||||
f_node = following_nodes[0]
|
||||
_humanized_click(device, f_node["x"], f_node["y"])
|
||||
random_sleep(1.0, 2.0)
|
||||
|
||||
# Find 'Unfollow' confirm
|
||||
# Find the confirmation button
|
||||
confirm_xml = device.dump_hierarchy()
|
||||
confirm_nodes = telepathic._extract_semantic_nodes(
|
||||
confirm_xml, "find 'Unfollow' confirmation button", threshold=0.8
|
||||
confirm_xml,
|
||||
"find the confirmation button to stop following, look for id 'follow_sheet_unfollow_row' or red warning text",
|
||||
threshold=0.8,
|
||||
)
|
||||
|
||||
if confirm_nodes and not confirm_nodes[0].get("skip"):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from time import sleep
|
||||
@@ -100,11 +100,12 @@ def get_value(count, name, default=0):
|
||||
_LEARNED_AD_MARKERS_FILE = os.path.join(os.getcwd(), "learned_ad_markers.json")
|
||||
_LEARNED_AD_MARKERS_CACHE = None
|
||||
|
||||
|
||||
def get_learned_ad_markers() -> set:
|
||||
global _LEARNED_AD_MARKERS_CACHE
|
||||
if _LEARNED_AD_MARKERS_CACHE is not None:
|
||||
return _LEARNED_AD_MARKERS_CACHE
|
||||
|
||||
|
||||
if os.path.exists(_LEARNED_AD_MARKERS_FILE):
|
||||
try:
|
||||
with open(_LEARNED_AD_MARKERS_FILE, "r") as f:
|
||||
@@ -114,18 +115,20 @@ def get_learned_ad_markers() -> set:
|
||||
_LEARNED_AD_MARKERS_CACHE = set()
|
||||
else:
|
||||
_LEARNED_AD_MARKERS_CACHE = set()
|
||||
|
||||
|
||||
return _LEARNED_AD_MARKERS_CACHE
|
||||
|
||||
|
||||
def learn_ad_marker(marker: str, xml_hierarchy: str):
|
||||
global _LEARNED_AD_MARKERS_CACHE
|
||||
if not marker or len(marker) > 30:
|
||||
return
|
||||
|
||||
|
||||
marker = marker.strip().lower()
|
||||
|
||||
|
||||
# Structural verification: the VLM-suggested marker MUST exist as an exact node text/desc in the current UI!
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_hierarchy)
|
||||
found_in_ui = False
|
||||
@@ -135,17 +138,22 @@ def learn_ad_marker(marker: str, xml_hierarchy: str):
|
||||
if text == marker or desc == marker:
|
||||
found_in_ui = True
|
||||
break
|
||||
|
||||
|
||||
if not found_in_ui:
|
||||
logger.debug(f"🧠 [Autonomous FSD] Rejected hallucinated Ad marker '{marker}' (not found as exact node match in UI).")
|
||||
logger.debug(
|
||||
f"🧠 [Autonomous FSD] Rejected hallucinated Ad marker '{marker}' (not found as exact node match in UI)."
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
markers = get_learned_ad_markers()
|
||||
if marker not in markers and marker not in {"ad", "sponsored", "advertisement", "gesponsert", "anzeige", "werbung"}:
|
||||
if marker not in markers and marker not in {"ad", "sponsored", "advertisement"}:
|
||||
markers.add(marker)
|
||||
logger.info(f"🧠 [Autonomous FSD] Verified and Learned new Ad marker: '{marker}'. Persisting for zero-latency detection.", extra={"color": f"{Style.BRIGHT}{Fore.GREEN}"})
|
||||
logger.info(
|
||||
f"🧠 [Autonomous FSD] Verified and Learned new Ad marker: '{marker}'. Persisting for zero-latency detection.",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.GREEN}"},
|
||||
)
|
||||
try:
|
||||
with open(_LEARNED_AD_MARKERS_FILE, "w") as f:
|
||||
json.dump(list(markers), f)
|
||||
@@ -182,14 +190,15 @@ def is_ad(xml_hierarchy: str, cognitive_stack: dict = None) -> bool:
|
||||
|
||||
# Standalone label patterns: match only when the text/desc IS the ad marker,
|
||||
# not when "ad" appears inside longer phrases like "Create messaging ad"
|
||||
AD_EXACT_LABELS = {"ad", "sponsored", "advertisement", "gesponsert", "anzeige", "werbung"}
|
||||
AD_EXACT_LABELS = {"ad", "sponsored", "advertisement"}
|
||||
AD_EXACT_LABELS.update(get_learned_ad_markers())
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_hierarchy)
|
||||
|
||||
|
||||
# Check if we are in a feed (to prevent false positives on profiles with 'Ad Tools' buttons)
|
||||
from GramAddict.core.perception.feed_analysis import FEED_MARKERS
|
||||
|
||||
in_feed = any(marker in xml_hierarchy for marker in FEED_MARKERS)
|
||||
|
||||
for node in root.iter("node"):
|
||||
|
||||
13
README.md
13
README.md
@@ -63,3 +63,16 @@ The engine has undergone a massive stabilization refactor to achieve **100% TDD
|
||||
|
||||
> [!NOTE]
|
||||
> Unlike legacy bots, GramPilot requires zero maintenance. It will automatically re-learn the UI over time using its integrated Qdrant memory vectors.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Core Architecture Rules: Language Agnosticism & Structural Determinism
|
||||
|
||||
**CRITICAL: Hardcoding localized UI strings (e.g., "Follow", "Like", "Send", "Report") is STRICTLY FORBIDDEN across the entire codebase.**
|
||||
|
||||
GramPilot operates on a globally language-agnostic plane. If the UI is switched to German, Arabic, or Japanese, the bot must not fail. To achieve this, all perception and navigation logic must adhere to the following strict rules:
|
||||
|
||||
1. **Spatial Geometry over Text:** Use coordinates and boundaries to identify elements. (e.g., The "Send" button in DMs is *always* on the far right `center_x > width * 0.75`).
|
||||
2. **Structural Resource IDs over Descriptions:** Use Android UI resource IDs (`action_sheet_row_text_view`, `button_following`) instead of English `desc` or `text` properties. IDs are not localized and are universally stable.
|
||||
3. **Semantic LLM Prompts:** When querying the Vision-Language Model (VLM), **do not give exact string examples** that assume an English UI. For example, do not say `find the 'Unfollow' button`. Instead, say `find the button to stop following, look for a red warning text or id 'follow_sheet_unfollow_row'`.
|
||||
4. **Zero Magie:** Any text-based matching logic is considered a legacy flaw and will be mercilessly purged. If you find a text match, replace it with a structural O(1) fast-path or spatial guard.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import os
|
||||
import glob
|
||||
import re
|
||||
|
||||
for file in glob.glob("tests/e2e/test_workflow_*.py"):
|
||||
with open(file, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Remove FakeSAENormal class definition
|
||||
content = re.sub(r'class FakeSAENormal:\n(?: {4}.*\n)+', '', content)
|
||||
|
||||
# Remove monkeypatch.setattr for SituationalAwarenessEngine
|
||||
content = re.sub(r' +monkeypatch\.setattr\(\n +["\']GramAddict\.core\.behaviors\.obstacle_guard\.SituationalAwarenessEngine["\'],\n +FakeSAENormal,?\n +\)\n', '', content)
|
||||
# Also inline ones
|
||||
content = re.sub(r' +monkeypatch\.setattr\("GramAddict\.core\.behaviors\.obstacle_guard\.SituationalAwarenessEngine", FakeSAENormal\)\n', '', content)
|
||||
|
||||
with open(file, "w") as f:
|
||||
f.write(content)
|
||||
print("Removed FakeSAENormal from all tests")
|
||||
@@ -1,33 +0,0 @@
|
||||
from GramAddict.core.llm_provider import query_telepathic_llm
|
||||
|
||||
xml = """
|
||||
<node package="com.instagram.android">
|
||||
<node resource-id="com.instagram.android:id/gallery_cancel_button" bounds="[10,10][20,20]" />
|
||||
<node resource-id="com.instagram.android:id/feed_tab" selected="true" bounds="[0,0][1080,2400]" />
|
||||
</node>
|
||||
"""
|
||||
|
||||
system_prompt = (
|
||||
"You are an Android UI navigation agent. Your job is to escape obstacles "
|
||||
"(dialogs, modals, foreign apps, system popups) and return to Instagram. "
|
||||
"Analyze the screen content (Screenshot AND XML) and return a JSON escape action.\n\n"
|
||||
"Rules:\n"
|
||||
"- If you see a dismiss/close/cancel/skip/not now button, click it\n"
|
||||
"- If the Situation type is OBSTACLE_LOCKED_SCREEN, action must be 'unlock'\n"
|
||||
"- If the Situation type is OBSTACLE_FOREIGN_APP, action must be 'back'\n"
|
||||
"- If there is NO obstacle and the screen is a normal Instagram view (false positive), action must be 'false_positive'\n"
|
||||
"- 'reason' must explain why.\n"
|
||||
'Output ONLY valid JSON matching: {"action": "click"|"back"|"unlock"|"false_positive", "x": int, "y": int, "reason": str}'
|
||||
)
|
||||
user_prompt = f"Situation: OBSTACLE_MODAL\n\nXML Hierarchy:\n{xml}\n\nWhat action should I take to clear this obstacle and return to Instagram? Return JSON only."
|
||||
|
||||
print(
|
||||
query_telepathic_llm(
|
||||
url="http://localhost:11434/api/generate",
|
||||
model="llava:latest",
|
||||
user_prompt=user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
images_b64=None,
|
||||
temperature=0.0,
|
||||
)
|
||||
)
|
||||
BIN
tests/.DS_Store
vendored
Normal file
BIN
tests/.DS_Store
vendored
Normal file
Binary file not shown.
56
tests/core/test_action_memory_fast_path.py
Normal file
56
tests/core/test_action_memory_fast_path.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from GramAddict.core.perception.action_memory import ActionMemory
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
|
||||
def test_screen_identity_detects_other_profile_with_missing_header_container():
|
||||
"""
|
||||
RED: ScreenIdentity used to misclassify OTHER_PROFILE as UNKNOWN or POST_DETAIL
|
||||
if `profile_header_container` was missing, even though `row_profile_header_imageview`
|
||||
was present.
|
||||
GREEN: We added row_profile_header_imageview and profile_tabs_container.
|
||||
"""
|
||||
identity = ScreenIdentity(bot_username="my_bot")
|
||||
|
||||
# Simulate a profile screen that is missing the main container but has the imageview
|
||||
xml_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/row_profile_header_imageview" bounds="[0,0][100,100]" clickable="true"/>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tabs_container" bounds="[0,200][1080,300]"/>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/action_bar_title" text="justkay"/>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
result = identity.identify(xml_dump)
|
||||
assert result["screen_type"] == ScreenType.OTHER_PROFILE
|
||||
|
||||
|
||||
def test_action_memory_verifies_profile_navigation_in_o1_without_vlm(monkeypatch):
|
||||
"""
|
||||
RED: ActionMemory.verify_success used to fallback to VLM because navigating to a profile
|
||||
caused a huge delta, but there was no explicit fast-path for 'profile', causing it
|
||||
to hit `confidence < 0.95` and invoke `evaluator._query_vlm`.
|
||||
GREEN: It now structurally verifies `profile_header_container` instantly.
|
||||
"""
|
||||
memory = ActionMemory()
|
||||
|
||||
class DummyDevice:
|
||||
def get_screenshot_b64(self):
|
||||
raise Exception("VLM SHOULD NOT BE CALLED!")
|
||||
|
||||
device = DummyDevice()
|
||||
|
||||
intent = "tap post username"
|
||||
pre_xml = "<hierarchy><node/></hierarchy>"
|
||||
# Post XML contains profile_header_container!
|
||||
post_xml = "<hierarchy><node resource-id='com.instagram.android:id/profile_header_container'/></hierarchy>"
|
||||
|
||||
# If the fast-path works, it will return True instantly and NOT call get_screenshot_b64
|
||||
success = memory.verify_success(
|
||||
intent=intent,
|
||||
pre_click_xml=pre_xml,
|
||||
post_click_xml=post_xml,
|
||||
device=device,
|
||||
confidence=0.0, # low confidence triggers VLM fallback if fast-path is missing
|
||||
)
|
||||
|
||||
assert success is True
|
||||
51
tests/core/test_intent_resolver_multilingual.py
Normal file
51
tests/core/test_intent_resolver_multilingual.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
|
||||
def test_semantic_guard_allows_multilingual_follow_button():
|
||||
"""
|
||||
RED: The IntentResolver's Semantic Guard used to hard-filter for EXACT quotes.
|
||||
If the plugin requested "tap 'Follow' button", but the UI was in German ("Abonnieren"),
|
||||
the Semantic Guard would block it, causing the bot to never follow anyone.
|
||||
GREEN: We added multilingual equivalents to the Semantic Guard logic.
|
||||
"""
|
||||
resolver = IntentResolver()
|
||||
|
||||
intent = "tap 'Follow' button"
|
||||
|
||||
# Create a node that represents a German follow button
|
||||
german_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/profile_header_follow_button",
|
||||
content_desc="Abonnieren",
|
||||
text="Abonnieren",
|
||||
bounds=(0, 0, 100, 100),
|
||||
)
|
||||
|
||||
# Run the resolver without a device (forces semantic/structural resolution, bypasses VLM)
|
||||
result = resolver.resolve(intent, candidates=[german_node], device=None, screen_height=2000)
|
||||
|
||||
assert result is not None
|
||||
assert result.text == "Abonnieren"
|
||||
|
||||
|
||||
def test_semantic_guard_allows_multilingual_following_button():
|
||||
"""
|
||||
Ensures that "tap 'Following' button" resolves correctly for German UIs
|
||||
("Abonniert", "Gefolgt", "Angefragt").
|
||||
"""
|
||||
resolver = IntentResolver()
|
||||
|
||||
intent = "tap 'Following' button"
|
||||
|
||||
# Create a node that represents a German "Following" button
|
||||
german_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/profile_header_follow_button",
|
||||
content_desc="Abonniert",
|
||||
text="Abonniert",
|
||||
bounds=(0, 0, 100, 100),
|
||||
)
|
||||
|
||||
result = resolver.resolve(intent, candidates=[german_node], device=None, screen_height=2000)
|
||||
|
||||
assert result is not None
|
||||
assert result.text == "Abonniert"
|
||||
147
tests/core/test_zero_maintenance_strings.py
Normal file
147
tests/core/test_zero_maintenance_strings.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
TDD Tests: Zero-Maintenance String Compliance
|
||||
|
||||
These tests enforce that no hardcoded German/localized strings
|
||||
exist in navigation-critical code paths. The bot must rely
|
||||
exclusively on structural resource_id patterns, never on
|
||||
localized UI text that changes with device language.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 1. TOGGLE_INTENT_MARKERS must be language-agnostic
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestToggleIntentMarkersAreLanguageAgnostic:
|
||||
"""Ensure TOGGLE_INTENT_MARKERS contains zero localized strings."""
|
||||
|
||||
GERMAN_STRINGS = [
|
||||
"gefällt",
|
||||
"gefolgt",
|
||||
"abonnieren",
|
||||
"speichern",
|
||||
"gespeichert",
|
||||
"antworten",
|
||||
"kommentar",
|
||||
"beitrag",
|
||||
]
|
||||
|
||||
def test_no_german_strings_in_toggle_markers(self):
|
||||
from GramAddict.core.perception.action_memory import TOGGLE_INTENT_MARKERS
|
||||
|
||||
for intent_key, markers in TOGGLE_INTENT_MARKERS.items():
|
||||
for marker in markers:
|
||||
assert marker.lower() not in [
|
||||
g.lower() for g in self.GERMAN_STRINGS
|
||||
], f"TOGGLE_INTENT_MARKERS['{intent_key}'] contains German string '{marker}'!"
|
||||
|
||||
def test_markers_only_contain_english_or_resource_id_patterns(self):
|
||||
"""All markers must be English words or resource_id fragments."""
|
||||
from GramAddict.core.perception.action_memory import TOGGLE_INTENT_MARKERS
|
||||
|
||||
allowed_pattern = re.compile(r"^[a-z_]+$")
|
||||
for intent_key, markers in TOGGLE_INTENT_MARKERS.items():
|
||||
for marker in markers:
|
||||
assert allowed_pattern.match(
|
||||
marker
|
||||
), f"Marker '{marker}' in '{intent_key}' contains non-ASCII or non-ID characters!"
|
||||
|
||||
def test_intent_match_rejects_reel_message_composer(self):
|
||||
"""The semantic guard must reject reel message composer for 'like' intent."""
|
||||
from GramAddict.core.perception.action_memory import _intent_matches_node
|
||||
|
||||
# This was the exact production failure: VLM picked the message composer
|
||||
semantic = "text: 'Send message', desc: '', id: 'com.instagram.android:id/reel_viewer_message_composer_text'"
|
||||
assert _intent_matches_node("tap like button", semantic) is False
|
||||
|
||||
def test_intent_match_accepts_real_like_button(self):
|
||||
"""The semantic guard must accept a real like button by resource_id."""
|
||||
from GramAddict.core.perception.action_memory import _intent_matches_node
|
||||
|
||||
semantic = "text: '', desc: 'Like', id: 'com.instagram.android:id/row_feed_button_like'"
|
||||
assert _intent_matches_node("tap like button", semantic) is True
|
||||
|
||||
def test_intent_match_accepts_like_button_by_id_only(self):
|
||||
"""Even without text/desc, resource_id containing 'like' is enough."""
|
||||
from GramAddict.core.perception.action_memory import _intent_matches_node
|
||||
|
||||
semantic = "text: '', desc: '', id: 'com.instagram.android:id/row_feed_button_like'"
|
||||
assert _intent_matches_node("tap like button", semantic) is True
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 2. TelepathicEngine Following Guard must be structural
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestTelepathicEngineFollowingGuardIsStructural:
|
||||
"""The 'already followed' guard must not rely on German strings."""
|
||||
|
||||
def test_no_german_in_following_guard_source(self):
|
||||
"""Scan telepathic_engine.py for any German follow-state strings."""
|
||||
import inspect
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
source = inspect.getsource(TelepathicEngine)
|
||||
german_terms = ["gefolgt", "angefragt", "abonniert", "abonnieren"]
|
||||
for term in german_terms:
|
||||
assert (
|
||||
term not in source
|
||||
), f"TelepathicEngine source contains German string '{term}'!"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 3. DarwinEngine comment detection must be structural
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestDarwinEngineCommentDetectionIsStructural:
|
||||
"""The _has_comments heuristic must not rely on German strings."""
|
||||
|
||||
def test_no_german_in_has_comments_source(self):
|
||||
import inspect
|
||||
|
||||
from GramAddict.core.darwin_engine import DarwinEngine
|
||||
|
||||
source = inspect.getsource(DarwinEngine._has_comments)
|
||||
german_terms = ["kommentar", "ansehen"]
|
||||
for term in german_terms:
|
||||
assert (
|
||||
term not in source
|
||||
), f"DarwinEngine._has_comments contains German string '{term}'!"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 4. ResonanceEngine comment filtering must be structural
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestResonanceEngineCommentFilteringIsStructural:
|
||||
"""Comment extraction blocked_exact list must not contain German strings."""
|
||||
|
||||
def test_no_german_in_resonance_source(self):
|
||||
import inspect
|
||||
|
||||
from GramAddict.core.resonance_engine import ResonanceEngine
|
||||
|
||||
source = inspect.getsource(ResonanceEngine.extract_and_learn_comments)
|
||||
german_terms = [
|
||||
"antworten",
|
||||
"gefällt mir",
|
||||
"antworten ansehen",
|
||||
"übersetzung anzeigen",
|
||||
"antworten verbergen",
|
||||
"alle kommentare ansehen",
|
||||
"absenden",
|
||||
"gehe zu",
|
||||
"tippe auf",
|
||||
"aktionen für diesen beitrag",
|
||||
]
|
||||
for term in german_terms:
|
||||
assert (
|
||||
term not in source
|
||||
), f"ResonanceEngine.extract_and_learn_comments contains German '{term}'!"
|
||||
@@ -247,7 +247,8 @@ class InstagramEmulator:
|
||||
# Could implement swipe transitions here (e.g. scroll down loads new feed)
|
||||
|
||||
def app_start(self, pkg, use_monkey=False):
|
||||
pass
|
||||
self.app_starts.append(pkg)
|
||||
logger.info(f"[Emulator] app_start({pkg})")
|
||||
|
||||
def app_stop(self, pkg):
|
||||
pass
|
||||
|
||||
@@ -41,7 +41,7 @@ def test_story_view_clicks_story_ring(make_real_device_with_image, e2e_configs,
|
||||
# 3. goap verification post-click (xml_after)
|
||||
# 4. Fallbacks/extras (xml_after, xml_after)
|
||||
device = make_real_device_with_image(
|
||||
"tests/fixtures/home_feed_with_ad.jpg", [xml_before, xml_before, xml_after, xml_after, xml_after]
|
||||
"tests/fixtures/home_feed_with_ad.jpg", [xml_before, xml_after, xml_after, xml_after]
|
||||
)
|
||||
|
||||
from GramAddict.core.session_state import SessionState
|
||||
|
||||
133
tests/e2e/test_goap_meta_ai_comment.py
Normal file
133
tests/e2e/test_goap_meta_ai_comment.py
Normal file
@@ -0,0 +1,133 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.live_llm
|
||||
def test_goap_meta_ai_comment_selection(make_real_device_with_image):
|
||||
"""
|
||||
TDD Test: Verifies that when the comment keyboard is open and Meta AI chips
|
||||
are present on the screen, the GOAP engine detects them and uses the VLM
|
||||
to select the best tone chip, entirely bypassing manual fallback typing.
|
||||
"""
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
|
||||
# Define an XML hierarchy that simulates the comment keyboard open with Meta AI chips.
|
||||
xml_chip_first = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node package="com.instagram.android" class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
|
||||
<!-- Meta AI Chips FIRST so they become box 0 for the VLM mock -->
|
||||
<node class="android.widget.TextView" text="Funny" bounds="[450,1150][700,1250]" clickable="true" />
|
||||
<node class="android.widget.TextView" text="Supportive" bounds="[100,1150][400,1250]" clickable="true" />
|
||||
<node class="android.widget.TextView" text="Rewrite" bounds="[750,1150][950,1250]" clickable="true" />
|
||||
|
||||
<!-- The comment composer input field -->
|
||||
<node resource-id="com.instagram.android:id/layout_comment_thread_edittext" text="Add a comment..." bounds="[100,1000][900,1100]" clickable="true" />
|
||||
<!-- The Post button -->
|
||||
<node resource-id="com.instagram.android:id/layout_comment_thread_button_post" text="Post" bounds="[900,1000][1000,1100]" clickable="true" />
|
||||
</node>
|
||||
|
||||
<!-- Keyboard is open -->
|
||||
<node package="com.google.android.inputmethod.latin" class="android.widget.FrameLayout" bounds="[0,1500][1080,2400]">
|
||||
<node resource-id="com.google.android.inputmethod.latin:id/B00" content-desc="Q" bounds="[0,1800][100,1900]" clickable="true" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
xml_post_first = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node package="com.instagram.android" class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
|
||||
<!-- Post button FIRST so it becomes box 0 for the VLM mock -->
|
||||
<node resource-id="com.instagram.android:id/layout_comment_thread_button_post" text="Post" bounds="[900,1000][1000,1100]" clickable="true" />
|
||||
<!-- The comment composer input field -->
|
||||
<node resource-id="com.instagram.android:id/layout_comment_thread_edittext" text="Add a comment..." bounds="[100,1000][900,1100]" clickable="true" />
|
||||
|
||||
<!-- Meta AI Chips -->
|
||||
<node class="android.widget.TextView" text="Funny" bounds="[450,1150][700,1250]" clickable="true" />
|
||||
<node class="android.widget.TextView" text="Supportive" bounds="[100,1150][400,1250]" clickable="true" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
# We provide this XML twice: once for the initial check (picks chip), once for the 'Post' button click.
|
||||
device = make_real_device_with_image(
|
||||
"tests/fixtures/home_feed_with_ad.jpg", [xml_chip_first, xml_post_first, xml_post_first]
|
||||
)
|
||||
|
||||
# To track if ghost_type was incorrectly called
|
||||
type_text_calls = []
|
||||
|
||||
def mock_ghost_type(dev, text, speed="normal"):
|
||||
type_text_calls.append(text)
|
||||
|
||||
# Monkeypatch the module where ghost_type is imported, or just the whole module.
|
||||
# Actually, goap.py imports ghost_type dynamically:
|
||||
# `from GramAddict.core.stealth_typing import ghost_type`
|
||||
# So we monkeypatch it in stealth_typing
|
||||
import GramAddict.core.stealth_typing
|
||||
|
||||
original_ghost_type = GramAddict.core.stealth_typing.ghost_type
|
||||
GramAddict.core.stealth_typing.ghost_type = mock_ghost_type
|
||||
|
||||
import GramAddict.core.telepathic_engine
|
||||
|
||||
original_find_best_node = GramAddict.core.telepathic_engine.TelepathicEngine.find_best_node
|
||||
|
||||
def mock_find_best_node(self, xml_dump, instruction, *args, **kwargs):
|
||||
# We parse the XML to return a specific node based on the instruction
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = ET.fromstring(xml_dump.encode("utf-8"))
|
||||
|
||||
def _enrich_node(node):
|
||||
attribs = dict(node.attrib)
|
||||
bounds_str = attribs.get("bounds", "")
|
||||
match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str)
|
||||
if match:
|
||||
left, top, right, bottom = map(int, match.groups())
|
||||
attribs["x"] = (left + right) // 2
|
||||
attribs["y"] = (top + bottom) // 2
|
||||
return attribs
|
||||
|
||||
if "tap the best Meta AI tone chip" in instruction:
|
||||
for node in root.iter("node"):
|
||||
if node.attrib.get("text") == "Funny":
|
||||
return _enrich_node(node)
|
||||
return None
|
||||
elif "tap post comment button" in instruction:
|
||||
for node in root.iter("node"):
|
||||
if node.attrib.get("text") == "Post":
|
||||
return _enrich_node(node)
|
||||
return None
|
||||
return None
|
||||
|
||||
GramAddict.core.telepathic_engine.TelepathicEngine.find_best_node = mock_find_best_node
|
||||
|
||||
try:
|
||||
goap = GoalExecutor.get_instance(device, bot_username="testuser")
|
||||
|
||||
# Execute the new GOAP action
|
||||
result = goap._execute_action("type and post comment", text="This is a fallback text")
|
||||
finally:
|
||||
# Cleanup mocks
|
||||
GramAddict.core.stealth_typing.ghost_type = original_ghost_type
|
||||
GramAddict.core.telepathic_engine.TelepathicEngine.find_best_node = original_find_best_node
|
||||
|
||||
# Assertions
|
||||
assert result is True, "GOAP action 'type and post comment' failed."
|
||||
|
||||
# We expect 2 clicks:
|
||||
# 1. Tapping one of the Meta AI chips (y between 1150 and 1250)
|
||||
# 2. Tapping the "Post" button (y between 1000 and 1100)
|
||||
# (Since keyboard is already open, it shouldn't tap the input field)
|
||||
assert len(device.clicks) >= 2, f"Expected at least 2 clicks (Meta AI chip + Post), got {len(device.clicks)}"
|
||||
|
||||
print(f"DEBUG: device.clicks = {device.clicks}")
|
||||
# Ensure a Meta AI chip was clicked (y between 1150 and 1250)
|
||||
chip_clicked = any(1150 <= y <= 1250 for (x, y) in device.clicks)
|
||||
assert chip_clicked, f"VLM failed to select and click a Meta AI chip! Clicks were: {device.clicks}"
|
||||
|
||||
# Ensure manual typing was bypassed!
|
||||
assert len(type_text_calls) == 0, f"Expected 0 ghost_type calls, but got: {type_text_calls}"
|
||||
|
||||
# Cleanup
|
||||
GramAddict.core.stealth_typing.ghost_type = original_ghost_type
|
||||
@@ -34,7 +34,7 @@ def test_goap_recovers_from_trapped_state_with_restart(monkeypatch):
|
||||
# We just want to see that AFTER the restart, it tries 'tap reels tab' again,
|
||||
# meaning it cleared the state.
|
||||
|
||||
goap.achieve("open reels", max_steps=6)
|
||||
goap.achieve("open reels", max_steps=10)
|
||||
|
||||
# It should have tried 'tap reels tab' twice, failed both times,
|
||||
# then triggered 'force start instagram'.
|
||||
|
||||
34
tests/e2e/test_intent_report_guard.py
Normal file
34
tests/e2e/test_intent_report_guard.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import os
|
||||
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
from GramAddict.core.perception.spatial_parser import SpatialParser
|
||||
|
||||
|
||||
def test_intent_resolver_filters_report_menu():
|
||||
"""
|
||||
Proves that the intent resolver filters out the 'More actions' / 3-dots menu
|
||||
when the bot is looking for the post author, preventing it from clicking 'Report'.
|
||||
"""
|
||||
base_dir = os.path.dirname(os.path.dirname(__file__))
|
||||
feed_xml_path = os.path.join(base_dir, "e2e", "fixtures", "home_feed_real.xml")
|
||||
|
||||
with open(feed_xml_path, "r", encoding="utf-8") as f:
|
||||
feed_xml = f.read()
|
||||
|
||||
parser = SpatialParser()
|
||||
root = parser.parse(feed_xml)
|
||||
candidates = parser.get_clickable_nodes(root)
|
||||
|
||||
resolver = IntentResolver()
|
||||
|
||||
# Run the filter explicitly for "tap post username"
|
||||
filtered = resolver.filter_navigation_conflicts(candidates, "tap post username", screen_height=2400)
|
||||
|
||||
# Look for the 'More actions' option button in the filtered results
|
||||
for node in filtered:
|
||||
rid = (node.resource_id or "").lower()
|
||||
desc = (node.content_desc or "").lower()
|
||||
|
||||
# It must NOT be in the filtered list!
|
||||
assert "option_button" not in rid, f"Option button leaked into candidates! {rid}"
|
||||
assert "more actions" not in desc, f"More actions leaked into candidates! {desc}"
|
||||
259
tests/e2e/test_keyboard_guard.py
Normal file
259
tests/e2e/test_keyboard_guard.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Keyboard Contamination Guard — TDD Tests
|
||||
|
||||
Production Bug 2026-05-04: The VLM clicked on the comment composer text view,
|
||||
which opened the Android keyboard. All subsequent intents became poisoned because
|
||||
keyboard key nodes (com.google.android.inputmethod.latin) flooded the candidate
|
||||
list with 30+ single-character entries (A, B, C, N, M, ...).
|
||||
|
||||
The VLM then hallucinated 'N' as the "post username" and clicked it.
|
||||
|
||||
These tests enforce:
|
||||
1. Keyboard nodes are NEVER included in visual discovery candidates
|
||||
2. The resolver can detect when a keyboard is open
|
||||
3. When keyboard is present, non-keyboard candidates still resolve correctly
|
||||
"""
|
||||
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Fixtures: Keyboard-Contaminated Candidate Lists
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _make_keyboard_nodes() -> list[SpatialNode]:
|
||||
"""Generates realistic Android soft-keyboard nodes that pollute the candidate pool."""
|
||||
keys = [
|
||||
("Q", "com.google.android.inputmethod.latin:id/B00"),
|
||||
("W", "com.google.android.inputmethod.latin:id/B01"),
|
||||
("E", "com.google.android.inputmethod.latin:id/B02"),
|
||||
("R", "com.google.android.inputmethod.latin:id/B03"),
|
||||
("T", "com.google.android.inputmethod.latin:id/B04"),
|
||||
("Z", "com.google.android.inputmethod.latin:id/B05"),
|
||||
("N", "com.google.android.inputmethod.latin:id/B06"),
|
||||
("Löschen", "com.google.android.inputmethod.latin:id/key_pos_del"),
|
||||
("Senden", "com.google.android.inputmethod.latin:id/key_pos_ime_action"),
|
||||
("Leerzeichen DE • EN", "com.google.android.inputmethod.latin:id/key_pos_space"),
|
||||
("Shift enabled", "com.google.android.inputmethod.latin:id/key_pos_shift"),
|
||||
(",", "com.google.android.inputmethod.latin:id/key_pos_comma"),
|
||||
(".", "com.google.android.inputmethod.latin:id/key_pos_period"),
|
||||
("Symboltastatur ?123", "com.google.android.inputmethod.latin:id/key_pos_symbol"),
|
||||
("Emoji-Button", "com.google.android.inputmethod.latin:id/key_pos_emoji"),
|
||||
]
|
||||
nodes = []
|
||||
y = 1800
|
||||
for i, (desc, rid) in enumerate(keys):
|
||||
x = (i % 10) * 100
|
||||
nodes.append(
|
||||
SpatialNode(
|
||||
resource_id=rid,
|
||||
class_name="android.widget.FrameLayout",
|
||||
text="",
|
||||
content_desc=desc,
|
||||
bounds=(x, y, x + 90, y + 100),
|
||||
clickable=True,
|
||||
)
|
||||
)
|
||||
return nodes
|
||||
|
||||
|
||||
def _make_instagram_post_detail_nodes() -> list[SpatialNode]:
|
||||
"""Generates realistic Instagram POST_DETAIL nodes."""
|
||||
return [
|
||||
SpatialNode(
|
||||
resource_id="com.instagram.android:id/row_feed_photo_profile_name",
|
||||
class_name="android.widget.TextView",
|
||||
text="robert_bohnke",
|
||||
content_desc="",
|
||||
bounds=(100, 400, 400, 440),
|
||||
clickable=True,
|
||||
),
|
||||
SpatialNode(
|
||||
resource_id="com.instagram.android:id/row_feed_photo_profile_imageview",
|
||||
class_name="android.widget.ImageView",
|
||||
text="",
|
||||
content_desc="Profile picture of robert_bohnke",
|
||||
bounds=(20, 400, 80, 460),
|
||||
clickable=True,
|
||||
),
|
||||
SpatialNode(
|
||||
resource_id="com.instagram.android:id/row_feed_button_like",
|
||||
class_name="android.widget.ImageView",
|
||||
text="",
|
||||
content_desc="Like",
|
||||
bounds=(20, 1200, 100, 1280),
|
||||
clickable=True,
|
||||
),
|
||||
SpatialNode(
|
||||
resource_id="com.instagram.android:id/row_feed_button_comment",
|
||||
class_name="android.widget.ImageView",
|
||||
text="",
|
||||
content_desc="Comment",
|
||||
bounds=(120, 1200, 200, 1280),
|
||||
clickable=True,
|
||||
),
|
||||
SpatialNode(
|
||||
resource_id="com.instagram.android:id/row_feed_button_share",
|
||||
class_name="android.widget.ImageView",
|
||||
text="",
|
||||
content_desc="Send post",
|
||||
bounds=(220, 1200, 300, 1280),
|
||||
clickable=True,
|
||||
),
|
||||
SpatialNode(
|
||||
resource_id="com.instagram.android:id/comment_composer_text_view",
|
||||
class_name="android.widget.EditText",
|
||||
text="Add comment…",
|
||||
content_desc="",
|
||||
bounds=(100, 1500, 800, 1560),
|
||||
clickable=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# TEST 1: Keyboard nodes are filtered from candidates
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestKeyboardContaminationGuard:
|
||||
def test_keyboard_nodes_filtered_from_visual_discovery_candidates(self):
|
||||
"""
|
||||
When the keyboard is open, _visual_discovery MUST exclude all nodes
|
||||
from keyboard packages (com.google.android.inputmethod.*).
|
||||
|
||||
This prevents the VLM from seeing 30+ single-letter boxes
|
||||
and hallucinating keyboard keys as valid UI targets.
|
||||
"""
|
||||
resolver = IntentResolver()
|
||||
keyboard_nodes = _make_keyboard_nodes()
|
||||
instagram_nodes = _make_instagram_post_detail_nodes()
|
||||
all_candidates = instagram_nodes + keyboard_nodes
|
||||
|
||||
# The production pre-filter must strip keyboard nodes
|
||||
filtered = resolver.pre_filter_candidates(all_candidates)
|
||||
|
||||
# ZERO keyboard nodes should survive
|
||||
keyboard_survivors = [n for n in filtered if "inputmethod" in (n.resource_id or "")]
|
||||
assert (
|
||||
len(keyboard_survivors) == 0
|
||||
), f"Keyboard nodes leaked through filter: {[n.content_desc for n in keyboard_survivors]}"
|
||||
|
||||
# Instagram nodes MUST survive
|
||||
instagram_survivors = [n for n in filtered if "instagram" in (n.resource_id or "")]
|
||||
assert len(instagram_survivors) > 0, "Instagram nodes were incorrectly filtered!"
|
||||
|
||||
def test_structural_fast_path_ignores_keyboard_even_without_filter(self):
|
||||
"""
|
||||
Even if keyboard nodes somehow pass pre-filtering, the structural
|
||||
fast-path for 'tap post username' must NEVER resolve to a keyboard key.
|
||||
"""
|
||||
resolver = IntentResolver()
|
||||
keyboard_nodes = _make_keyboard_nodes()
|
||||
instagram_nodes = _make_instagram_post_detail_nodes()
|
||||
all_candidates = instagram_nodes + keyboard_nodes
|
||||
|
||||
result = resolver.resolve("tap post username", all_candidates, screen_height=2400)
|
||||
|
||||
assert result is not None, "Resolver returned None for 'tap post username'"
|
||||
assert "inputmethod" not in (
|
||||
result.resource_id or ""
|
||||
), f"Resolver picked a KEYBOARD KEY: {result.resource_id} (desc: {result.content_desc})"
|
||||
assert (
|
||||
"row_feed_photo_profile" in (result.resource_id or "").lower()
|
||||
), f"Expected profile imageview or name, got: {result.resource_id}"
|
||||
|
||||
def test_keyboard_detection_helper(self):
|
||||
"""
|
||||
The IntentResolver must expose a method to detect if the keyboard
|
||||
is open based on the candidate list's package names.
|
||||
"""
|
||||
resolver = IntentResolver()
|
||||
keyboard_nodes = _make_keyboard_nodes()
|
||||
instagram_nodes = _make_instagram_post_detail_nodes()
|
||||
|
||||
assert resolver.has_keyboard_open(instagram_nodes + keyboard_nodes) is True
|
||||
assert resolver.has_keyboard_open(instagram_nodes) is False
|
||||
|
||||
def test_send_post_button_resolves_to_share_not_comment_composer(self):
|
||||
"""
|
||||
The 'tap send post button' intent MUST resolve to row_feed_button_share,
|
||||
NOT to comment_composer_text_view.
|
||||
|
||||
Production Bug 2026-05-04: VLM selected comment_composer → keyboard opened.
|
||||
"""
|
||||
resolver = IntentResolver()
|
||||
candidates = _make_instagram_post_detail_nodes()
|
||||
|
||||
result = resolver.resolve("tap send post button", candidates, screen_height=2400)
|
||||
|
||||
assert result is not None, "Resolver returned None for 'tap send post button'"
|
||||
assert (
|
||||
"row_feed_button_share" in (result.resource_id or "").lower()
|
||||
), f"Expected share button, got: {result.resource_id} (desc: {result.content_desc})"
|
||||
assert (
|
||||
"comment_composer" not in (result.resource_id or "").lower()
|
||||
), "REGRESSION: Resolver picked comment_composer instead of share button!"
|
||||
|
||||
|
||||
class TestTelepathicEngineKeyboardGuard:
|
||||
def test_telepathic_engine_auto_dismisses_keyboard(self):
|
||||
"""
|
||||
When the keyboard is detected in find_best_node for a non-typing intent,
|
||||
the TelepathicEngine must actively call device.back() and re-fetch the XML
|
||||
via device.dump_hierarchy().
|
||||
"""
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
class DummyDeviceForKeyboardTest:
|
||||
def __init__(self):
|
||||
self.back_called = False
|
||||
self.dump_hierarchy_called = False
|
||||
self.xml_without_keyboard = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node package="com.instagram.android" class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" bounds="[100,400][400,440]" clickable="true" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
def back(self):
|
||||
self.back_called = True
|
||||
|
||||
def dump_hierarchy(self, compressed=False):
|
||||
self.dump_hierarchy_called = True
|
||||
return self.xml_without_keyboard
|
||||
|
||||
mock_device = DummyDeviceForKeyboardTest()
|
||||
|
||||
xml_with_keyboard = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node package="com.instagram.android" class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
|
||||
<node resource-id="com.instagram.android:id/row_feed_photo_profile_name" bounds="[100,400][400,440]" clickable="true" />
|
||||
</node>
|
||||
<node package="com.google.android.inputmethod.latin" class="android.widget.FrameLayout" bounds="[0,1500][1080,2400]">
|
||||
<node resource-id="com.google.android.inputmethod.latin:id/B00" content-desc="Q" bounds="[0,1800][100,1900]" clickable="true" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
|
||||
engine = TelepathicEngine()
|
||||
|
||||
# We pass "tap post username" which is NOT a typing intent.
|
||||
# It should trigger active dismissal.
|
||||
result = engine.find_best_node(
|
||||
xml_string=xml_with_keyboard,
|
||||
intent_description="tap post username",
|
||||
device=mock_device,
|
||||
track=False
|
||||
)
|
||||
|
||||
# Ensure device.back() was called
|
||||
assert mock_device.back_called is True
|
||||
# Ensure device.dump_hierarchy() was called to re-fetch
|
||||
assert mock_device.dump_hierarchy_called is True
|
||||
|
||||
# Result should still resolve to the profile name node
|
||||
assert result is not None
|
||||
assert "row_feed_photo_profile_name" in result.get("id", "")
|
||||
52
tests/e2e/test_production_bug_play_store_trap_20260503.py
Normal file
52
tests/e2e/test_production_bug_play_store_trap_20260503.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Production Bug Regression: Play Store Trap (2026-05-03)
|
||||
========================================================
|
||||
Session: 2026-05-03_18-03-15
|
||||
Trace: Frames 14-40 (Instagram Stories) → Frame 41 (com.android.vending)
|
||||
|
||||
Root Cause: Story loop had no perimeter guard. A story's swipe-up link
|
||||
opened the Play Store, and the bot continued tapping at (w*0.85, h*0.5)
|
||||
on Play Store UI for 5+ iterations until user killed the process.
|
||||
|
||||
This test uses the EXACT production XML dumps to validate:
|
||||
1. ScreenIdentity correctly classifies Play Store as FOREIGN_APP
|
||||
2. SAE correctly classifies Play Store as OBSTACLE_FOREIGN_APP
|
||||
3. Story frame before the linkout is correctly classified as STORY_VIEW
|
||||
"""
|
||||
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
|
||||
|
||||
|
||||
class TestPlayStoreTrapRegression:
|
||||
def test_screen_identity_classifies_play_store_as_foreign(self):
|
||||
"""ScreenIdentity must classify com.android.vending as FOREIGN_APP."""
|
||||
xml = load_fixture_xml("play_store_from_story_link.xml")
|
||||
sid = ScreenIdentity("testuser")
|
||||
result = sid.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.FOREIGN_APP, (
|
||||
f"ScreenIdentity classified Play Store as {result['screen_type'].value}! "
|
||||
f"This is the exact production bug from 2026-05-03."
|
||||
)
|
||||
|
||||
def test_sae_classifies_play_store_as_obstacle_foreign_app(self):
|
||||
"""SAE must classify com.android.vending as OBSTACLE_FOREIGN_APP."""
|
||||
xml = load_fixture_xml("play_store_from_story_link.xml")
|
||||
device = E2EDeviceStub([xml])
|
||||
SituationalAwarenessEngine.reset()
|
||||
sae = SituationalAwarenessEngine(device)
|
||||
result = sae.perceive(xml)
|
||||
assert result == SituationType.OBSTACLE_FOREIGN_APP, (
|
||||
f"SAE classified Play Store as {result.value}! " f"Exact production bug regression from 2026-05-03."
|
||||
)
|
||||
|
||||
def test_story_view_before_linkout_is_story(self):
|
||||
"""The last story frame before the Play Store must be STORY_VIEW."""
|
||||
xml = load_fixture_xml("story_view_before_linkout.xml")
|
||||
sid = ScreenIdentity("testuser")
|
||||
result = sid.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.STORY_VIEW, (
|
||||
f"Frame before Play Store was classified as {result['screen_type'].value}, "
|
||||
f"not STORY_VIEW. This could cause false positives."
|
||||
)
|
||||
88
tests/e2e/test_sae_foreign_app_fastpath.py
Normal file
88
tests/e2e/test_sae_foreign_app_fastpath.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
E2E: SAE Foreign App Fast-Path
|
||||
================================
|
||||
Validates that the SAE classifies known foreign packages (Play Store,
|
||||
Chrome, Settings) as OBSTACLE_FOREIGN_APP using O(1) structural detection
|
||||
WITHOUT falling through to LLM classification.
|
||||
|
||||
This eliminates 2-5 seconds of LLM inference for detections that
|
||||
should be instantaneous set lookups.
|
||||
"""
|
||||
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
|
||||
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
|
||||
|
||||
|
||||
def test_sae_classifies_play_store_without_llm():
|
||||
"""Play Store XML must be classified as FOREIGN_APP via fast-path, not LLM."""
|
||||
xml = load_fixture_xml("play_store_from_story_link.xml")
|
||||
device = E2EDeviceStub([xml])
|
||||
SituationalAwarenessEngine.reset()
|
||||
sae = SituationalAwarenessEngine(device)
|
||||
|
||||
result = sae.perceive(xml)
|
||||
|
||||
assert result == SituationType.OBSTACLE_FOREIGN_APP, (
|
||||
f"SAE classified Play Store as {result.value} instead of OBSTACLE_FOREIGN_APP! "
|
||||
f"The bot cannot detect it left Instagram."
|
||||
)
|
||||
|
||||
|
||||
def test_sae_fast_path_handles_known_foreign_packages():
|
||||
"""
|
||||
Verify the fast-path handles com.android.vending, com.android.chrome,
|
||||
com.google.android.youtube etc. without LLM calls.
|
||||
com.android.settings may classify as OBSTACLE_SYSTEM which is also valid.
|
||||
"""
|
||||
known_foreign_pkgs = {
|
||||
"com.android.vending": SituationType.OBSTACLE_FOREIGN_APP,
|
||||
"com.android.chrome": SituationType.OBSTACLE_FOREIGN_APP,
|
||||
"com.google.android.youtube": SituationType.OBSTACLE_FOREIGN_APP,
|
||||
}
|
||||
for pkg, expected in known_foreign_pkgs.items():
|
||||
xml = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy rotation="0">
|
||||
<node class="android.widget.FrameLayout" package="{pkg}"
|
||||
bounds="[0,0][1080,2400]">
|
||||
<node text="Some content" class="android.widget.TextView"
|
||||
package="{pkg}" />
|
||||
</node>
|
||||
</hierarchy>"""
|
||||
device = E2EDeviceStub([xml])
|
||||
SituationalAwarenessEngine.reset()
|
||||
sae = SituationalAwarenessEngine(device)
|
||||
|
||||
result = sae.perceive(xml)
|
||||
assert result == expected, f"Package {pkg} was classified as {result.value}, expected {expected.value}!"
|
||||
|
||||
|
||||
def test_sae_ensure_clear_screen_escapes_foreign_app_without_llm():
|
||||
"""
|
||||
If SAE perceives a FOREIGN_APP, ensure_clear_screen must immediately
|
||||
execute a kill_foreign_apps action WITHOUT consulting the LLM.
|
||||
"""
|
||||
|
||||
xml_foreign = load_fixture_xml("play_store_from_story_link.xml")
|
||||
xml_normal = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy rotation="0">
|
||||
<node class="android.widget.FrameLayout" package="com.instagram.android"
|
||||
bounds="[0,0][1080,2400]">
|
||||
<node text="Instagram" class="android.widget.TextView" />
|
||||
</node>
|
||||
</hierarchy>"""
|
||||
|
||||
# device returns foreign first, then normal on next dump
|
||||
device = E2EDeviceStub([xml_foreign, xml_normal])
|
||||
SituationalAwarenessEngine.reset()
|
||||
sae = SituationalAwarenessEngine(device)
|
||||
|
||||
# Patch _plan_escape_via_llm to raise an error if called!
|
||||
def _mock_plan(*args, **kwargs):
|
||||
raise AssertionError("LLM was called for FOREIGN_APP escape! This should be an O(1) fast-path.")
|
||||
|
||||
sae._plan_escape_via_llm = _mock_plan
|
||||
|
||||
result = sae.ensure_clear_screen(max_attempts=3)
|
||||
|
||||
assert result is True, "SAE failed to clear the foreign app screen"
|
||||
assert "com.instagram.android" in device.app_starts, "SAE did not attempt to restart Instagram"
|
||||
@@ -24,11 +24,11 @@ import pytest
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _xml_with_ids(*resource_ids, selected_tab=None, texts=None, descs=None):
|
||||
"""Build a minimal XML dump with given resource IDs."""
|
||||
nodes = []
|
||||
@@ -40,13 +40,13 @@ def _xml_with_ids(*resource_ids, selected_tab=None, texts=None, descs=None):
|
||||
f'text="" content-desc="" clickable="true" selected="{selected}" '
|
||||
f'bounds="[0,0][100,100]" />'
|
||||
)
|
||||
for text in (texts or []):
|
||||
for text in texts or []:
|
||||
nodes.append(
|
||||
f'<node package="com.instagram.android" '
|
||||
f'resource-id="" text="{text}" content-desc="" '
|
||||
f'clickable="false" selected="false" bounds="[0,0][100,100]" />'
|
||||
)
|
||||
for desc in (descs or []):
|
||||
for desc in descs or []:
|
||||
nodes.append(
|
||||
f'<node package="com.instagram.android" '
|
||||
f'resource-id="" text="" content-desc="{desc}" '
|
||||
@@ -71,20 +71,17 @@ class TestScreenIdentification:
|
||||
self.si = ScreenIdentity(bot_username="testbot")
|
||||
|
||||
def test_home_feed_from_selected_tab(self):
|
||||
xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab",
|
||||
selected_tab="feed_tab")
|
||||
xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab", selected_tab="feed_tab")
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.HOME_FEED
|
||||
|
||||
def test_explore_grid_from_selected_tab(self):
|
||||
xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab",
|
||||
selected_tab="search_tab")
|
||||
xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab", selected_tab="search_tab")
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.EXPLORE_GRID
|
||||
|
||||
def test_reels_feed_from_selected_tab(self):
|
||||
xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab",
|
||||
selected_tab="clips_tab")
|
||||
xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab", selected_tab="clips_tab")
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.REELS_FEED
|
||||
|
||||
@@ -95,16 +92,15 @@ class TestScreenIdentification:
|
||||
assert result["screen_type"] == ScreenType.REELS_FEED
|
||||
|
||||
def test_own_profile_from_selected_tab(self):
|
||||
xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab",
|
||||
"profile_header_container",
|
||||
selected_tab="profile_tab")
|
||||
xml = _xml_with_ids(
|
||||
"feed_tab", "search_tab", "clips_tab", "profile_tab", "profile_header_container", selected_tab="profile_tab"
|
||||
)
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_other_profile_from_header_without_tab(self):
|
||||
"""Other profile has header but profile_tab is NOT selected."""
|
||||
xml = _xml_with_ids("profile_header_container", "feed_tab",
|
||||
selected_tab="feed_tab")
|
||||
xml = _xml_with_ids("profile_header_container", "feed_tab", selected_tab="feed_tab")
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.OTHER_PROFILE
|
||||
|
||||
@@ -146,9 +142,13 @@ class TestScreenIdentification:
|
||||
|
||||
def test_home_feed_has_feed_markers_with_action_bar(self):
|
||||
"""HOME_FEED has row_feed_* markers AND main_feed_action_bar."""
|
||||
xml = _xml_with_ids("row_feed_button_like", "row_feed_photo_profile_name",
|
||||
"main_feed_action_bar", "feed_tab",
|
||||
selected_tab="feed_tab")
|
||||
xml = _xml_with_ids(
|
||||
"row_feed_button_like",
|
||||
"row_feed_photo_profile_name",
|
||||
"main_feed_action_bar",
|
||||
"feed_tab",
|
||||
selected_tab="feed_tab",
|
||||
)
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.HOME_FEED
|
||||
|
||||
@@ -177,8 +177,7 @@ class TestAvailableActions:
|
||||
self.si = ScreenIdentity(bot_username="testbot")
|
||||
|
||||
def test_home_feed_has_all_tabs(self):
|
||||
xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab",
|
||||
"direct_tab", selected_tab="feed_tab")
|
||||
xml = _xml_with_ids("feed_tab", "search_tab", "clips_tab", "profile_tab", "direct_tab", selected_tab="feed_tab")
|
||||
result = self.si.identify(xml)
|
||||
actions = result["available_actions"]
|
||||
assert "tap home tab" in actions
|
||||
@@ -194,9 +193,7 @@ class TestAvailableActions:
|
||||
assert "tap first post" in actions
|
||||
|
||||
def test_profile_has_following_list(self):
|
||||
xml = _xml_with_ids("profile_header_container", "profile_tab",
|
||||
selected_tab="profile_tab",
|
||||
descs=["following"])
|
||||
xml = _xml_with_ids("profile_header_container", "profile_tab", selected_tab="profile_tab", descs=["following"])
|
||||
result = self.si.identify(xml)
|
||||
actions = result["available_actions"]
|
||||
assert "tap following list" in actions
|
||||
@@ -246,8 +243,8 @@ class TestHDMapRouting:
|
||||
assert route == []
|
||||
|
||||
def test_unreachable_returns_none(self):
|
||||
"""POST_DETAIL has no outgoing edges → can't reach other screens."""
|
||||
route = ScreenTopology.find_route(ScreenType.POST_DETAIL, ScreenType.DM_INBOX)
|
||||
"""MODAL has no outgoing edges → can't reach other screens."""
|
||||
route = ScreenTopology.find_route(ScreenType.MODAL, ScreenType.DM_INBOX)
|
||||
assert route is None
|
||||
|
||||
def test_masked_edge_makes_route_none(self):
|
||||
@@ -281,9 +278,7 @@ class TestHDMapRouting:
|
||||
ScreenType.REELS_FEED,
|
||||
avoid_actions=all_reels_actions,
|
||||
)
|
||||
assert route is None, (
|
||||
f"Expected None (unreachable) but got route: {route}"
|
||||
)
|
||||
assert route is None, f"Expected None (unreachable) but got route: {route}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -318,10 +313,20 @@ class TestGoalMapping:
|
||||
("comment on the post", None),
|
||||
],
|
||||
ids=[
|
||||
"home_feed", "home_short", "explore", "explore_full",
|
||||
"reels", "profile", "learn_profile", "messages",
|
||||
"following_list", "post", "other_profile",
|
||||
"like_non_nav", "follow_non_nav", "comment_non_nav",
|
||||
"home_feed",
|
||||
"home_short",
|
||||
"explore",
|
||||
"explore_full",
|
||||
"reels",
|
||||
"profile",
|
||||
"learn_profile",
|
||||
"messages",
|
||||
"following_list",
|
||||
"post",
|
||||
"other_profile",
|
||||
"like_non_nav",
|
||||
"follow_non_nav",
|
||||
"comment_non_nav",
|
||||
],
|
||||
)
|
||||
def test_goal_mapping(self, goal, expected_screen):
|
||||
@@ -381,14 +386,13 @@ class TestActionMasking:
|
||||
avoid_actions = {"tap reels tab"}
|
||||
|
||||
# Step 1: Check if route exists without masking
|
||||
route_clean = ScreenTopology.find_route(
|
||||
ScreenType.HOME_FEED, ScreenType.REELS_FEED
|
||||
)
|
||||
route_clean = ScreenTopology.find_route(ScreenType.HOME_FEED, ScreenType.REELS_FEED)
|
||||
assert route_clean is not None, "Route should exist without masking"
|
||||
|
||||
# Step 2: Check if route exists WITH masking
|
||||
route_masked = ScreenTopology.find_route(
|
||||
ScreenType.HOME_FEED, ScreenType.REELS_FEED,
|
||||
ScreenType.HOME_FEED,
|
||||
ScreenType.REELS_FEED,
|
||||
avoid_actions=avoid_actions,
|
||||
)
|
||||
|
||||
@@ -415,57 +419,40 @@ class TestGoalAchievement:
|
||||
|
||||
def setup_method(self):
|
||||
from GramAddict.core.navigation.planner import GoalPlanner
|
||||
|
||||
self.planner = GoalPlanner(username="testbot")
|
||||
|
||||
def test_open_explore_achieved_on_explore(self):
|
||||
assert self.planner._is_goal_achieved(
|
||||
"open explore", ScreenType.EXPLORE_GRID, {}
|
||||
) is True
|
||||
assert self.planner._is_goal_achieved("open explore", ScreenType.EXPLORE_GRID, {}) is True
|
||||
|
||||
def test_open_explore_not_achieved_on_home(self):
|
||||
assert self.planner._is_goal_achieved(
|
||||
"open explore", ScreenType.HOME_FEED, {}
|
||||
) is False
|
||||
assert self.planner._is_goal_achieved("open explore", ScreenType.HOME_FEED, {}) is False
|
||||
|
||||
def test_open_reels_achieved_on_reels(self):
|
||||
assert self.planner._is_goal_achieved(
|
||||
"open reels", ScreenType.REELS_FEED, {}
|
||||
) is True
|
||||
assert self.planner._is_goal_achieved("open reels", ScreenType.REELS_FEED, {}) is True
|
||||
|
||||
def test_open_reels_not_achieved_on_explore(self):
|
||||
"""
|
||||
Production bug 2026-05-02: Bot tapped 'reels tab' but
|
||||
landed on EXPLORE_GRID. Goal must NOT be achieved.
|
||||
"""
|
||||
assert self.planner._is_goal_achieved(
|
||||
"open reels", ScreenType.EXPLORE_GRID, {}
|
||||
) is False
|
||||
assert self.planner._is_goal_achieved("open reels", ScreenType.EXPLORE_GRID, {}) is False
|
||||
|
||||
def test_like_achieved_when_context_is_liked(self):
|
||||
assert self.planner._is_goal_achieved(
|
||||
"like a post", ScreenType.POST_DETAIL, {"is_liked": True}
|
||||
) is True
|
||||
assert self.planner._is_goal_achieved("like a post", ScreenType.POST_DETAIL, {"is_liked": True}) is True
|
||||
|
||||
def test_like_not_achieved_when_not_liked(self):
|
||||
assert self.planner._is_goal_achieved(
|
||||
"like a post", ScreenType.POST_DETAIL, {"is_liked": False}
|
||||
) is False
|
||||
assert self.planner._is_goal_achieved("like a post", ScreenType.POST_DETAIL, {"is_liked": False}) is False
|
||||
|
||||
def test_view_profile_achieved_on_own_profile(self):
|
||||
assert self.planner._is_goal_achieved(
|
||||
"view profile", ScreenType.OWN_PROFILE, {}
|
||||
) is True
|
||||
assert self.planner._is_goal_achieved("view profile", ScreenType.OWN_PROFILE, {}) is True
|
||||
|
||||
def test_view_profile_achieved_on_other_profile(self):
|
||||
assert self.planner._is_goal_achieved(
|
||||
"view profile", ScreenType.OTHER_PROFILE, {}
|
||||
) is True
|
||||
assert self.planner._is_goal_achieved("view profile", ScreenType.OTHER_PROFILE, {}) is True
|
||||
|
||||
def test_non_navigation_goal_not_achieved_by_screen(self):
|
||||
"""Goals like 'follow the user' are NOT achieved by just being on a screen."""
|
||||
assert self.planner._is_goal_achieved(
|
||||
"follow the user", ScreenType.OTHER_PROFILE, {}
|
||||
) is False
|
||||
assert self.planner._is_goal_achieved("follow the user", ScreenType.OTHER_PROFILE, {}) is False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -482,28 +469,18 @@ class TestStructuralActionProtection:
|
||||
"""Tests that HD Map actions are never permanently poisoned."""
|
||||
|
||||
def test_tap_reels_tab_is_structural_on_home(self):
|
||||
assert ScreenTopology.is_structural_action(
|
||||
ScreenType.HOME_FEED, "tap reels tab"
|
||||
) is True
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "tap reels tab") is True
|
||||
|
||||
def test_tap_profile_tab_is_structural_on_explore(self):
|
||||
assert ScreenTopology.is_structural_action(
|
||||
ScreenType.EXPLORE_GRID, "tap profile tab"
|
||||
) is True
|
||||
assert ScreenTopology.is_structural_action(ScreenType.EXPLORE_GRID, "tap profile tab") is True
|
||||
|
||||
def test_random_action_is_not_structural(self):
|
||||
assert ScreenTopology.is_structural_action(
|
||||
ScreenType.HOME_FEED, "like this post"
|
||||
) is False
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "like this post") is False
|
||||
|
||||
def test_structural_on_wrong_screen_is_false(self):
|
||||
"""'tap following list' is structural on OWN_PROFILE, not on HOME_FEED."""
|
||||
assert ScreenTopology.is_structural_action(
|
||||
ScreenType.HOME_FEED, "tap following list"
|
||||
) is False
|
||||
assert ScreenTopology.is_structural_action(
|
||||
ScreenType.OWN_PROFILE, "tap following list"
|
||||
) is True
|
||||
assert ScreenTopology.is_structural_action(ScreenType.HOME_FEED, "tap following list") is False
|
||||
assert ScreenTopology.is_structural_action(ScreenType.OWN_PROFILE, "tap following list") is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -520,15 +497,11 @@ class TestStepValidation:
|
||||
"""Tests expected_screen_for_action validation."""
|
||||
|
||||
def test_tap_reels_tab_from_home_expects_reels(self):
|
||||
expected = ScreenTopology.expected_screen_for_action(
|
||||
"tap reels tab", ScreenType.HOME_FEED
|
||||
)
|
||||
expected = ScreenTopology.expected_screen_for_action("tap reels tab", ScreenType.HOME_FEED)
|
||||
assert expected == ScreenType.REELS_FEED
|
||||
|
||||
def test_tap_explore_tab_from_home_expects_explore(self):
|
||||
expected = ScreenTopology.expected_screen_for_action(
|
||||
"tap explore tab", ScreenType.HOME_FEED
|
||||
)
|
||||
expected = ScreenTopology.expected_screen_for_action("tap explore tab", ScreenType.HOME_FEED)
|
||||
assert expected == ScreenType.EXPLORE_GRID
|
||||
|
||||
def test_tap_reels_tab_landing_on_explore_is_failure(self):
|
||||
@@ -536,20 +509,22 @@ class TestStepValidation:
|
||||
Production bug 2026-05-02: Bot tapped 'reels tab' but
|
||||
landed on EXPLORE_GRID. This must be detected as a failure.
|
||||
"""
|
||||
expected = ScreenTopology.expected_screen_for_action(
|
||||
"tap reels tab", ScreenType.HOME_FEED
|
||||
)
|
||||
expected = ScreenTopology.expected_screen_for_action("tap reels tab", ScreenType.HOME_FEED)
|
||||
actual = ScreenType.EXPLORE_GRID
|
||||
assert expected != actual, "Reels tab landing on Explore must be a mismatch!"
|
||||
|
||||
def test_unknown_action_returns_none(self):
|
||||
expected = ScreenTopology.expected_screen_for_action(
|
||||
"like this post", ScreenType.HOME_FEED
|
||||
)
|
||||
expected = ScreenTopology.expected_screen_for_action("like this post", ScreenType.HOME_FEED)
|
||||
assert expected is None
|
||||
|
||||
def test_press_back_from_follow_list_expects_profile(self):
|
||||
expected = ScreenTopology.expected_screen_for_action(
|
||||
"press back", ScreenType.FOLLOW_LIST
|
||||
)
|
||||
expected = ScreenTopology.expected_screen_for_action("press back", ScreenType.FOLLOW_LIST)
|
||||
assert expected == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_other_profile_to_home_feed_routing_uses_back_press(self):
|
||||
# We removed 'tap home tab' from OTHER_PROFILE because it doesn't exist.
|
||||
route = ScreenTopology.find_route(ScreenType.OTHER_PROFILE, ScreenType.HOME_FEED)
|
||||
assert route is not None
|
||||
assert len(route) == 1
|
||||
assert route[0][0] == "press back"
|
||||
assert route[0][1] == ScreenType.HOME_FEED
|
||||
|
||||
103
tests/e2e/test_system_learning_live.py
Normal file
103
tests/e2e/test_system_learning_live.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
@pytest.mark.live_llm
|
||||
def test_live_qdrant_learning_pipeline_no_mocks(make_real_device_with_image):
|
||||
"""
|
||||
100% Live Learning E2E Test.
|
||||
Proves Qdrant and LLM are used seamlessly. No lying mocks.
|
||||
|
||||
Scenario:
|
||||
1. The bot encounters a real UI and is given an abstract intent.
|
||||
2. Since Qdrant is empty, it uses the REAL Vision LLM to find the element.
|
||||
3. The click is tracked and confirmed (Positive Reinforcement).
|
||||
4. We prove the bot learned by asking again: this time we break the VLM connection.
|
||||
If the bot survives and finds the node, it means Qdrant O(1) retrieval worked perfectly.
|
||||
5. We track the click again but reject it (Negative Reinforcement).
|
||||
6. We prove the bot 'unlearned' by checking the confidence decay in Qdrant.
|
||||
"""
|
||||
base_dir = os.path.dirname(os.path.dirname(__file__))
|
||||
xml_path = os.path.join(base_dir, "fixtures", "user_profile_dump.xml")
|
||||
img_path = os.path.join(base_dir, "fixtures", "user_profile_dump.jpg")
|
||||
|
||||
with open(xml_path, "r", encoding="utf-8") as f:
|
||||
xml_content = f.read()
|
||||
|
||||
device = make_real_device_with_image(img_path, xml_content)
|
||||
|
||||
# 1. Reset engine and wipe memory to guarantee blank slate
|
||||
engine = TelepathicEngine.get_instance()
|
||||
engine.wipe()
|
||||
action_memory = engine._memory
|
||||
|
||||
# We use an abstract intent that shouldn't hit a simple keyword fast-path easily
|
||||
intent = "open the direct message window to chat with this user"
|
||||
|
||||
# 2. First pass: VLM Discovery
|
||||
node = engine.find_best_node(xml_content, intent, device=device)
|
||||
assert node is not None, "VLM failed to find any node for the intent!"
|
||||
|
||||
# Verify the VLM actually picked something reasonable (like the message button)
|
||||
desc = (node.get("description") or "").lower()
|
||||
text = (node.get("text") or "").lower()
|
||||
assert "message" in desc or "message" in text, f"VLM picked wrong node: {node}"
|
||||
|
||||
# 3. Track and Confirm Click (Learn)
|
||||
orig = node.get("original_attribs", {})
|
||||
s_node = SpatialNode(
|
||||
bounds=orig.get("bounds", (0, 0, 0, 0)),
|
||||
text=orig.get("text", ""),
|
||||
content_desc=orig.get("content_desc", ""),
|
||||
resource_id=orig.get("resource_id", ""),
|
||||
)
|
||||
action_memory.track_click(intent, s_node, xml_content)
|
||||
action_memory.confirm_click(intent)
|
||||
|
||||
# 4. Verify Qdrant persistence and O(1) retrieval (Bypass VLM)
|
||||
from GramAddict.core.perception.semantic_evaluator import SemanticEvaluator
|
||||
|
||||
original_query = SemanticEvaluator._query_vlm
|
||||
vlm_called = False
|
||||
|
||||
def boom_vlm(*args, **kwargs):
|
||||
nonlocal vlm_called
|
||||
vlm_called = True
|
||||
raise Exception("VLM should NOT be called! The bot should use its memory.")
|
||||
|
||||
SemanticEvaluator._query_vlm = boom_vlm
|
||||
try:
|
||||
node2 = engine.find_best_node(xml_content, intent, device=device)
|
||||
assert node2 is not None, "Failed to retrieve from Qdrant memory!"
|
||||
assert node2["id"] == node["id"], "Memory mismatch!"
|
||||
assert not vlm_called, "VLM was called despite memory existing! Bot is not learning efficiently."
|
||||
finally:
|
||||
SemanticEvaluator._query_vlm = original_query
|
||||
|
||||
# 5. Check Confidence before decay
|
||||
point_id = action_memory.ui_memory._deterministic_id(intent)
|
||||
points = action_memory.ui_memory.client.retrieve(
|
||||
collection_name=action_memory.ui_memory.collection_name, ids=[point_id], with_payload=True
|
||||
)
|
||||
assert points, "Memory point not found in Qdrant!"
|
||||
initial_confidence = points[0].payload.get("confidence", 0.0)
|
||||
|
||||
# 6. Reject Click (Unlearn)
|
||||
action_memory.track_click(intent, s_node, xml_content)
|
||||
action_memory.reject_click(intent)
|
||||
|
||||
# 7. Check Confidence after decay
|
||||
points_after = action_memory.ui_memory.client.retrieve(
|
||||
collection_name=action_memory.ui_memory.collection_name, ids=[point_id], with_payload=True
|
||||
)
|
||||
|
||||
if points_after:
|
||||
after_confidence = points_after[0].payload.get("confidence", 0.0)
|
||||
assert after_confidence < initial_confidence, "Confidence did not decay! Negative reinforcement failed."
|
||||
else:
|
||||
# It's possible the confidence dropped below the threshold (0.1) and was purged.
|
||||
pass
|
||||
@@ -431,9 +431,9 @@ class TestSemanticMatchGuard:
|
||||
("follow", "text: 'Follow', desc: '', id: 'follow_button'", True),
|
||||
("like", "text: '', desc: 'Like', id: 'like_button'", True),
|
||||
("save", "text: 'Save', desc: 'Add to Saved', id: 'save_btn'", True),
|
||||
# German locale
|
||||
("follow", "text: 'Abonnieren', desc: '', id: ''", True),
|
||||
("like", "text: '', desc: 'Gefällt mir', id: ''", True),
|
||||
# German locale — MUST be rejected (Zero-Maintenance: no localized strings)
|
||||
("follow", "text: 'Abonnieren', desc: '', id: ''", False),
|
||||
("like", "text: '', desc: 'Gefällt mir', id: ''", False),
|
||||
# POISONING ATTEMPTS — must be blocked
|
||||
(
|
||||
"follow",
|
||||
@@ -454,8 +454,8 @@ class TestSemanticMatchGuard:
|
||||
"follow_correct",
|
||||
"like_correct",
|
||||
"save_correct",
|
||||
"follow_german",
|
||||
"like_german",
|
||||
"follow_german_rejected",
|
||||
"like_german_rejected",
|
||||
"follow_reel_poison",
|
||||
"like_photo_poison",
|
||||
"save_comment_poison",
|
||||
@@ -915,3 +915,25 @@ class TestVerifySuccessStructuralDelta:
|
||||
|
||||
result = memory.verify_success("like", pre_click_xml=pre_xml, post_click_xml=post_xml)
|
||||
assert result is False
|
||||
|
||||
def test_semantic_evaluator_malformed_json_fallback(self):
|
||||
"""TDD Test to ensure malformed JSON without closing braces is handled automatically without regex."""
|
||||
from GramAddict.core.perception.semantic_evaluator import SemanticEvaluator
|
||||
|
||||
evaluator = SemanticEvaluator()
|
||||
|
||||
# Override the VLM query to return a truncated JSON string (like an LLM out of tokens)
|
||||
evaluator._query_vlm = (
|
||||
lambda prompt, img: '{\n "should_like": true,\n "should_comment": false,\n "is_ad": false'
|
||||
)
|
||||
|
||||
class MockDevice:
|
||||
def get_screenshot_b64(self):
|
||||
return "dummy_b64"
|
||||
|
||||
result = evaluator.evaluate_post_vibe(MockDevice(), ["test"])
|
||||
|
||||
assert result is not None
|
||||
assert result["should_like"] is True
|
||||
assert result["should_comment"] is False
|
||||
assert result["is_ad"] is False
|
||||
|
||||
@@ -154,6 +154,10 @@ class TestSAELoop:
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity
|
||||
from GramAddict.core.qdrant_memory import ScreenMemoryDB
|
||||
|
||||
db = ScreenMemoryDB()
|
||||
if not db.is_connected:
|
||||
pytest.skip("Qdrant is not running — this test requires vector store for NORMAL override storage")
|
||||
|
||||
# Load real home feed XML
|
||||
xml_dump = Path("tests/e2e/fixtures/home_feed_real.xml").read_text()
|
||||
|
||||
@@ -168,7 +172,7 @@ class TestSAELoop:
|
||||
|
||||
# Simulate LLM unlearning by storing this exact state as NORMAL
|
||||
compressed = sae._compress_xml(xml_dump)
|
||||
ScreenMemoryDB().store_screen(compressed, "NORMAL")
|
||||
db.store_screen(compressed, "NORMAL")
|
||||
|
||||
identity = ScreenIdentity("testuser")
|
||||
# Ensure we inject the device so get_screenshot_b64 doesn't crash if it falls back
|
||||
|
||||
74
tests/e2e/test_system_story_trap.py
Normal file
74
tests/e2e/test_system_story_trap.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
|
||||
@pytest.mark.live_llm
|
||||
def test_story_trap_negative_reinforcement(make_real_device_with_image):
|
||||
"""
|
||||
Proves that clicking a profile picture that leads to a STORY (not a PROFILE)
|
||||
correctly triggers a verification failure and negative reinforcement.
|
||||
"""
|
||||
base_dir = os.path.dirname(os.path.dirname(__file__))
|
||||
feed_xml_path = os.path.join(base_dir, "e2e", "fixtures", "home_feed_real.xml")
|
||||
story_xml_path = os.path.join(base_dir, "e2e", "fixtures", "story_view_full.xml")
|
||||
img_path = os.path.join(base_dir, "e2e", "fixtures", "home_feed_real.jpg")
|
||||
|
||||
with open(feed_xml_path, "r", encoding="utf-8") as f:
|
||||
feed_xml = f.read()
|
||||
|
||||
with open(story_xml_path, "r", encoding="utf-8") as f:
|
||||
story_xml = f.read()
|
||||
|
||||
device = make_real_device_with_image(img_path, feed_xml)
|
||||
|
||||
engine = TelepathicEngine.get_instance()
|
||||
engine.wipe()
|
||||
action_memory = engine._memory
|
||||
|
||||
intent = "tap post username"
|
||||
|
||||
# Track the click on a profile picture (which is what caused the trap)
|
||||
s_node = SpatialNode(
|
||||
bounds=(0, 0, 100, 100),
|
||||
text="",
|
||||
content_desc="Profile picture of costarica",
|
||||
resource_id="com.instagram.android:id/row_feed_photo_profile_imageview",
|
||||
)
|
||||
|
||||
action_memory.track_click(intent, s_node, feed_xml)
|
||||
|
||||
# 1. Verification should FAIL because the profile header is missing.
|
||||
# The action_memory uses the NEW screen XML (which we provide or it fetches via device).
|
||||
# verify_success uses the passed xml.
|
||||
success = action_memory.verify_success(intent, feed_xml, story_xml, device=device)
|
||||
|
||||
assert success is False, "Story Trap was not detected! verify_success should return False."
|
||||
|
||||
# 2. Because verification failed, reject_click is called. Let's do that manually to simulate GOAP:
|
||||
# First confirm it to set a baseline confidence, then reject to prove decay.
|
||||
action_memory.confirm_click(intent)
|
||||
|
||||
point_id = action_memory.ui_memory._deterministic_id(intent)
|
||||
points_before = action_memory.ui_memory.client.retrieve(
|
||||
collection_name=action_memory.ui_memory.collection_name, ids=[point_id], with_payload=True
|
||||
)
|
||||
initial_confidence = points_before[0].payload.get("confidence", 0.0)
|
||||
|
||||
# Track the click again so reject_click has a context
|
||||
action_memory.track_click(intent, s_node, feed_xml)
|
||||
action_memory.reject_click(intent)
|
||||
|
||||
points_after = action_memory.ui_memory.client.retrieve(
|
||||
collection_name=action_memory.ui_memory.collection_name, ids=[point_id], with_payload=True
|
||||
)
|
||||
|
||||
if points_after:
|
||||
after_confidence = points_after[0].payload.get("confidence", 0.0)
|
||||
assert after_confidence < initial_confidence, "Story Trap click was not penalized in Qdrant!"
|
||||
else:
|
||||
# Penalized so much it was dropped from the DB.
|
||||
pass
|
||||
53
tests/e2e/test_workflow_feed_foreign_app_escape.py
Normal file
53
tests/e2e/test_workflow_feed_foreign_app_escape.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
E2E: Feed Loop Foreign App Escape
|
||||
====================================
|
||||
Validates that the feed loop detects a foreign app takeover
|
||||
(e.g. Play Store opened) and immediately aborts with CONTEXT_LOST.
|
||||
Parity with story loop guard.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
|
||||
|
||||
|
||||
def test_feed_loop_escapes_foreign_app(e2e_configs, e2e_cognitive_stack_factory):
|
||||
"""
|
||||
Simulates: Feed → Play Store.
|
||||
The loop must detect com.android.vending and abort immediately.
|
||||
"""
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
|
||||
|
||||
feed_xml = load_fixture_xml("home_feed_with_ad.xml")
|
||||
play_store_xml = load_fixture_xml("play_store_from_story_link.xml")
|
||||
|
||||
# Feed -> Play Store
|
||||
device = E2EDeviceStub([feed_xml, play_store_xml])
|
||||
|
||||
cognitive_stack = e2e_cognitive_stack_factory(device)
|
||||
cognitive_stack["dopamine"].session_limit_seconds = 999
|
||||
|
||||
session_state = type(
|
||||
"S", (), {"startTime": datetime.datetime.now(), "check_limit": lambda *args, **kwargs: (False, False)}
|
||||
)()
|
||||
|
||||
# Override speed_multiplier to avoid multi-minute sleeps in CI
|
||||
e2e_configs.args.speed_multiplier = 0.01
|
||||
|
||||
result = _run_zero_latency_feed_loop(
|
||||
device,
|
||||
cognitive_stack["zero_engine"],
|
||||
cognitive_stack["nav_graph"],
|
||||
e2e_configs,
|
||||
session_state,
|
||||
"HomeFeed",
|
||||
cognitive_stack,
|
||||
)
|
||||
|
||||
# Must return CONTEXT_LOST
|
||||
assert (
|
||||
result == "CONTEXT_LOST"
|
||||
), f"Feed loop returned '{result}' instead of 'CONTEXT_LOST' when Play Store was in foreground."
|
||||
|
||||
# Must have pressed back to attempt recovery
|
||||
assert "back" in device.pressed_keys, "Feed loop detected foreign app but never pressed BACK to recover!"
|
||||
52
tests/e2e/test_workflow_stories_foreign_app_escape.py
Normal file
52
tests/e2e/test_workflow_stories_foreign_app_escape.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
E2E: Story Loop Foreign App Escape
|
||||
====================================
|
||||
Validates that the story binging loop detects a foreign app takeover
|
||||
(e.g. Play Store opened via story link) and immediately aborts with
|
||||
CONTEXT_LOST instead of continuing to tap blindly.
|
||||
|
||||
Production Bug Reproduction:
|
||||
Session: 2026-05-03_18-03-15
|
||||
Trace: Frames 14-40 (Instagram Stories) → Frame 41 (com.android.vending)
|
||||
Root Cause: _run_zero_latency_stories_loop had no perimeter guard.
|
||||
|
||||
Uses the REAL production XML dumps from the 2026-05-03 session trace.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
|
||||
|
||||
|
||||
def test_story_loop_escapes_foreign_app(e2e_configs, e2e_cognitive_stack_factory):
|
||||
"""
|
||||
Simulates: Story → Story → Story → Play Store (via swipe-up link).
|
||||
The loop must detect com.android.vending and abort immediately.
|
||||
"""
|
||||
from GramAddict.core.bot_flow import _run_zero_latency_stories_loop
|
||||
|
||||
story_xml = load_fixture_xml("story_view_before_linkout.xml")
|
||||
play_store_xml = load_fixture_xml("play_store_from_story_link.xml")
|
||||
|
||||
# 3 story iterations → then Play Store appears
|
||||
device = E2EDeviceStub([story_xml, story_xml, story_xml, play_store_xml])
|
||||
|
||||
cognitive_stack = e2e_cognitive_stack_factory(device)
|
||||
cognitive_stack["dopamine"].session_limit_seconds = 999
|
||||
|
||||
session_state = type("S", (), {"startTime": datetime.datetime.now()})()
|
||||
|
||||
# Override speed_multiplier to avoid multi-minute sleeps in CI
|
||||
e2e_configs.args.speed_multiplier = 0.01
|
||||
|
||||
result = _run_zero_latency_stories_loop(device, e2e_configs, session_state, cognitive_stack)
|
||||
|
||||
# Must return CONTEXT_LOST, NOT FEED_EXHAUSTED
|
||||
assert result == "CONTEXT_LOST", (
|
||||
f"Story loop returned '{result}' instead of 'CONTEXT_LOST' when Play Store was in foreground. "
|
||||
f"The bot would have tapped blindly on the Play Store! "
|
||||
f"This is the exact production bug from 2026-05-03."
|
||||
)
|
||||
|
||||
# Must have pressed back to attempt recovery
|
||||
assert "back" in device.pressed_keys, "Story loop detected foreign app but never pressed BACK to recover!"
|
||||
214
tests/fixtures/play_store_from_story_link.xml
vendored
Normal file
214
tests/fixtures/play_store_from_story_link.xml
vendored
Normal file
@@ -0,0 +1,214 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="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="[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][257,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][257,173]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="18:05" resource-id="com.android.systemui:id/clock" class="android.widget.TextView" package="com.android.systemui" content-desc="18:05" 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][199,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="[199,3][257,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="[199,3][257,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="[199,3][257,173]" drawing-order="1" 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="[772,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="[772,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="[780,59][908,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="[780,59][841,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="[788,59][833,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="[788,72][833,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="[788,72][833,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="[841,59][900,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="[849,59][892,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="[849,72][892,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="[849,72][892,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="[908,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="[908,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="[908,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 charging, 97 percent." 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="[908,71][971,105]" drawing-order="0" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.android.vending" 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.android.vending" 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.android.vending" 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.android.vending:id/0_resource_name_obfuscated" class="android.widget.FrameLayout" package="com.android.vending" 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.android.vending" 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.android.vending:id/0_resource_name_obfuscated" class="android.widget.FrameLayout" package="com.android.vending" 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.android.vending:id/0_resource_name_obfuscated" class="android.widget.FrameLayout" package="com.android.vending" 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.android.vending:id/0_resource_name_obfuscated" class="androidx.compose.ui.platform.ComposeView" package="com.android.vending" 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.view.View" package="com.android.vending" 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.view.View" package="com.android.vending" 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,2361]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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,2361]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Close sheet" 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,0][1080,612]" drawing-order="0" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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,612][1080,2361]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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,612][1080,2361]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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,612][1080,2361]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,759][1080,2361]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[63,791][1017,1095]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="MILLION VICTORIES" resource-id="" class="android.widget.TextView" package="com.android.vending" 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="[63,765][461,859]" drawing-order="0" hint="" display-id="0" />
|
||||
<node index="1" text="Million Lords: World Conquest" resource-id="" class="android.widget.TextView" package="com.android.vending" 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="[63,859][1017,1095]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[221,1127][1080,1253]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Average rating 4.1 stars in 79 thousand reviews" 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="[221,1127][434,1253]" drawing-order="0" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Content rating USK: Ages 6+" 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="[498,1127][735,1253]" drawing-order="1" hint="" display-id="0" />
|
||||
<node index="2" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Downloaded 1 million plus times" 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="[799,1140][964,1240]" drawing-order="2" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="2" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[64,1286][1017,1412]" drawing-order="2" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Install" 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="[490,1322][592,1375]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="Install" resource-id="" class="android.widget.TextView" package="com.android.vending" 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="[490,1322][592,1375]" drawing-order="0" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.widget.Button" package="com.android.vending" 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="[64,1296][1017,1401]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="3" text="Contains ads" resource-id="" class="android.widget.TextView" package="com.android.vending" 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="[63,1411][250,1453]" drawing-order="3" hint="" display-id="0" />
|
||||
<node index="4" text="In-app purchases" resource-id="" class="android.widget.TextView" package="com.android.vending" 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="[290,1411][545,1453]" drawing-order="4" hint="" display-id="0" />
|
||||
<node index="5" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,1485][1080,1931]" drawing-order="5" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[68,1490][843,1926]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Play trailer for 'Million Lords: World Conquest'" 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="[68,1490][843,1926]" drawing-order="0" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Play trailer for 'Million Lords: World Conquest'" 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="[68,1490][843,1926]" drawing-order="1" hint="" display-id="0" />
|
||||
<node index="2" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Play trailer" 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="[344,1597][567,1820]" drawing-order="2" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Screenshot 1 of 8" 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="[877,1490][1080,1926]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="6" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[0,1963][1080,2110]" drawing-order="6" hint="" display-id="0">
|
||||
<node index="0" text="About this game" resource-id="" class="android.widget.TextView" package="com.android.vending" 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="[63,2005][424,2068]" drawing-order="0" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="" class="android.widget.Button" package="com.android.vending" content-desc="Learn more About this game" 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="[906,1974][1032,2100]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="7" text="Protect your kingdom, forge alliances, attack castles and extend your empire!" resource-id="" class="android.widget.TextView" package="com.android.vending" 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="[63,2110][1017,2216]" drawing-order="7" hint="" display-id="0" />
|
||||
<node index="8" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" checkable="true" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[63,2290][293,2361]" drawing-order="8" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Strategy tag" 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="[63,2311][293,2361]" drawing-order="0" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[63,2290][147,2416]" drawing-order="1" hint="" display-id="0" />
|
||||
<node index="2" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[105,2327][251,2361]" drawing-order="2" hint="" display-id="0" />
|
||||
<node index="3" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[209,2290][293,2416]" drawing-order="3" hint="" display-id="0" />
|
||||
<node index="4" text="" resource-id="" class="android.widget.CheckBox" package="com.android.vending" 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="[63,2311][293,2361]" drawing-order="4" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="9" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" checkable="true" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[325,2290][455,2361]" drawing-order="9" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="4X tag" 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="[325,2311][455,2361]" drawing-order="0" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[325,2290][409,2416]" drawing-order="1" hint="" display-id="0" />
|
||||
<node index="2" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[367,2327][413,2361]" drawing-order="2" hint="" display-id="0" />
|
||||
<node index="3" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[371,2290][455,2416]" drawing-order="3" hint="" display-id="0" />
|
||||
<node index="4" text="" resource-id="" class="android.widget.CheckBox" package="com.android.vending" 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="[325,2311][455,2361]" drawing-order="4" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="10" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" checkable="true" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[487,2290][703,2361]" drawing-order="10" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Battling tag" 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="[487,2311][703,2361]" drawing-order="0" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[487,2290][571,2416]" drawing-order="1" hint="" display-id="0" />
|
||||
<node index="2" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[529,2327][661,2361]" drawing-order="2" hint="" display-id="0" />
|
||||
<node index="3" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[619,2290][703,2416]" drawing-order="3" hint="" display-id="0" />
|
||||
<node index="4" text="" resource-id="" class="android.widget.CheckBox" package="com.android.vending" 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="[487,2311][703,2361]" drawing-order="4" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="11" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" checkable="true" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[735,2290][942,2361]" drawing-order="11" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="History tag" 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="[735,2311][942,2361]" drawing-order="0" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[735,2290][819,2416]" drawing-order="1" hint="" display-id="0" />
|
||||
<node index="2" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[777,2327][900,2361]" drawing-order="2" hint="" display-id="0" />
|
||||
<node index="3" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[858,2290][942,2416]" drawing-order="3" hint="" display-id="0" />
|
||||
<node index="4" text="" resource-id="" class="android.widget.CheckBox" package="com.android.vending" 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="[735,2311][942,2361]" drawing-order="4" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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,612][1080,759]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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,612][1080,759]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[43,623][806,749]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Google Play" 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="[64,623][806,749]" drawing-order="0" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[817,622][943,748]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" 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="[818,623][944,749]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Search Google Play" 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,654][912,717]" drawing-order="0" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="" class="android.widget.Button" package="com.android.vending" 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="[828,633][933,738]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
<node index="2" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[943,622][1069,748]" drawing-order="2" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" 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="[944,623][1070,749]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="Close" 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="[975,654][1038,717]" drawing-order="0" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="" class="android.widget.Button" package="com.android.vending" 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="[954,633][1059,738]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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,2361][1080,2424]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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,2361][1080,2424]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" checkable="false" checked="false" clickable="true" enabled="false" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[0,2361][216,2424]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[35,2356][182,2424]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="jjj" package="com.android.vending" 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="[77,2388][140,2424]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.widget.ImageView" package="com.android.vending" 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="[77,2388][140,2424]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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,2361][216,2424]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" checkable="false" checked="false" clickable="true" enabled="false" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[216,2361][432,2424]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[251,2356][398,2424]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="jjj" package="com.android.vending" 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="[293,2388][356,2424]" drawing-order="2" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.widget.ImageView" package="com.android.vending" 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="[293,2388][356,2424]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[216,2361][432,2424]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="2" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" checkable="false" checked="false" clickable="true" enabled="false" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[432,2361][648,2424]" drawing-order="2" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[467,2356][614,2424]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="jjj" package="com.android.vending" 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="[509,2388][572,2424]" drawing-order="3" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.widget.ImageView" package="com.android.vending" 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="[509,2388][572,2424]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[432,2361][648,2424]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="3" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" checkable="false" checked="false" clickable="true" enabled="false" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[648,2361][864,2424]" drawing-order="3" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[683,2356][830,2424]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="jjj" package="com.android.vending" 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="[725,2388][788,2424]" drawing-order="4" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.widget.ImageView" package="com.android.vending" 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="[725,2388][788,2424]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[648,2361][864,2424]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="4" text="" resource-id="" class="android.view.View" package="com.android.vending" content-desc="" checkable="false" checked="false" clickable="true" enabled="false" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[864,2361][1080,2424]" drawing-order="4" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[899,2356][1046,2424]" drawing-order="0" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="jjj" package="com.android.vending" 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="[941,2388][1004,2424]" drawing-order="5" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" class="android.widget.ImageView" package="com.android.vending" 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="[941,2388][1004,2424]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.view.View" package="com.android.vending" 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="[864,2361][1080,2424]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node index="2" text="" resource-id="" class="android.view.View" package="com.android.vending" 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,2298][1080,2424]" drawing-order="2" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
190
tests/fixtures/story_view_before_linkout.xml
vendored
Normal file
190
tests/fixtures/story_view_before_linkout.xml
vendored
Normal file
@@ -0,0 +1,190 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="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="[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][257,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][257,173]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="18:05" resource-id="com.android.systemui:id/clock" class="android.widget.TextView" package="com.android.systemui" content-desc="18:05" 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][199,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="[199,3][257,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="[199,3][257,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="[199,3][257,173]" drawing-order="1" 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="[772,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="[772,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="[780,59][908,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="[780,59][841,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="[788,59][833,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="[788,72][833,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="[788,72][833,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="[841,59][900,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="[849,59][892,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="[849,72][892,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="[849,72][892,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="[908,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="[908,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="[908,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 charging, 97 percent." 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="[908,71][971,105]" drawing-order="0" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<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="" 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="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/swipe_navigation_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,0][1080,2424]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="1" text="" resource-id="com.instagram.android:id/layout_container_center_right_coordinator_layout" 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,2361]" drawing-order="3" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/layout_container_right" 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="2" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="com.instagram.android:id/activity_and_camera_shared_views_main_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,173][1080,2361]" drawing-order="4" hint="" display-id="0" />
|
||||
<node index="2" text="" resource-id="com.instagram.android:id/layout_container_main_panel" 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,2361]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/layout_container_main_wrapper" 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="2" 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="1" text="" resource-id="com.instagram.android:id/swipeable_tab_view_pager" class="androidx.viewpager.widget.ViewPager" 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="com.instagram.android:id/swipeable_nav_view_pager_inner_recycler_view" class="androidx.recyclerview.widget.RecyclerView" package="com.instagram.android" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="true" 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.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/layout_container_swipeable" 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="true" 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.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="2" 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,173][1080,2361]" drawing-order="1" 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,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_main_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,2361]" 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,223][1080,2361]" drawing-order="9" 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="[287,1949][792,2075]" drawing-order="1" hint="" display-id="0">
|
||||
<node NAF="true" index="0" text="" resource-id="" class="android.view.ViewGroup" 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="[287,1949][792,2075]" drawing-order="1" hint="" display-id="0" />
|
||||
</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/story_comment_preview_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,2143]" drawing-order="50" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="com.instagram.android:id/mobile_app_install_card_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="34" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/mobile_app_install_dimmer_overlay" 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,2143]" drawing-order="1" hint="" display-id="0" />
|
||||
<node index="1" text="" resource-id="com.instagram.android:id/mobile_app_install_card" class="android.view.ViewGroup" 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="[137,558][943,1808]" drawing-order="2" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="" 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="[137,558][943,1808]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="Million Lords: World Conquest" resource-id="" 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="[371,614][907,715]" drawing-order="1" hint="" display-id="0" />
|
||||
<node index="1" text="MILLION VICTORIES" resource-id="" 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="[371,715][907,756]" drawing-order="2" hint="" display-id="0" />
|
||||
<node index="2" text="4,1" resource-id="" 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="[251,813][296,860]" drawing-order="3" hint="" display-id="0" />
|
||||
<node index="3" text="83K" resource-id="" 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="[506,813][574,860]" drawing-order="5" hint="" display-id="0" />
|
||||
<node index="4" text="Strategy" resource-id="" 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="[708,813][863,860]" drawing-order="7" hint="" display-id="0" />
|
||||
<node index="5" text="Avg rating" resource-id="" 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="[218,860][372,901]" drawing-order="4" hint="" display-id="0" />
|
||||
<node index="6" text="Reviews" resource-id="" 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="[478,860][602,901]" drawing-order="6" hint="" display-id="0" />
|
||||
<node index="7" text="Category" resource-id="" 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="[716,860][853,901]" drawing-order="8" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node index="2" 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="2" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_image_view" 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.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="[0,223][1080,2143]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
<node index="3" 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="38" hint="" display-id="0" />
|
||||
<node index="4" 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,223][1080,427]" drawing-order="52" hint="" display-id="0">
|
||||
<node index="0" 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,427]" drawing-order="1" 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="millionlords's sponsored 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="[0,248][1080,427]" 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="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[32,272][116,351]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_profile_picture" class="android.widget.ImageView" 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,351]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="1" text="" resource-id="com.instagram.android:id/reel_viewer_text_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="[116,286][954,337]" drawing-order="2" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/reel_viewer_title_text_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="[116,286][359,330]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="millionlords" resource-id="com.instagram.android:id/reel_viewer_title" class="android.widget.TextView" package="com.instagram.android" content-desc="millionlords" 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,286][359,330]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="1" 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,330][180,337]" drawing-order="3" hint="" display-id="0" />
|
||||
</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="[954,249][1080,375]" drawing-order="3" hint="" display-id="0">
|
||||
<node index="0" 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,249][1080,375]" drawing-order="4" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node index="5" 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,1567][1080,2143]" drawing-order="45" hint="" display-id="0" />
|
||||
<node index="6" text="" resource-id="com.instagram.android:id/afi_container_for_media" 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,2025][1080,2143]" drawing-order="48" 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="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" visible-to-user="true" bounds="[162,2025][540,2143]" 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="[162,2025][540,2143]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/igds_pill_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="[162,2036][524,2120]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="Not interested" resource-id="com.instagram.android:id/igds_pill_label" 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="[216,2057][470,2099]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" 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="[540,2025][918,2143]" drawing-order="2" 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="[540,2025][918,2143]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/igds_pill_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="[556,2036][918,2120]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="Interested" resource-id="com.instagram.android:id/igds_pill_label" 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="[639,2057][834,2099]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node index="2" text="" resource-id="com.instagram.android:id/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/reel_item_toolbar_inner_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,2164][1080,2311]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/toolbar_left_right_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,2164][1080,2311]" drawing-order="1" hint="" display-id="0">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/sponsored_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][732,2290]" drawing-order="3" 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_button_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="[753,2164][1059,2290]" drawing-order="4" 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="[753,2174][858,2279]" drawing-order="2" 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="[753,2174][858,2279]" drawing-order="1" hint="" display-id="0" />
|
||||
</node>
|
||||
<node NAF="true" index="1" text="" resource-id="com.instagram.android:id/reel_viewer_comments_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="[858,2174][963,2279]" drawing-order="3" hint="" display-id="0" />
|
||||
<node index="2" text="Ad" resource-id="com.instagram.android:id/reel_item_sponsored_label_footer_pill" class="android.widget.TextView" package="com.instagram.android" content-desc="Ad" 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="[984,2190][1033,2264]" drawing-order="6" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</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="2" text="" resource-id="com.instagram.android:id/bottom_sheet_camera_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,173][1080,2361]" drawing-order="2" hint="" display-id="0" />
|
||||
</node>
|
||||
<node index="1" text="" resource-id="com.instagram.android:id/modal_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,0][1080,2361]" drawing-order="3" hint="" display-id="0" />
|
||||
<node index="2" text="" resource-id="com.instagram.android:id/overlay_layout_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,0][1080,2361]" drawing-order="5" hint="" display-id="0" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -42,13 +42,8 @@ def test_has_comments_zero_reel(darwin):
|
||||
def test_has_comments_regex_cases(darwin):
|
||||
# Specific edge cases string tests
|
||||
assert darwin._has_comments('<node text="View all 12 comments" />') is True
|
||||
assert darwin._has_comments('<node text="Alle 4 Kommentare ansehen" />') is True
|
||||
assert darwin._has_comments('<node text="View 1 comment" />') is True
|
||||
assert darwin._has_comments('<node text="1 Kommentar ansehen" />') is True
|
||||
assert darwin._has_comments('<node content-desc="Photo by Alice, 0 comments" />') is False
|
||||
assert darwin._has_comments('<node content-desc="Photo by Alice, 0 Kommentare" />') is False
|
||||
assert darwin._has_comments('<node content-desc="Liked by john and others, 1,234 comments" />') is True
|
||||
assert darwin._has_comments('<node content-desc="Liked by john and others, 12.345 Kommentare" />') is True
|
||||
# Just the comment button shouldn't trigger as having comments
|
||||
assert darwin._has_comments('<node content-desc="Comment" />') is False
|
||||
assert darwin._has_comments('<node content-desc="Kommentieren" />') is False
|
||||
|
||||
308
tests/unit/test_sae_zero_maintenance.py
Normal file
308
tests/unit/test_sae_zero_maintenance.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
🔴 RED: Zero-Maintenance Compliance for SituationalAwarenessEngine
|
||||
|
||||
These tests enforce that the SAE contains ZERO hardcoded UI element identifiers
|
||||
and ZERO hardcoded model/URL defaults. The bot must:
|
||||
|
||||
1. Detect obstacles via autonomous LLM+Qdrant pipeline — NOT via hardcoded resource-ids or text patterns.
|
||||
2. Pull ALL model names/URLs from Config() — NO fallback literals scattered across the codebase.
|
||||
3. Rely on structural package checks (app-agnostic) and ScreenIdentity delegation — NOT marker tuples.
|
||||
|
||||
Violating any of these rules means the bot breaks when Instagram updates its UI.
|
||||
That is the opposite of Zero Maintenance.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import re
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 1. NO HARDCODED INSTAGRAM UI ELEMENT IDENTIFIERS IN SAE
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSAEContainsNoHardcodedUIElements:
|
||||
"""
|
||||
The SAE perceive() must NOT contain any hardcoded Instagram resource-id
|
||||
fragments, button text patterns, or content-desc strings.
|
||||
ALL obstacle detection must flow through: package check → Qdrant cache → LLM fallback.
|
||||
"""
|
||||
|
||||
FORBIDDEN_RESOURCE_ID_FRAGMENTS = [
|
||||
"survey_overlay",
|
||||
"survey_title",
|
||||
"interstitial_container",
|
||||
"mystery_interstitial",
|
||||
"nux_overlay",
|
||||
"rating_prompt",
|
||||
"feedback_dialog",
|
||||
"action_bar_browser",
|
||||
"browser_action_bar",
|
||||
"quick_capture",
|
||||
"gallery_cancel_button",
|
||||
"creation_flow",
|
||||
"reel_camera",
|
||||
]
|
||||
|
||||
FORBIDDEN_BUTTON_TEXT_PATTERNS = [
|
||||
"Not Now",
|
||||
"not now",
|
||||
"Nicht jetzt",
|
||||
"Take Survey",
|
||||
"Bewerten",
|
||||
"rate \\d+ stars",
|
||||
]
|
||||
|
||||
FORBIDDEN_CONTENT_DESC_PATTERNS = [
|
||||
"Close browser",
|
||||
]
|
||||
|
||||
def _get_perceive_source(self):
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
|
||||
|
||||
return inspect.getsource(SituationalAwarenessEngine.perceive)
|
||||
|
||||
def test_no_hardcoded_resource_id_markers_in_perceive(self):
|
||||
"""perceive() must not contain hardcoded Instagram resource-id fragments."""
|
||||
source = self._get_perceive_source()
|
||||
for fragment in self.FORBIDDEN_RESOURCE_ID_FRAGMENTS:
|
||||
assert fragment not in source, (
|
||||
f"SAE.perceive() contains hardcoded resource-id fragment '{fragment}'! "
|
||||
f"This must be detected autonomously via LLM+Qdrant, not hardcoded."
|
||||
)
|
||||
|
||||
def test_no_hardcoded_button_text_patterns_in_perceive(self):
|
||||
"""perceive() must not contain hardcoded dismiss button text patterns."""
|
||||
source = self._get_perceive_source()
|
||||
for pattern in self.FORBIDDEN_BUTTON_TEXT_PATTERNS:
|
||||
assert pattern not in source, (
|
||||
f"SAE.perceive() contains hardcoded button text pattern '{pattern}'! "
|
||||
f"Modal detection must be autonomous, not text-matching."
|
||||
)
|
||||
|
||||
def test_no_hardcoded_content_desc_in_perceive(self):
|
||||
"""perceive() must not contain hardcoded content-desc strings."""
|
||||
source = self._get_perceive_source()
|
||||
for pattern in self.FORBIDDEN_CONTENT_DESC_PATTERNS:
|
||||
assert pattern not in source, (
|
||||
f"SAE.perceive() contains hardcoded content-desc '{pattern}'! " f"Must be discovered autonomously."
|
||||
)
|
||||
|
||||
def test_no_marker_tuples_in_perceive(self):
|
||||
"""perceive() must not define any '_markers' or '_patterns' tuples."""
|
||||
source = self._get_perceive_source()
|
||||
marker_defs = re.findall(r"\b\w+_markers\s*=\s*\(", source)
|
||||
pattern_defs = re.findall(r"\b\w+_patterns\s*=\s*\(", source)
|
||||
all_defs = marker_defs + pattern_defs
|
||||
assert len(all_defs) == 0, (
|
||||
f"SAE.perceive() defines hardcoded marker/pattern tuples: {all_defs}. "
|
||||
f"All obstacle detection must be autonomous."
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 2. NO HARDCODED MODEL NAMES / URLS IN SAE
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSAEContainsNoHardcodedModelDefaults:
|
||||
"""
|
||||
The SAE must never contain hardcoded model names or URLs as fallback strings.
|
||||
ALL model config must come from Config() — the single source of truth.
|
||||
A naked `except: model = "llava:latest"` is a maintenance bomb.
|
||||
"""
|
||||
|
||||
FORBIDDEN_MODEL_LITERALS = [
|
||||
"llava:latest",
|
||||
"qwen3.5:latest",
|
||||
"llava",
|
||||
]
|
||||
|
||||
FORBIDDEN_URL_LITERALS = [
|
||||
"localhost:11434",
|
||||
"http://localhost:11434/api/generate",
|
||||
]
|
||||
|
||||
def _get_sae_source(self):
|
||||
from GramAddict.core.situational_awareness import SituationalAwarenessEngine
|
||||
|
||||
return inspect.getsource(SituationalAwarenessEngine)
|
||||
|
||||
def test_no_hardcoded_model_names(self):
|
||||
"""SAE must not contain any hardcoded model name strings."""
|
||||
source = self._get_sae_source()
|
||||
for literal in self.FORBIDDEN_MODEL_LITERALS:
|
||||
# Only check actual string literals (quoted), not comments
|
||||
pattern = rf"""['"]({re.escape(literal)})['"]"""
|
||||
matches = re.findall(pattern, source)
|
||||
assert len(matches) == 0, (
|
||||
f"SAE contains hardcoded model name '{literal}' ({len(matches)} occurrence(s)). "
|
||||
f"Model config must come exclusively from Config()."
|
||||
)
|
||||
|
||||
def test_no_hardcoded_urls(self):
|
||||
"""SAE must not contain any hardcoded Ollama/API URLs."""
|
||||
source = self._get_sae_source()
|
||||
for literal in self.FORBIDDEN_URL_LITERALS:
|
||||
pattern = rf"""['"]([^'"]*{re.escape(literal)}[^'"]*)['"]"""
|
||||
matches = re.findall(pattern, source)
|
||||
assert len(matches) == 0, (
|
||||
f"SAE contains hardcoded URL '{literal}' ({len(matches)} occurrence(s)). "
|
||||
f"All URLs must come from Config()."
|
||||
)
|
||||
|
||||
def test_no_naked_except_with_model_defaults(self):
|
||||
"""SAE must not have except blocks that hardcode model fallbacks."""
|
||||
source = self._get_sae_source()
|
||||
# Pattern: `except` followed within 5 lines by a model/url assignment
|
||||
except_blocks = re.findall(
|
||||
r"except.*?:\s*\n(?:.*\n){0,5}.*(?:model|url)\s*=\s*['\"]",
|
||||
source,
|
||||
)
|
||||
assert len(except_blocks) == 0, (
|
||||
f"SAE has {len(except_blocks)} except block(s) with hardcoded model/URL fallbacks. "
|
||||
f"Config() must be the SSOT — if it fails, crash loudly (Fail Fast)."
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 2b. NO HARDCODED MODEL/URL DEFAULTS IN ANY MODULE (codebase-wide)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCodebaseContainsNoHardcodedModelDefaults:
|
||||
"""
|
||||
CODEBASE-WIDE enforcement: No Python module in GramAddict/core/ may contain
|
||||
hardcoded model names or localhost URLs as getattr() default values.
|
||||
|
||||
The ONLY file allowed to define these defaults is config.py (the SSOT).
|
||||
Every other module must read from Config().args WITHOUT providing a fallback literal.
|
||||
If Config().args is missing, the system must crash (Fail Fast) — not silently
|
||||
degrade to a potentially wrong model.
|
||||
"""
|
||||
|
||||
# These are the exact default-value patterns that are FORBIDDEN outside config.py.
|
||||
# They indicate a getattr(..., "ai_xxx", "HARDCODED_DEFAULT") anti-pattern.
|
||||
FORBIDDEN_DEFAULT_PATTERNS = [
|
||||
r'getattr\([^)]*,\s*["\']ai_[^"\']*["\']\s*,\s*["\']http://localhost:11434',
|
||||
r'getattr\([^)]*,\s*["\']ai_[^"\']*["\']\s*,\s*["\']llava:',
|
||||
r'getattr\([^)]*,\s*["\']ai_[^"\']*["\']\s*,\s*["\']qwen3\.',
|
||||
r'getattr\([^)]*,\s*["\']ai_[^"\']*["\']\s*,\s*["\']llama3\.',
|
||||
r'getattr\([^)]*,\s*["\']ai_[^"\']*["\']\s*,\s*["\']llama3\.2-vision',
|
||||
r'getattr\([^)]*,\s*["\']ai_[^"\']*["\']\s*,\s*["\']nomic-embed-text',
|
||||
]
|
||||
|
||||
# Files that are ALLOWED to have these defaults (SSOT only)
|
||||
ALLOWED_FILES = {"config.py"}
|
||||
|
||||
def _get_all_core_python_files(self):
|
||||
import os
|
||||
|
||||
core_dir = os.path.join(os.path.dirname(__file__), "..", "..", "GramAddict", "core")
|
||||
core_dir = os.path.normpath(core_dir)
|
||||
files = []
|
||||
for root, _dirs, filenames in os.walk(core_dir):
|
||||
for f in filenames:
|
||||
if f.endswith(".py") and f not in self.ALLOWED_FILES:
|
||||
files.append(os.path.join(root, f))
|
||||
return files
|
||||
|
||||
def test_no_getattr_with_hardcoded_model_defaults_outside_config(self):
|
||||
"""
|
||||
No module except config.py may use getattr(args, 'ai_xxx', 'HARDCODED_DEFAULT').
|
||||
The correct pattern is: getattr(cfg.args, 'ai_xxx') — no default, crash if missing.
|
||||
"""
|
||||
import os
|
||||
|
||||
violations = []
|
||||
for filepath in self._get_all_core_python_files():
|
||||
with open(filepath) as f:
|
||||
content = f.read()
|
||||
for pattern in self.FORBIDDEN_DEFAULT_PATTERNS:
|
||||
matches = re.findall(pattern, content)
|
||||
if matches:
|
||||
basename = os.path.basename(filepath)
|
||||
violations.append(f"{basename}: {len(matches)}x pattern '{pattern[:60]}...'")
|
||||
|
||||
assert len(violations) == 0, (
|
||||
f"Found {len(violations)} file(s) with hardcoded model/URL defaults outside config.py!\n"
|
||||
f"Config() is the SSOT. Remove default values from getattr() calls.\n"
|
||||
+ "\n".join(f" ❌ {v}" for v in violations)
|
||||
)
|
||||
|
||||
def test_no_bare_localhost_url_string_literals_outside_config(self):
|
||||
"""
|
||||
No module except config.py and llm_provider.py (routing logic) may contain
|
||||
'http://localhost:11434' as a bare string literal used as a fallback value.
|
||||
"""
|
||||
import os
|
||||
|
||||
allowed = {"config.py", "llm_provider.py"}
|
||||
violations = []
|
||||
core_dir = os.path.join(os.path.dirname(__file__), "..", "..", "GramAddict", "core")
|
||||
core_dir = os.path.normpath(core_dir)
|
||||
|
||||
for root, _dirs, filenames in os.walk(core_dir):
|
||||
for f in filenames:
|
||||
if f.endswith(".py") and f not in allowed:
|
||||
path = os.path.join(root, f)
|
||||
with open(path) as fh:
|
||||
for i, line in enumerate(fh, 1):
|
||||
if "localhost:11434" in line and not line.strip().startswith("#"):
|
||||
violations.append(f"{f}:{i}: {line.strip()[:100]}")
|
||||
|
||||
assert len(violations) == 0, (
|
||||
f"Found {len(violations)} hardcoded localhost:11434 references outside config.py/llm_provider.py!\n"
|
||||
+ "\n".join(f" ❌ {v}" for v in violations)
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 3. GOAP SMART UI STABILIZATION (Poll instead of static sleep)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGOAPSmartUIStabilization:
|
||||
"""
|
||||
After clicking an element, GOAP must poll for UI changes instead of
|
||||
using a static sleep. This prevents false 'inconclusive' results on
|
||||
slow devices/networks where UI transitions take >2s.
|
||||
"""
|
||||
|
||||
def test_goap_polls_dump_hierarchy_multiple_times_after_click(self):
|
||||
"""
|
||||
The GOAP _execute_action must call dump_hierarchy multiple times
|
||||
(polling loop) instead of a single post-click dump.
|
||||
We verify this by inspecting the source code for the poll pattern.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
|
||||
source = inspect.getsource(GoalExecutor._execute_action)
|
||||
|
||||
# Must have a polling loop with MAX_POLLS
|
||||
assert "MAX_POLLS" in source, "GOAP._execute_action must use a MAX_POLLS polling loop, not a static sleep."
|
||||
|
||||
# Must NOT have the old static random.uniform sleep
|
||||
assert "random.uniform" not in source, (
|
||||
"GOAP._execute_action still uses random.uniform for static sleep! " "Must use smart UI polling instead."
|
||||
)
|
||||
|
||||
# Must poll dump_hierarchy inside a loop
|
||||
assert "dump_hierarchy()" in source, "GOAP._execute_action must call dump_hierarchy() inside the poll loop."
|
||||
|
||||
def test_goap_has_no_static_sleep_after_click(self):
|
||||
"""The old pattern was: click → sleep(random) → single dump. This must be gone."""
|
||||
import inspect
|
||||
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
|
||||
source = inspect.getsource(GoalExecutor._execute_action)
|
||||
|
||||
# The click line and the next significant operation must NOT be a raw sleep with random
|
||||
click_idx = source.index("self.device.click(")
|
||||
code_after_click = source[click_idx : click_idx + 500]
|
||||
|
||||
assert "random.uniform" not in code_after_click, (
|
||||
"GOAP still uses random.uniform sleep after click! "
|
||||
"Replace with smart UI polling (poll dump_hierarchy until XML changes)."
|
||||
)
|
||||
103
tests/unit/test_structural_hardening.py
Normal file
103
tests/unit/test_structural_hardening.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resolver():
|
||||
return IntentResolver()
|
||||
|
||||
|
||||
def test_clips_author_fast_path(resolver):
|
||||
"""
|
||||
Ensures that Reel author usernames are resolved via the clips_author_username fast-path.
|
||||
"""
|
||||
reel_author_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/clips_author_username", text="maor.benezri", bounds=(0, 0, 100, 100)
|
||||
)
|
||||
|
||||
result = resolver.resolve("tap post username", [reel_author_node])
|
||||
assert result is not None
|
||||
assert result.resource_id == "com.instagram.android:id/clips_author_username"
|
||||
|
||||
|
||||
def test_scrubber_blacklist_for_author_intent(resolver):
|
||||
"""
|
||||
Ensures that the seeker/scrubber bar is filtered out for author intents.
|
||||
"""
|
||||
scrubber_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/scrubber",
|
||||
text="32291.0",
|
||||
content_desc="@2131978468",
|
||||
bounds=(0, 0, 500, 10),
|
||||
)
|
||||
|
||||
# We need a mock device that supports screenshots to enter _visual_discovery
|
||||
# where filter_navigation_conflicts is called for visual intents.
|
||||
class MockDevice:
|
||||
def screenshot(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def deviceV2(self):
|
||||
return self
|
||||
|
||||
# Since we can't easily mock the VLM response here without more setup,
|
||||
# we test filter_navigation_conflicts directly.
|
||||
candidates = [scrubber_node]
|
||||
filtered = resolver.filter_navigation_conflicts(candidates, "tap post username")
|
||||
|
||||
assert scrubber_node not in filtered, "Scrubber should be filtered out for author intents"
|
||||
|
||||
|
||||
def test_interaction_guard_excludes_scrubber_for_author(resolver):
|
||||
"""
|
||||
Verifies that the interaction guard correctly identifies and excludes the scrubber.
|
||||
"""
|
||||
scrubber_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/scrubber",
|
||||
text="32291.0",
|
||||
content_desc="@2131978468",
|
||||
bounds=(0, 0, 500, 10),
|
||||
)
|
||||
|
||||
filtered = resolver.filter_navigation_conflicts([scrubber_node], "post author username")
|
||||
assert len(filtered) == 0, "Interaction guard should have excluded the scrubber"
|
||||
|
||||
|
||||
def test_telepathic_grid_selection_uses_structural_id(monkeypatch):
|
||||
"""
|
||||
Ensures TelepathicEngine prefers grid_card_layout_container for grid candidates.
|
||||
"""
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
engine = TelepathicEngine()
|
||||
|
||||
# Mock nodes
|
||||
grid_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/grid_card_layout_container", bounds=(10, 10, 300, 300)
|
||||
)
|
||||
background_node = SpatialNode(
|
||||
resource_id="com.instagram.android:id/swipeable_nav_view_pager_inner_recycler_view", bounds=(0, 0, 1080, 2400)
|
||||
)
|
||||
|
||||
# Use monkeypatch instead of MagicMock
|
||||
monkeypatch.setattr(engine._parser, "parse", lambda x: "mock_root")
|
||||
monkeypatch.setattr(engine._parser, "get_all_nodes", lambda x: [grid_node, background_node])
|
||||
|
||||
candidates_captured = []
|
||||
|
||||
def mock_evaluate(device, persona_interests, candidates):
|
||||
candidates_captured.extend(candidates)
|
||||
return grid_node
|
||||
|
||||
monkeypatch.setattr(engine._evaluator, "evaluate_grid_visuals", mock_evaluate)
|
||||
|
||||
class MockDevice:
|
||||
def dump_hierarchy(self):
|
||||
return "<xml/>"
|
||||
|
||||
engine.evaluate_grid_visuals(MockDevice(), ["travel"])
|
||||
|
||||
assert background_node not in candidates_captured, "Background recycler view should not be a grid candidate"
|
||||
Reference in New Issue
Block a user