diff --git a/GramAddict/core/behaviors/close_friends_guard.py b/GramAddict/core/behaviors/close_friends_guard.py
index 6c99bb5..7205b35 100644
--- a/GramAddict/core/behaviors/close_friends_guard.py
+++ b/GramAddict/core/behaviors/close_friends_guard.py
@@ -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...")
diff --git a/GramAddict/core/behaviors/profile_guard.py b/GramAddict/core/behaviors/profile_guard.py
index 6a8391a..61fdde0 100644
--- a/GramAddict/core/behaviors/profile_guard.py
+++ b/GramAddict/core/behaviors/profile_guard.py
@@ -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"}
)
diff --git a/GramAddict/core/bot_flow.py b/GramAddict/core/bot_flow.py
index be71e3d..edbb40b 100644
--- a/GramAddict/core/bot_flow.py
+++ b/GramAddict/core/bot_flow.py
@@ -715,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"}
)
@@ -875,7 +875,7 @@ def _run_zero_latency_stories_loop(device, configs, session_state, cognitive_sta
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"},
diff --git a/GramAddict/core/perception/intent_resolver.py b/GramAddict/core/perception/intent_resolver.py
index 16804d2..ab40a76 100644
--- a/GramAddict/core/perception/intent_resolver.py
+++ b/GramAddict/core/perception/intent_resolver.py
@@ -180,6 +180,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
# ββββββββββββββββββββββββββββββββββββββββββββββ
@@ -582,15 +607,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
diff --git a/GramAddict/core/perception/screen_identity.py b/GramAddict/core/perception/screen_identity.py
index b527384..41b9ca8 100644
--- a/GramAddict/core/perception/screen_identity.py
+++ b/GramAddict/core/perception/screen_identity.py
@@ -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"
@@ -176,11 +177,34 @@ class ScreenIdentity:
# 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
+ # 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
+
# Priority 2: Structural Heuristics (100% Deterministic)
if "unified_follow_list_tab_layout" in ids or "follow_list_container" in ids:
return ScreenType.FOLLOW_LIST
@@ -193,7 +217,7 @@ class ScreenIdentity:
"profile_header_name",
)
if any(marker in ids for marker in PROFILE_MARKERS):
- own_profile_texts = ("edit profile", "share profile", "profil bearbeiten", "profil teilen")
+ 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
@@ -372,11 +396,10 @@ class ScreenIdentity:
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 "message" in desc_lower:
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()
):
diff --git a/GramAddict/core/qdrant_memory.py b/GramAddict/core/qdrant_memory.py
index c4232a2..fe193ab 100644
--- a/GramAddict/core/qdrant_memory.py
+++ b/GramAddict/core/qdrant_memory.py
@@ -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):
diff --git a/GramAddict/core/situational_awareness.py b/GramAddict/core/situational_awareness.py
index 35868fd..d6c5693 100644
--- a/GramAddict/core/situational_awareness.py
+++ b/GramAddict/core/situational_awareness.py
@@ -439,6 +439,11 @@ class SituationalAwarenessEngine:
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:
diff --git a/GramAddict/core/unfollow_engine.py b/GramAddict/core/unfollow_engine.py
index 9e80ab5..89dc8ad 100644
--- a/GramAddict/core/unfollow_engine.py
+++ b/GramAddict/core/unfollow_engine.py
@@ -105,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}
)
diff --git a/GramAddict/core/utils.py b/GramAddict/core/utils.py
index c745906..c671c39 100644
--- a/GramAddict/core/utils.py
+++ b/GramAddict/core/utils.py
@@ -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"):
diff --git a/tests/e2e/test_keyboard_guard.py b/tests/e2e/test_keyboard_guard.py
new file mode 100644
index 0000000..ca57cbd
--- /dev/null
+++ b/tests/e2e/test_keyboard_guard.py
@@ -0,0 +1,197 @@
+"""
+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!"
diff --git a/tests/unit/__pycache__/test_darwin_engine_comments.cpython-311-pytest-8.3.5.pyc b/tests/unit/__pycache__/test_darwin_engine_comments.cpython-311-pytest-8.3.5.pyc
index 1fcb1c4..08dfae2 100644
Binary files a/tests/unit/__pycache__/test_darwin_engine_comments.cpython-311-pytest-8.3.5.pyc and b/tests/unit/__pycache__/test_darwin_engine_comments.cpython-311-pytest-8.3.5.pyc differ
diff --git a/tests/unit/test_darwin_engine_comments.py b/tests/unit/test_darwin_engine_comments.py
index 9344faa..7fc43ac 100644
--- a/tests/unit/test_darwin_engine_comments.py
+++ b/tests/unit/test_darwin_engine_comments.py
@@ -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('') is True
- assert darwin._has_comments('') is True
assert darwin._has_comments('') is True
- assert darwin._has_comments('') is True
assert darwin._has_comments('') is False
- assert darwin._has_comments('') is False
assert darwin._has_comments('') is True
- assert darwin._has_comments('') is True
# Just the comment button shouldn't trigger as having comments
assert darwin._has_comments('') is False
- assert darwin._has_comments('') is False