from __future__ import annotations
import argparse
import json
import os
import re
import xml.etree.ElementTree as ET
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Set, Tuple
ANDROID_NS = "{http://schemas.android.com/apk/res/android}"
TOOLS_NS = "{http://schemas.android.com/tools}"
RETROFIT_ANNOTATIONS = {
"GET",
"POST",
"PUT",
"DELETE",
"PATCH",
"HEAD",
"OPTIONS",
"HTTP",
"Multipart",
"FormUrlEncoded",
"Streaming",
}
DAO_ANNOTATIONS = {"Query", "Insert", "Update", "Delete", "Upsert", "RawQuery", "Transaction"}
SKIP_DIR_NAMES = {".git", ".gradle", ".idea", "build", "out", "node_modules"}
INTERACTIVE_VIEW_TYPES = {
"Button",
"ImageButton",
"TextView",
"ImageView",
"FloatingActionButton",
"MaterialButton",
"Chip",
"CheckBox",
"Switch",
"SwitchCompat",
"RadioButton",
"LinearLayout",
"RelativeLayout",
"ConstraintLayout",
"FrameLayout",
"CardView",
"MaterialCardView",
"RecyclerView",
"ListView",
"GridView",
"SwipeRefreshLayout",
"NavigationView",
"Toolbar",
}
LIFECYCLE_ENTRY_METHODS = {
"onCreate",
"onStart",
"onResume",
"onViewCreated",
"onCreateView",
"onActivityCreated",
}
CUSTOM_ENTRY_NAME_RE = re.compile(
r"^(init(?:Data|View|Views|State)?|load(?:Data|Items|Content)?|fetch\w+|query\w+|refresh\w+|setup(?:Observers|Observer|Data|Ui)?|observe\w*|request\w+|populate\w+|bind\w+)$",
re.IGNORECASE,
)
SKIP_CALL_NAMES = {
"if",
"for",
"while",
"when",
"switch",
"catch",
"return",
"super",
"this",
"fun",
"class",
"interface",
"object",
"try",
"println",
"print",
"listOf",
"mutableListOf",
"mapOf",
"lazy",
"apply",
"also",
"let",
"run",
"with",
"checkNotNull",
"requireNotNull",
"setOnClickListener",
"setOnLongClickListener",
"setOnItemClickListener",
"setOnMenuItemClickListener",
"setOnNavigationItemSelectedListener",
"setNavigationItemSelectedListener",
"setOnRefreshListener",
}
@dataclass
class Location:
file: str
line: Optional[int] = None
snippet: Optional[str] = None
@dataclass
class LayoutComponent:
component_id: str
view_type: str
label: Optional[str]
source: Location
depth: int
order: int
resource_kind: str = "view"
on_click: Optional[str] = None
clickable: bool = False
layout_name: Optional[str] = None
@dataclass
class MenuItemInfo:
menu_name: str
item_id: str
title: Optional[str]
source: Location
order: int
@dataclass
class ClassInfo:
name: str
qualified_name: str
file_path: str
package_name: str
line: int
end_line: int
kind: str
annotations: List[str]
super_types: List[str]
imports: List[str]
body: str
fields: Dict[str, str] = field(default_factory=dict)
@dataclass
class MethodInfo:
name: str
owner_name: str
qualified_owner: str
owner_kind: str
receiver_type: Optional[str]
file_path: str
line: int
end_line: int
body_start_line: int
signature: str
body: str
annotations: List[str]
package_name: str
imports: List[str]
super_types: List[str] = field(default_factory=list)
fields: Dict[str, str] = field(default_factory=dict)
class_kind: str = "class"
@dataclass
class CallSite:
name: str
qualifier: Optional[str]
line: int
raw: str
class AndroidUIApiAnalyzer:
def __init__(self, project_root: str, max_depth: int = 4) -> None:
self.project_root = os.path.abspath(project_root)
self.max_depth = max(1, max_depth)
self.file_cache: Dict[str, str] = {}
self.manifest_path: Optional[str] = None
self.manifest_data: Dict[str, object] = {}
self.class_map: Dict[str, List[ClassInfo]] = defaultdict(list)
self.class_by_qualified: Dict[str, ClassInfo] = {}
self.methods: List[MethodInfo] = []
self.methods_by_name: Dict[str, List[MethodInfo]] = defaultdict(list)
self.methods_by_owner: Dict[str, Dict[str, List[MethodInfo]]] = defaultdict(lambda: defaultdict(list))
self.extension_methods: Dict[str, List[MethodInfo]] = defaultdict(list)
self.layout_files: Dict[str, str] = {}
self.menu_files: Dict[str, str] = {}
self.navigation_files: Dict[str, str] = {}
self.navigation_destinations: Dict[str, str] = {}
self.string_table: Dict[str, str] = {}
self.menu_items_by_id: Dict[str, List[MenuItemInfo]] = defaultdict(list)
self.resource_component_cache: Dict[str, Tuple[List[LayoutComponent], List[str]]] = {}
def analyze(self, target_activity: Optional[str] = None) -> Dict[str, object]:
self.discover_files()
self.parse_strings()
self.parse_manifest()
self.parse_navigation_resources()
self.parse_menu_resources()
self.parse_sources()
activities_from_manifest = self.manifest_data.get("activities", []) or []
source_inferred_activities = self.infer_activities_from_source()
activity_entries = self.merge_activity_entries(activities_from_manifest, source_inferred_activities)
analyses = []
for activity_entry in activity_entries:
activity_name = activity_entry["name"]
simple_name = simple_name_of(activity_name)
if target_activity and target_activity not in {activity_name, simple_name}:
continue
analyses.append(self.build_activity_analysis(activity_entry))
return {
"schema_version": "1.0.0",
"generated_at": datetime.now(timezone.utc).isoformat(),
"analyzed_activity": target_activity,
"project": {
"root_path": self.project_root.replace("\\", "/"),
"name": self.manifest_data.get("application_label") or os.path.basename(self.project_root),
"package_name": self.manifest_data.get("package_name"),
"manifest_path": self.rel(self.manifest_path) if self.manifest_path else None,
},
"activities": analyses,
}
def discover_files(self) -> None:
for root, dirs, files in os.walk(self.project_root):
dirs[:] = [directory for directory in dirs if directory not in SKIP_DIR_NAMES]
for file_name in files:
path = os.path.join(root, file_name)
if file_name == "AndroidManifest.xml":
if self.manifest_path is None and path.replace("\\", "/").endswith("src/main/AndroidManifest.xml"):
self.manifest_path = path
elif self.manifest_path is None:
self.manifest_path = path
elif file_name.endswith((".kt", ".java")):
continue
elif file_name.endswith(".xml") and "/res/layout" in path.replace("\\", "/"):
self.layout_files[os.path.splitext(file_name)[0]] = path
elif file_name.endswith(".xml") and "/res/menu" in path.replace("\\", "/"):
self.menu_files[os.path.splitext(file_name)[0]] = path
elif file_name.endswith(".xml") and "/res/navigation" in path.replace("\\", "/"):
self.navigation_files[os.path.splitext(file_name)[0]] = path
def parse_manifest(self) -> None:
if not self.manifest_path:
self.manifest_data = {"activities": []}
return
tree = ET.parse(self.manifest_path)
root = tree.getroot()
package_name = root.attrib.get("package")
application = root.find("application")
application_label = None
activities: List[Dict[str, object]] = []
if application is not None:
label_attr = application.attrib.get(f"{ANDROID_NS}label")
application_label = self.resolve_resource_string(label_attr)
for activity_tag in application.findall("activity") + application.findall("activity-alias"):
name = activity_tag.attrib.get(f"{ANDROID_NS}name")
if not name:
continue
qualified_name = qualify_android_name(name, package_name)
intent_filters = []
for intent_filter in activity_tag.findall("intent-filter"):
actions = [item.attrib.get(f"{ANDROID_NS}name") for item in intent_filter.findall("action")]
categories = [item.attrib.get(f"{ANDROID_NS}name") for item in intent_filter.findall("category")]
data_values = [item.attrib for item in intent_filter.findall("data")]
intent_filters.append(
{
"actions": [value for value in actions if value],
"categories": [value for value in categories if value],
"data": data_values,
}
)
activities.append(
{
"name": qualified_name,
"simple_name": simple_name_of(qualified_name),
"source": "manifest",
"manifest": {
"exported": activity_tag.attrib.get(f"{ANDROID_NS}exported"),
"theme": activity_tag.attrib.get(f"{ANDROID_NS}theme"),
"parent_activity": qualify_android_name(
activity_tag.attrib.get(f"{ANDROID_NS}parentActivityName", ""),
package_name,
)
if activity_tag.attrib.get(f"{ANDROID_NS}parentActivityName")
else None,
"launch_mode": activity_tag.attrib.get(f"{ANDROID_NS}launchMode"),
"intent_filters": intent_filters,
"location": {"file": self.rel(self.manifest_path), "line": find_line_for_text(self.read_text(self.manifest_path), name)},
},
}
)
self.manifest_data = {
"package_name": package_name,
"application_label": application_label,
"activities": activities,
}
def parse_strings(self) -> None:
for root, dirs, files in os.walk(self.project_root):
dirs[:] = [directory for directory in dirs if directory not in SKIP_DIR_NAMES]
normalized_root = root.replace("\\", "/")
if "/res/values" not in normalized_root:
continue
for file_name in files:
if not file_name.endswith(".xml"):
continue
path = os.path.join(root, file_name)
try:
tree = ET.parse(path)
except ET.ParseError:
continue
xml_root = tree.getroot()
for element in xml_root.findall("string"):
name = element.attrib.get("name")
if name and name not in self.string_table:
self.string_table[name] = "".join(element.itertext()).strip()
def parse_navigation_resources(self) -> None:
for file_name, path in self.navigation_files.items():
try:
tree = ET.parse(path)
except ET.ParseError:
continue
root = tree.getroot()
for element in root.iter():
destination_id = element.attrib.get(f"{ANDROID_NS}id")
destination_name = element.attrib.get(f"{ANDROID_NS}name") or element.attrib.get(f"{TOOLS_NS}layout")
if destination_id:
cleaned = clean_resource_id(destination_id)
if destination_name:
self.navigation_destinations[cleaned] = simple_name_of(destination_name)
else:
self.navigation_destinations[cleaned] = simple_name_of(cleaned)
def parse_menu_resources(self) -> None:
for menu_name, path in self.menu_files.items():
try:
tree = ET.parse(path)
except ET.ParseError:
continue
root = tree.getroot()
order = 0
for item in root.iter():
if simple_xml_tag(item.tag) != "item":
continue
item_id = item.attrib.get(f"{ANDROID_NS}id")
if not item_id:
continue
order += 1
title = self.resolve_resource_string(item.attrib.get(f"{ANDROID_NS}title"))
info = MenuItemInfo(
menu_name=menu_name,
item_id=clean_resource_id(item_id),
title=title,
source=Location(self.rel(path), find_line_for_text(self.read_text(path), item_id)),
order=order,
)
self.menu_items_by_id[info.item_id].append(info)
def parse_sources(self) -> None:
source_files: List[str] = []
for root, dirs, files in os.walk(self.project_root):
dirs[:] = [directory for directory in dirs if directory not in SKIP_DIR_NAMES]
for file_name in files:
if file_name.endswith((".kt", ".java")):
source_files.append(os.path.join(root, file_name))
all_classes: List[ClassInfo] = []
all_methods: List[MethodInfo] = []
for path in source_files:
classes, methods = self.parse_source_file(path)
all_classes.extend(classes)
all_methods.extend(methods)
for class_info in all_classes:
self.class_map[class_info.name].append(class_info)
self.class_by_qualified[class_info.qualified_name] = class_info
for method in all_methods:
self.methods.append(method)
self.methods_by_name[method.name].append(method)
owner_key = method.qualified_owner or method.owner_name
self.methods_by_owner[owner_key][method.name].append(method)
if method.owner_kind == "extension" and method.receiver_type:
self.extension_methods[method.receiver_type].append(method)
def parse_source_file(self, path: str) -> Tuple[List[ClassInfo], List[MethodInfo]]:
text = self.read_text(path)
package_name = extract_package_name(text)
imports = extract_imports(text)
classes = self.extract_classes(text, path, package_name, imports)
methods = self.extract_methods(text, path, package_name, imports, classes)
return classes, methods
def extract_classes(self, text: str, path: str, package_name: str, imports: List[str]) -> List[ClassInfo]:
classes: List[ClassInfo] = []
class_pattern = re.compile(
r"(?m)^[ \t]*(?:@[\w.]+(?:\([^\n]*\))?[ \t]*\n[ \t]*)*(?:public|private|protected|internal|open|abstract|sealed|data|enum|final|static|inner|\s)*\b(class|interface|object|enum)\s+([A-Za-z_][A-Za-z0-9_]*)\s*([^\n{]*)"
)
for match in class_pattern.finditer(text):
kind = match.group(1)
name = match.group(2)
suffix = match.group(3) or ""
brace_index = text.find("{", match.end())
if brace_index == -1:
continue
end_index = find_matching_brace(text, brace_index)
if end_index == -1:
continue
start_line = line_number_for_offset(text, match.start())
end_line = line_number_for_offset(text, end_index)
annotations = extract_leading_annotations(text, match.start())
super_types = parse_super_types_from_class_suffix(suffix)
body = text[brace_index + 1 : end_index]
header_segment = text[match.start() : brace_index]
fields = parse_fields(header_segment, body)
qualified_name = qualify_with_package(name, package_name)
classes.append(
ClassInfo(
name=name,
qualified_name=qualified_name,
file_path=path,
package_name=package_name,
line=start_line,
end_line=end_line,
kind=kind,
annotations=annotations,
super_types=super_types,
imports=imports,
body=body,
fields=fields,
)
)
return classes
def extract_methods(
self,
text: str,
path: str,
package_name: str,
imports: List[str],
classes: List[ClassInfo],
) -> List[MethodInfo]:
methods: List[MethodInfo] = []
class_ranges = sorted(classes, key=lambda item: (item.line, item.end_line))
kotlin_pattern = re.compile(
r"(?m)^[ \t]*(?:@[\w.]+(?:\([^\n]*\))?[ \t]*\n[ \t]*)*(?:public|private|protected|internal|override|open|abstract|suspend|tailrec|operator|infix|inline|external|final|\s)*fun\s+(?:<[^>]+>\s*)?(?:(\w+)\.)?([A-Za-z_][A-Za-z0-9_]*)\s*\("
)
java_pattern = re.compile(
r"(?m)^[ \t]*(?:@[\w.]+(?:\([^\n]*\))?[ \t]*\n[ \t]*)*(?:public|private|protected|static|final|synchronized|abstract|native|override|\s)+([A-Za-z_][A-Za-z0-9_<>,\[\]? ]+)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;\n]*\)\s*(?:throws [^{\n]+)?\{"
)
for match in kotlin_pattern.finditer(text):
receiver_type = match.group(1)
name = match.group(2)
owner = find_enclosing_class(class_ranges, line_number_for_offset(text, match.start()))
signature, body, start_line, end_line, body_start_line = extract_kotlin_callable(text, match.start(), match.end())
if not signature:
continue
annotations = extract_leading_annotations(text, match.start())
owner_name, qualified_owner, owner_kind, super_types, fields, class_kind = self.method_owner_context(owner, receiver_type)
methods.append(
MethodInfo(
name=name,
owner_name=owner_name,
qualified_owner=qualified_owner,
owner_kind=owner_kind,
receiver_type=receiver_type,
file_path=path,
line=start_line,
end_line=end_line,
body_start_line=body_start_line,
signature=signature,
body=body,
annotations=annotations,
package_name=package_name,
imports=imports,
super_types=super_types,
fields=fields,
class_kind=class_kind,
)
)
for match in java_pattern.finditer(text):
name = match.group(2)
owner = find_enclosing_class(class_ranges, line_number_for_offset(text, match.start()))
signature, body, start_line, end_line, body_start_line = extract_java_callable(text, match.start())
if not signature:
continue
annotations = extract_leading_annotations(text, match.start())
owner_name, qualified_owner, owner_kind, super_types, fields, class_kind = self.method_owner_context(owner, None)
methods.append(
MethodInfo(
name=name,
owner_name=owner_name,
qualified_owner=qualified_owner,
owner_kind=owner_kind,
receiver_type=None,
file_path=path,
line=start_line,
end_line=end_line,
body_start_line=body_start_line,
signature=signature,
body=body,
annotations=annotations,
package_name=package_name,
imports=imports,
super_types=super_types,
fields=fields,
class_kind=class_kind,
)
)
return deduplicate_methods(methods)
def method_owner_context(self, owner: Optional[ClassInfo], receiver_type: Optional[str]) -> Tuple[str, str, str, List[str], Dict[str, str], str]:
if owner is not None:
return (
owner.name,
owner.qualified_name,
"class",
owner.super_types,
owner.fields,
owner.kind,
)
if receiver_type:
return (receiver_type, receiver_type, "extension", [], {}, "extension")
return ("<top-level>", "<top-level>", "top_level", [], {}, "top_level")
def infer_activities_from_source(self) -> List[Dict[str, object]]:
activities = []
seen: Set[str] = set()
for candidates in self.class_map.values():
for class_info in candidates:
if any("Activity" in value for value in class_info.super_types) or class_info.name.endswith("Activity"):
if class_info.qualified_name in seen:
continue
seen.add(class_info.qualified_name)
activities.append(
{
"name": class_info.qualified_name,
"simple_name": class_info.name,
"source": "source_inference",
"manifest": None,
}
)
return activities
def merge_activity_entries(self, *activity_groups: Sequence[Dict[str, object]]) -> List[Dict[str, object]]:
merged: List[Dict[str, object]] = []
seen_names: Set[str] = set()
seen_simple_names: Set[str] = set()
for group in activity_groups:
for activity_entry in group:
activity_name = str(activity_entry.get("name") or "")
simple_name = str(activity_entry.get("simple_name") or simple_name_of(activity_name))
if activity_name and activity_name in seen_names:
continue
if simple_name and simple_name in seen_simple_names:
continue
merged.append(activity_entry)
if activity_name:
seen_names.add(activity_name)
if simple_name:
seen_simple_names.add(simple_name)
return merged
def build_activity_analysis(self, activity_entry: Dict[str, object]) -> Dict[str, object]:
activity_name = str(activity_entry["name"])
class_info = self.resolve_class(activity_name)
surface = self.get_owner_surface(class_info)
layouts = self.discover_layouts_for_surface(surface)
layout_components, fragment_names = self.collect_layout_components(layouts)
menus = self.discover_menus_for_surface(surface)
menu_items = self.collect_menu_items(menus)
ui_components = self.collect_ui_components(
activity_name=simple_name_of(activity_name),
owner_path=simple_name_of(activity_name),
surface=surface,
layout_components=layout_components,
menu_items=menu_items,
)
fragment_analyses = []
for fragment_name in sorted(set(fragment_names + self.discover_fragments_in_surface(surface))):
fragment_class = self.resolve_class(fragment_name)
if fragment_class is None:
continue
fragment_surface = self.get_owner_surface(fragment_class)
fragment_layouts = self.discover_layouts_for_surface(fragment_surface)
fragment_components, nested_fragments = self.collect_layout_components(fragment_layouts)
fragment_ui = self.collect_ui_components(
activity_name=simple_name_of(activity_name),
owner_path=f"{simple_name_of(activity_name)} > {fragment_class.name}",
surface=fragment_surface,
layout_components=fragment_components,
menu_items=self.collect_menu_items(self.discover_menus_for_surface(fragment_surface)),
)
fragment_entries = self.collect_page_data_entries(fragment_surface, f"{simple_name_of(activity_name)} > {fragment_class.name}")
fragment_analyses.append(
{
"fragment_name": fragment_class.name,
"source_file": self.rel(fragment_class.file_path),
"layouts": fragment_layouts,
"nested_fragment_refs": nested_fragments,
"ui_components": fragment_ui,
"page_data_loading_entries": fragment_entries,
}
)
adapter_components = self.collect_adapter_components(simple_name_of(activity_name), surface)
ui_components.extend(adapter_components)
ui_components.sort(key=lambda item: (item.get("sort_order", 10_000), item.get("component_id") or "", item.get("source_location", {}).get("line") or 0))
page_entries = self.collect_page_data_entries(surface, simple_name_of(activity_name))
functional_evidence = self.build_functional_evidence(activity_entry, class_info, layouts, menus, fragment_names, page_entries, ui_components)
return {
"activity_name": simple_name_of(activity_name),
"qualified_activity_name": activity_name,
"source_file": self.rel(class_info.file_path) if class_info else None,
"manifest": activity_entry.get("manifest"),
"layouts": layouts,
"menus": menus,
"hosted_fragments": sorted(set(fragment_names)),
"functional_evidence": functional_evidence,
"ui_components": ui_components,
"page_data_loading_entries": page_entries,
"fragment_analyses": fragment_analyses,
}
def build_functional_evidence(
self,
activity_entry: Dict[str, object],
class_info: Optional[ClassInfo],
layouts: List[Dict[str, object]],
menus: List[Dict[str, object]],
fragment_names: List[str],
page_entries: List[Dict[str, object]],
ui_components: List[Dict[str, object]],
) -> List[Dict[str, object]]:
evidence: List[Dict[str, object]] = []
manifest = activity_entry.get("manifest") or {}
if manifest:
for intent_filter in manifest.get("intent_filters", []):
for action in intent_filter.get("actions", []):
evidence.append(
{
"kind": "manifest_intent_action",
"value": action,
"source": manifest.get("location"),
}
)
for layout in layouts:
evidence.append(
{
"kind": "layout",
"value": layout["layout_name"],
"source": layout["evidence"],
}
)
for menu in menus:
evidence.append(
{
"kind": "menu",
"value": menu["menu_name"],
"source": menu["evidence"],
}
)
for fragment_name in sorted(set(fragment_names)):
evidence.append(
{
"kind": "fragment_ref",
"value": fragment_name,
"source": {"file": self.rel(class_info.file_path), "line": class_info.line} if class_info else None,
}
)
for entry in page_entries:
evidence.append(
{
"kind": "page_entry",
"value": entry["entry_name"],
"source": entry["source_location"],
}
)
evidence.append(
{
"kind": "interactive_component_count",
"value": len(ui_components),
"source": {"file": self.rel(class_info.file_path), "line": class_info.line} if class_info else None,
}
)
return evidence
def get_owner_surface(self, class_info: Optional[ClassInfo]) -> Dict[str, object]:
if class_info is None:
return {"root_class": None, "classes": [], "methods": [], "extensions": []}
classes = []
visited: Set[str] = set()
queue = [class_info]
while queue:
current = queue.pop(0)
if current.qualified_name in visited:
continue
visited.add(current.qualified_name)
classes.append(current)
for super_type in current.super_types:
super_class = self.resolve_class(super_type)
if super_class is not None and super_class.qualified_name not in visited:
queue.append(super_class)
methods: List[MethodInfo] = []
extensions: List[MethodInfo] = []
for class_item in classes:
owner_key = class_item.qualified_name
for method_group in self.methods_by_owner.get(owner_key, {}).values():
methods.extend(method_group)
for receiver_name in {class_item.name, class_item.qualified_name}:
extensions.extend(self.extension_methods.get(receiver_name, []))
deduped_methods = deduplicate_methods(methods + extensions)
return {
"root_class": class_info,
"classes": classes,
"methods": deduped_methods,
"extensions": extensions,
}
def discover_layouts_for_surface(self, surface: Dict[str, object]) -> List[Dict[str, object]]:
layouts: List[Dict[str, object]] = []
seen: Set[Tuple[str, str]] = set()
for class_info in surface.get("classes", []):
file_text = self.read_text(class_info.file_path)
for match in re.finditer(r"setContentView\s*\(\s*R\.layout\.([A-Za-z0-9_]+)", file_text):
layout_name = match.group(1)
if layout_name in self.layout_files and (layout_name, class_info.file_path) not in seen:
seen.add((layout_name, class_info.file_path))
layouts.append(
{
"layout_name": layout_name,
"layout_file": self.rel(self.layout_files[layout_name]),
"binding_type": "setContentView",
"evidence": {"file": self.rel(class_info.file_path), "line": line_number_for_offset(file_text, match.start())},
}
)
for match in re.finditer(r"DataBindingUtil\.setContentView(?:<[^>]+>)?\s*\([^,]+,\s*R\.layout\.([A-Za-z0-9_]+)", file_text):
layout_name = match.group(1)
if layout_name in self.layout_files and (layout_name, class_info.file_path) not in seen:
seen.add((layout_name, class_info.file_path))
layouts.append(
{
"layout_name": layout_name,
"layout_file": self.rel(self.layout_files[layout_name]),
"binding_type": "data_binding",
"evidence": {"file": self.rel(class_info.file_path), "line": line_number_for_offset(file_text, match.start())},
}
)
for match in re.finditer(r"([A-Za-z0-9_]+Binding)\.inflate\s*\(", file_text):
layout_name = binding_class_to_layout(match.group(1))
if layout_name in self.layout_files and (layout_name, class_info.file_path) not in seen:
seen.add((layout_name, class_info.file_path))
layouts.append(
{
"layout_name": layout_name,
"layout_file": self.rel(self.layout_files[layout_name]),
"binding_type": "view_binding",
"evidence": {"file": self.rel(class_info.file_path), "line": line_number_for_offset(file_text, match.start())},
}
)
for match in re.finditer(r"inflate\s*\(\s*R\.layout\.([A-Za-z0-9_]+)\s*,", file_text):
layout_name = match.group(1)
if layout_name in self.layout_files and (layout_name, class_info.file_path) not in seen:
seen.add((layout_name, class_info.file_path))
layouts.append(
{
"layout_name": layout_name,
"layout_file": self.rel(self.layout_files[layout_name]),
"binding_type": "layout_inflater",
"evidence": {"file": self.rel(class_info.file_path), "line": line_number_for_offset(file_text, match.start())},
}
)
return layouts
def discover_menus_for_surface(self, surface: Dict[str, object]) -> List[Dict[str, object]]:
menus: List[Dict[str, object]] = []
seen: Set[Tuple[str, str]] = set()
for class_info in surface.get("classes", []):
file_text = self.read_text(class_info.file_path)
for match in re.finditer(r"R\.menu\.([A-Za-z0-9_]+)", file_text):
menu_name = match.group(1)
if menu_name not in self.menu_files:
continue
if (menu_name, class_info.file_path) in seen:
continue
seen.add((menu_name, class_info.file_path))
menus.append(
{
"menu_name": menu_name,
"menu_file": self.rel(self.menu_files[menu_name]),
"evidence": {"file": self.rel(class_info.file_path), "line": line_number_for_offset(file_text, match.start())},
}
)
return menus
def discover_fragments_in_surface(self, surface: Dict[str, object]) -> List[str]:
fragments: List[str] = []
fragment_pattern = re.compile(r"(?:replace|add|show)\s*\([^\n]*?([A-Za-z_][A-Za-z0-9_]*Fragment)\s*\(")
for class_info in surface.get("classes", []):
text = self.read_text(class_info.file_path)
fragments.extend(match.group(1) for match in fragment_pattern.finditer(text))
return fragments
def collect_layout_components(self, layouts: Sequence[Dict[str, object]]) -> Tuple[Dict[str, LayoutComponent], List[str]]:
component_map: Dict[str, LayoutComponent] = {}
fragment_names: List[str] = []
for layout in layouts:
layout_name = layout["layout_name"]
components, fragments = self.parse_layout_recursive(layout_name)
fragment_names.extend(fragments)
for component in components:
if component.component_id not in component_map:
component_map[component.component_id] = component
return component_map, fragment_names
def parse_layout_recursive(self, layout_name: str, visited: Optional[Set[str]] = None) -> Tuple[List[LayoutComponent], List[str]]:
if visited is None:
visited = set()
if layout_name in visited:
return [], []
if layout_name in self.resource_component_cache:
return self.resource_component_cache[layout_name]
visited.add(layout_name)
path = self.layout_files.get(layout_name)
if not path:
return [], []
try:
tree = ET.parse(path)
except ET.ParseError:
return [], []
root = tree.getroot()
components: List[LayoutComponent] = []
fragments: List[str] = []
order = 0
file_text = self.read_text(path)
def walk(element: ET.Element, depth: int) -> None:
nonlocal order
tag = simple_xml_tag(element.tag)
if tag == "include":
layout_ref = element.attrib.get("layout") or element.attrib.get(f"{ANDROID_NS}layout")
if layout_ref and layout_ref.startswith("@layout/"):
nested_name = layout_ref.split("/", 1)[1]
nested_components, nested_fragments = self.parse_layout_recursive(nested_name, visited)
components.extend(nested_components)
fragments.extend(nested_fragments)
return
component_id_attr = element.attrib.get(f"{ANDROID_NS}id")
component_id = clean_resource_id(component_id_attr) if component_id_attr else None
label = self.resolve_first_label(element.attrib)
on_click = element.attrib.get(f"{ANDROID_NS}onClick")
clickable = element.attrib.get(f"{ANDROID_NS}clickable") == "true"
if component_id:
order += 1
components.append(
LayoutComponent(
component_id=component_id,
view_type=tag,
label=label,
source=Location(self.rel(path), find_line_for_text(file_text, component_id_attr or component_id)),
depth=depth,
order=order,
on_click=on_click,
clickable=clickable or tag in INTERACTIVE_VIEW_TYPES,
layout_name=layout_name,
)
)
if tag == "fragment":
fragment_name = element.attrib.get(f"{ANDROID_NS}name") or element.attrib.get("class")
if fragment_name:
fragments.append(simple_name_of(fragment_name))
for child in list(element):
walk(child, depth + 1)
walk(root, 0)
self.resource_component_cache[layout_name] = (components, fragments)
return components, fragments
def collect_menu_items(self, menus: Sequence[Dict[str, object]]) -> Dict[str, MenuItemInfo]:
menu_map: Dict[str, MenuItemInfo] = {}
for menu in menus:
for item_id, infos in self.menu_items_by_id.items():
for info in infos:
if info.menu_name == menu["menu_name"] and item_id not in menu_map:
menu_map[item_id] = info
return menu_map
def collect_ui_components(
self,
activity_name: str,
owner_path: str,
surface: Dict[str, object],
layout_components: Dict[str, LayoutComponent],
menu_items: Dict[str, MenuItemInfo],
) -> List[Dict[str, object]]:
items: List[Dict[str, object]] = []
seen_keys: Set[Tuple[str, str, int]] = set()
known_ids = set(layout_components.keys()) | set(menu_items.keys())
for component in layout_components.values():
if component.on_click:
handler_method = self.resolve_method_in_surface(surface, component.on_click)
if handler_method:
item = self.build_ui_component_from_handler(
activity_name=activity_name,
owner_path=owner_path,
component_id=component.component_id,
view_type=component.view_type,
label=component.label,
interaction_type="click",
handler_method=handler_method,
handler_body=handler_method.body,
source_location={"file": self.rel(handler_method.file_path), "line": handler_method.line},
layout_component=component,
)
key = (item["component_id"], item["interaction_type"], item["source_location"].get("line") or 0)
if key not in seen_keys:
seen_keys.add(key)
items.append(item)
for method in surface.get("methods", []):
extracted = self.extract_listener_events_from_method(method, known_ids, layout_components, menu_items)
for event in extracted:
item = self.build_ui_component_from_handler(
activity_name=activity_name,
owner_path=owner_path,
component_id=event["component_id"],
view_type=event["view_type"],
label=event.get("label"),
interaction_type=event["interaction_type"],
handler_method=event["handler_method"],
handler_body=event["handler_body"],
source_location=event["source_location"],
layout_component=layout_components.get(event["component_id"]),
sort_order=event.get("sort_order"),
resource_kind=event.get("resource_kind", "view"),
)
key = (item["component_id"], item["interaction_type"], item["source_location"].get("line") or 0)
if key not in seen_keys:
seen_keys.add(key)
items.append(item)
return items
def build_ui_component_from_handler(
self,
activity_name: str,
owner_path: str,
component_id: str,
view_type: str,
label: Optional[str],
interaction_type: str,
handler_method: MethodInfo,
handler_body: str,
source_location: Dict[str, object],
layout_component: Optional[LayoutComponent] = None,
sort_order: Optional[int] = None,
resource_kind: str = "view",
) -> Dict[str, object]:
navigation = self.detect_navigation_targets(handler_body, activity_name)
api_dependencies = self.trace_from_body(handler_method, handler_body, source_location.get("line") or handler_method.line)
return {
"activity": owner_path,
"component_id": component_id,
"view_type": view_type,
"component_label": label,
"resource_kind": resource_kind,
"source_location": source_location,
"interaction_type": interaction_type,
"handler_entry": {
"owner": handler_method.owner_name,
"method_name": handler_method.name,
"file": self.rel(handler_method.file_path),
"line": handler_method.line,
},
"navigation_target": navigation,
"api_dependencies": api_dependencies,
"evidence": self.build_component_evidence(layout_component, handler_method),
"sort_order": sort_order or (layout_component.order if layout_component else 50_000),
}
def build_component_evidence(self, layout_component: Optional[LayoutComponent], handler_method: MethodInfo) -> List[Dict[str, object]]:
evidence = [
{
"kind": "handler_method",
"source": {"file": self.rel(handler_method.file_path), "line": handler_method.line},
}
]
if layout_component is not None:
evidence.append(
{
"kind": "layout_component",
"source": {"file": layout_component.source.file, "line": layout_component.source.line},
}
)
return evidence
def collect_adapter_components(self, activity_name: str, surface: Dict[str, object]) -> List[Dict[str, object]]:
root_classes: List[ClassInfo] = surface.get("classes", [])
if not root_classes:
return []
root_texts = {item.file_path: self.read_text(item.file_path) for item in root_classes}
referenced_adapter_names: Set[str] = set()
for text in root_texts.values():
for match in re.finditer(r"([A-Za-z_][A-Za-z0-9_]*Adapter)\s*\(", text):
referenced_adapter_names.add(match.group(1))
for match in re.finditer(r"new\s+([A-Za-z_][A-Za-z0-9_]*Adapter)\s*\(", text):
referenced_adapter_names.add(match.group(1))
items: List[Dict[str, object]] = []
for adapter_name in sorted(referenced_adapter_names):
adapter_class = self.resolve_class(adapter_name)
if adapter_class is None:
continue
adapter_surface = self.get_owner_surface(adapter_class)
adapter_layouts = self.discover_layouts_for_surface(adapter_surface)
adapter_components, _ = self.collect_layout_components(adapter_layouts)
known_ids = set(adapter_components.keys())
for method in adapter_surface.get("methods", []):
events = self.extract_listener_events_from_method(method, known_ids, adapter_components, {})
for event in events:
component_id = event["component_id"]
if component_id == "<unknown>":
component_id = f"{snake_case(adapter_name)}_item_click"
item = self.build_ui_component_from_handler(
activity_name=activity_name,
owner_path=f"{activity_name} > {adapter_name}",
component_id=component_id,
view_type=event.get("view_type") or f"{adapter_name} Item",
label=event.get("label"),
interaction_type=event["interaction_type"],
handler_method=event["handler_method"],
handler_body=event["handler_body"],
source_location=event["source_location"],
layout_component=adapter_components.get(component_id),
sort_order=90_000 + (event.get("sort_order") or 0),
resource_kind="adapter_item",
)
items.append(item)
return items
def collect_page_data_entries(self, surface: Dict[str, object], owner_path: str) -> List[Dict[str, object]]:
entries: List[Dict[str, object]] = []
seen: Set[Tuple[str, int]] = set()
for method in surface.get("methods", []):
if not self.is_page_data_entry_method(method):
continue
dependencies = self.trace_method_dependencies(method, visited=set(), depth=0, chain=[])
if not dependencies:
continue
key = (method.name, method.line)
if key in seen:
continue
seen.add(key)
entries.append(
{
"activity": owner_path,
"entry_name": method.name,
"entry_type": self.classify_entry_type(method),
"purpose": humanize_entry_name(method.name),
"source_location": {"file": self.rel(method.file_path), "line": method.line},
"api_dependencies": dependencies,
"evidence": [
{
"kind": "entry_method",
"source": {"file": self.rel(method.file_path), "line": method.line},
}
],
}
)
entries.sort(key=lambda item: (item["source_location"].get("line") or 0, item["entry_name"]))
return entries
def is_page_data_entry_method(self, method: MethodInfo) -> bool:
if method.name in LIFECYCLE_ENTRY_METHODS:
return True
return bool(CUSTOM_ENTRY_NAME_RE.match(method.name))
def classify_entry_type(self, method: MethodInfo) -> str:
if method.name in LIFECYCLE_ENTRY_METHODS:
return "lifecycle"
if method.name.lower().startswith("observe") or method.name.lower().startswith("setupobserver"):
return "observer_setup"
if method.name.lower().startswith("init"):
return "initialization"
if method.name.lower().startswith(("load", "fetch", "query", "refresh", "request")):
return "data_loading"
return "custom_entry"
def extract_listener_events_from_method(
self,
method: MethodInfo,
known_ids: Set[str],
layout_components: Dict[str, LayoutComponent],
menu_items: Dict[str, MenuItemInfo],
) -> List[Dict[str, object]]:
body = method.body
events: List[Dict[str, object]] = []
listener_patterns = [
("setOnClickListener", "click"),
("setOnLongClickListener", "long_click"),
("setOnItemClickListener", "item_click"),
("setOnMenuItemClickListener", "menu_click"),
("setOnNavigationItemSelectedListener", "navigation_select"),
("setNavigationItemSelectedListener", "navigation_select"),
("setOnRefreshListener", "refresh"),
]
for listener_name, interaction_type in listener_patterns:
pattern = re.compile(rf"(?P<target>[A-Za-z0-9_\.<>?()]+)\s*\.\s*{listener_name}\s*(?P<tail>\{{|\(|::|this)")
for match in pattern.finditer(body):
event = self.listener_event_from_match(method, match, interaction_type, known_ids, layout_components, menu_items)
if event:
events.append(event)
explicit_this_click = re.compile(r"(?P<target>[A-Za-z0-9_\.<>?()]+)\s*\.\s*setOnClickListener\s*\(\s*this\s*\)")
for match in explicit_this_click.finditer(body):
component_id = self.resolve_component_id(match.group("target"), known_ids)
handler_method = self.resolve_method_for_name(method, "onClick")
if component_id and handler_method:
events.append(
{
"component_id": component_id,
"view_type": layout_components.get(component_id).view_type if component_id in layout_components else "View",
"label": layout_components.get(component_id).label if component_id in layout_components else None,
"interaction_type": "click",
"handler_method": handler_method,
"handler_body": handler_method.body,
"source_location": {"file": self.rel(method.file_path), "line": method.body_start_line + count_newlines(body[: match.start()])},
"sort_order": layout_components.get(component_id).order if component_id in layout_components else 50_000,
}
)
if method.name in {"onOptionsItemSelected", "onNavigationItemSelected"}:
events.extend(self.extract_branch_events(method, layout_components, menu_items))
return events
def listener_event_from_match(
self,
method: MethodInfo,
match: re.Match[str],
interaction_type: str,
known_ids: Set[str],
layout_components: Dict[str, LayoutComponent],
menu_items: Dict[str, MenuItemInfo],
) -> Optional[Dict[str, object]]:
target_expr = match.group("target")
tail = match.group("tail")
component_id = self.resolve_component_id(target_expr, known_ids)
if not component_id:
if target_expr.endswith("itemView") or target_expr.endswith("binding.root"):
component_id = "<unknown>"
else:
return None
event_line = method.body_start_line + count_newlines(method.body[: match.start()])
handler_method = method
handler_body = method.body
if tail == "::":
reference_match = re.match(r"::\s*([A-Za-z_][A-Za-z0-9_]*)", method.body[match.end() :].lstrip())
if reference_match:
referenced = self.resolve_method_for_name(method, reference_match.group(1))
if referenced:
handler_method = referenced
handler_body = referenced.body
elif tail == "{":
brace_pos = method.body.find("{", match.end() - 1)
if brace_pos != -1:
end = find_matching_brace(method.body, brace_pos)
if end != -1:
handler_body = method.body[brace_pos + 1 : end]
elif tail == "(":
following = method.body[match.end() - 1 :]
anon_pos = following.find("{")
if anon_pos != -1:
absolute = match.end() - 1 + anon_pos
end = find_matching_brace(method.body, absolute)
if end != -1:
anon_body = method.body[absolute + 1 : end]
nested = self.extract_anonymous_callback_body(anon_body)
if nested:
handler_body = nested
elif tail == "this":
resolved = self.resolve_method_for_name(method, "onClick")
if resolved:
handler_method = resolved
handler_body = resolved.body
component = layout_components.get(component_id)
menu = menu_items.get(component_id)
view_type = component.view_type if component else ("MenuItem" if menu else "View")
label = component.label if component else (menu.title if menu else None)
sort_order = component.order if component else (80_000 + (menu.order if menu else 0))
resource_kind = "menu_item" if menu else "view"
return {
"component_id": component_id,
"view_type": view_type,
"label": label,
"interaction_type": interaction_type,
"handler_method": handler_method,
"handler_body": handler_body,
"source_location": {"file": self.rel(method.file_path), "line": event_line},
"sort_order": sort_order,
"resource_kind": resource_kind,
}
def extract_branch_events(
self,
method: MethodInfo,
layout_components: Dict[str, LayoutComponent],
menu_items: Dict[str, MenuItemInfo],
) -> List[Dict[str, object]]:
body = method.body
branch_regexes = [re.compile(r"R\.id\.([A-Za-z0-9_]+)\s*->"), re.compile(r"case\s+R\.id\.([A-Za-z0-9_]+)\s*:")]
matches: List[Tuple[str, int, int]] = []
for regex in branch_regexes:
for match in regex.finditer(body):
matches.append((match.group(1), match.start(), match.end()))
matches.sort(key=lambda item: item[1])
events: List[Dict[str, object]] = []
for index, (component_id, start, _) in enumerate(matches):
next_start = matches[index + 1][1] if index + 1 < len(matches) else len(body)
snippet = body[start:next_start]
component = layout_components.get(component_id)
menu = menu_items.get(component_id)
events.append(
{
"component_id": component_id,
"view_type": component.view_type if component else "MenuItem",
"label": component.label if component else (menu.title if menu else None),
"interaction_type": "menu_select",
"handler_method": method,
"handler_body": snippet,
"source_location": {"file": self.rel(method.file_path), "line": method.body_start_line + count_newlines(body[:start])},
"sort_order": component.order if component else (80_000 + (menu.order if menu else index)),
"resource_kind": "menu_item",
}
)
return events
def resolve_component_id(self, target_expr: str, known_ids: Set[str]) -> Optional[str]:
id_match = re.search(r"R\.id\.([A-Za-z0-9_]+)", target_expr)
if id_match:
return id_match.group(1)
if "binding." in target_expr:
prop = target_expr.split("binding.", 1)[1].split(".", 1)[0]
return resolve_view_token(prop, known_ids)
token = target_expr.split(".")[-1].split("?")[-1]
return resolve_view_token(token, known_ids)
def extract_anonymous_callback_body(self, anon_body: str) -> Optional[str]:
for name in ("onClick", "onLongClick", "onMenuItemClick", "onRefresh"):
matcher = re.search(rf"{name}\s*\([^)]*\)\s*\{{", anon_body)
if not matcher:
continue
brace_pos = anon_body.find("{", matcher.start())
if brace_pos == -1:
continue
end = find_matching_brace(anon_body, brace_pos)
if end == -1:
continue
return anon_body[brace_pos + 1 : end]
return None
def detect_navigation_targets(self, body: str, activity_name: str) -> Dict[str, object]:
targets: List[Tuple[str, str]] = []
for match in re.finditer(r"Intent\s*\([^\n]*?([A-Za-z_][A-Za-z0-9_]*Activity)::class\.java", body):
targets.append(("activity", match.group(1)))
for match in re.finditer(r"new\s+Intent\s*\([^\n]*?,\s*([A-Za-z_][A-Za-z0-9_]*Activity)\.class", body):
targets.append(("activity", match.group(1)))
for match in re.finditer(r"navigate\s*\(\s*R\.id\.([A-Za-z0-9_]+)", body):
destination_id = match.group(1)
targets.append(("activity", self.navigation_destinations.get(destination_id, destination_id)))
for match in re.finditer(r"(?:replace|add|show)\s*\([^\n]*?([A-Za-z_][A-Za-z0-9_]*Fragment)\s*\(", body):
fragment_name = match.group(1)
targets.append(("fragment", f"{fragment_name} (Fragment)"))
if re.search(r"\bfinish\s*\(", body):
targets.append(("back", "[Back]"))
if re.search(r"AlertDialog\.Builder|DialogFragment|BottomSheetDialogFragment|\.show\s*\(", body):
targets.append(("dialog", f"{activity_name} (self)"))
if re.search(r"Intent\.ACTION_VIEW|Intent\.ACTION_SEND|Intent\.createChooser|Uri\.parse\s*\(", body):
targets.append(("external", "N/A (external)"))
if re.search(r"startService\s*\(|bindService\s*\(|startForegroundService\s*\(", body):
targets.append(("self", f"{activity_name} (self)"))
if not targets and re.search(r"submitList\s*\(|notifyDataSetChanged\s*\(|setVisibility\s*\(|isVisible\s*=|setText\s*\(|adapter\s*=|refresh\s*\(", body):
targets.append(("self", f"{activity_name} (self)"))
deduped: List[Tuple[str, str]] = []
seen = set()
for item in targets:
if item not in seen:
seen.add(item)
deduped.append(item)
if not deduped:
return {"target_type": "unknown", "target": "N/A", "candidates": []}
target_type = deduped[0][0] if len({kind for kind, _ in deduped}) == 1 else "mixed"
return {
"target_type": target_type,
"target": " / ".join(value for _, value in deduped),
"candidates": [{"type": kind, "target": value} for kind, value in deduped],
}
def trace_from_body(self, method: MethodInfo, body: str, body_line: int) -> List[Dict[str, object]]:
dependencies = self.detect_system_api_dependencies(body, method, body_line, chain=[])
additional = self.trace_calls_in_body(method, body, body_line, depth=0, visited=set(), chain=[])
return deduplicate_dependency_dicts(dependencies + additional)
def trace_method_dependencies(
self,
method: MethodInfo,
visited: Set[Tuple[str, int, str]],
depth: int,
chain: List[Dict[str, object]],
) -> List[Dict[str, object]]:
key = (method.file_path, method.line, method.name)
if key in visited or depth > self.max_depth:
return []
visited = set(visited)
visited.add(key)
dependencies = self.detect_system_api_dependencies(method.body, method, method.body_start_line, chain)
dependencies.extend(self.trace_calls_in_body(method, method.body, method.body_start_line, depth, visited, chain))
return deduplicate_dependency_dicts(dependencies)
def trace_calls_in_body(
self,
method: MethodInfo,
body: str,
body_line: int,
depth: int,
visited: Set[Tuple[str, int, str]],
chain: List[Dict[str, object]],
) -> List[Dict[str, object]]:
if depth >= self.max_depth:
return []
dependencies: List[Dict[str, object]] = []
for call in extract_call_sites(body, body_line):
resolved = self.resolve_call(method, call)
if resolved is None:
continue
step = {
"from": f"{method.owner_name}.{method.name}",
"to": f"{resolved.owner_name}.{resolved.name}",
"call": call.raw,
"source": {"file": self.rel(method.file_path), "line": call.line},
}
nested = self.trace_method_dependencies(resolved, visited, depth + 1, chain + [step])
include_current = self.should_include_method_dependency(resolved, nested)
if include_current:
dependencies.append(self.method_dependency_record(resolved, chain + [step]))
dependencies.extend(nested)
return dependencies
def resolve_call(self, current_method: MethodInfo, call: CallSite) -> Optional[MethodInfo]:
candidates: List[MethodInfo] = []
if call.qualifier:
qualifier_tokens = [part for part in re.split(r"[?.]", call.qualifier) if part]
qualifier = qualifier_tokens[-1] if qualifier_tokens else call.qualifier
if qualifier in {"this", "super"}:
candidates.extend(self.lookup_owner_methods(current_method, call.name, include_extensions=True))
elif qualifier in current_method.fields:
type_name = current_method.fields[qualifier]
candidates.extend(self.lookup_methods_by_type(type_name, call.name))
elif qualifier_tokens and qualifier_tokens[0] in current_method.fields:
type_name = current_method.fields[qualifier_tokens[0]]
candidates.extend(self.lookup_methods_by_type(type_name, call.name))
elif qualifier and qualifier[:1].isupper():
candidates.extend(self.lookup_methods_by_type(qualifier, call.name))
else:
candidates.extend(self.lookup_owner_methods(current_method, call.name, include_extensions=True))
unique = unique_method(candidates)
if unique:
return unique
interesting = [method for method in self.methods_by_name.get(call.name, []) if self.classify_method_dependency_type(method) != "custom_method"]
if len(interesting) == 1:
return interesting[0]
if len(self.methods_by_name.get(call.name, [])) == 1:
return self.methods_by_name[call.name][0]
return None
def lookup_owner_methods(self, method: MethodInfo, method_name: str, include_extensions: bool = False) -> List[MethodInfo]:
results: List[MethodInfo] = []
owner_names = [method.qualified_owner, method.owner_name]
for owner_name in owner_names:
results.extend(self.methods_by_owner.get(owner_name, {}).get(method_name, []))
for super_type in method.super_types:
super_class = self.resolve_class(super_type)
if super_class:
results.extend(self.methods_by_owner.get(super_class.qualified_name, {}).get(method_name, []))
results.extend(self.methods_by_owner.get(super_class.name, {}).get(method_name, []))
if include_extensions:
results.extend([item for item in self.extension_methods.get(super_type, []) if item.name == method_name])
if super_class:
results.extend([item for item in self.extension_methods.get(super_class.qualified_name, []) if item.name == method_name])
if include_extensions:
for receiver in {method.owner_name, method.qualified_owner}:
results.extend([item for item in self.extension_methods.get(receiver, []) if item.name == method_name])
return deduplicate_methods(results)
def lookup_methods_by_type(self, type_name: str, method_name: str) -> List[MethodInfo]:
results: List[MethodInfo] = []
resolved_class = self.resolve_class(type_name)
if resolved_class:
results.extend(self.methods_by_owner.get(resolved_class.qualified_name, {}).get(method_name, []))
results.extend(self.methods_by_owner.get(resolved_class.name, {}).get(method_name, []))
results.extend([item for item in self.extension_methods.get(resolved_class.name, []) if item.name == method_name])
else:
results.extend(self.methods_by_owner.get(type_name, {}).get(method_name, []))
return deduplicate_methods(results)
def should_include_method_dependency(self, method: MethodInfo, nested: List[Dict[str, object]]) -> bool:
category = self.classify_method_dependency_type(method)
if category != "custom_method":
return True
if method.name in LIFECYCLE_ENTRY_METHODS | {"onClick", "onOptionsItemSelected", "onNavigationItemSelected"}:
return False
if nested:
return False
return True
def classify_method_dependency_type(self, method: MethodInfo) -> str:
annotation_names = {annotation.split("(", 1)[0].lstrip("@").split(".")[-1] for annotation in method.annotations}
owner_class = self.resolve_class(method.owner_name) or self.resolve_class(method.qualified_owner)
owner_annotations = set(owner_class.annotations if owner_class else [])
owner_simple = method.owner_name
if annotation_names & RETROFIT_ANNOTATIONS:
return "network_api"
if annotation_names & DAO_ANNOTATIONS or "@Dao" in owner_annotations or owner_simple.endswith("Dao"):
return "dao_method"
if owner_simple.endswith(("Repository", "Repo")):
return "repository_method"
if owner_simple.endswith(("UseCase", "Interactor")):
return "usecase_method"
return "custom_method"
def method_dependency_record(self, method: MethodInfo, chain: List[Dict[str, object]]) -> Dict[str, object]:
return {
"api_type": self.classify_method_dependency_type(method),
"declaration": compact_signature(method.signature, method.owner_name),
"owner": method.owner_name,
"source": {"file": self.rel(method.file_path), "line": method.line},
"evidence_chain": chain,
}
def detect_system_api_dependencies(
self,
body: str,
method: MethodInfo,
body_line: int,
chain: List[Dict[str, object]],
) -> List[Dict[str, object]]:
dependencies: List[Dict[str, object]] = []
patterns = [
(re.compile(r"\bcontentResolver\.(query|insert|update|delete|openInputStream|openOutputStream)\s*\("), "content_resolver", lambda m: f"android.content.ContentResolver.{m.group(1)}(...)"),
(re.compile(r"\bMediaStore\b"), "mediastore_query", lambda _m: "android.provider.MediaStore"),
(re.compile(r"\bregisterForActivityResult\s*\("), "system_intent", lambda _m: "androidx.activity.result.ActivityResultCaller.registerForActivityResult(...)"),
(re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*Launcher\.launch\s*\("), "system_intent", lambda _m: "androidx.activity.result.ActivityResultLauncher.launch(...)"),
(re.compile(r"\bstartActivity(?:ForResult)?\s*\("), "system_intent", lambda _m: "android.app.Activity.startActivity(...)"),
(re.compile(r"\bstartService\s*\("), "service_call", lambda _m: "android.content.Context.startService(...)"),
(re.compile(r"\bbindService\s*\("), "service_call", lambda _m: "android.content.Context.bindService(...)"),
(re.compile(r"\bstartForegroundService\s*\("), "service_call", lambda _m: "android.content.Context.startForegroundService(...)"),
(re.compile(r"\bIntent\s*\("), "system_intent", lambda _m: "android.content.Intent(...)"),
]
for regex, api_type, declaration_builder in patterns:
for match in regex.finditer(body):
dependencies.append(
{
"api_type": api_type,
"declaration": declaration_builder(match),
"owner": "android",
"source": {"file": self.rel(method.file_path), "line": body_line + count_newlines(body[: match.start()])},
"evidence_chain": chain,
}
)
return deduplicate_dependency_dicts(dependencies)
def resolve_method_in_surface(self, surface: Dict[str, object], method_name: str) -> Optional[MethodInfo]:
candidates = [method for method in surface.get("methods", []) if method.name == method_name]
return unique_method(candidates) or (candidates[0] if candidates else None)
def resolve_method_for_name(self, method: MethodInfo, name: str) -> Optional[MethodInfo]:
candidates = self.lookup_owner_methods(method, name, include_extensions=True)
return unique_method(candidates) or (candidates[0] if candidates else None)
def resolve_class(self, class_name: Optional[str]) -> Optional[ClassInfo]:
if not class_name:
return None
normalized = class_name.replace("$", ".")
if normalized in self.class_by_qualified:
return self.class_by_qualified[normalized]
simple = simple_name_of(normalized)
candidates = self.class_map.get(simple, [])
if len(candidates) == 1:
return candidates[0]
package_name = self.manifest_data.get("package_name")
if package_name:
qualified = qualify_android_name(normalized, str(package_name))
if qualified in self.class_by_qualified:
return self.class_by_qualified[qualified]
return candidates[0] if candidates else None
def resolve_first_label(self, attributes: Dict[str, str]) -> Optional[str]:
for key in (f"{ANDROID_NS}text", f"{ANDROID_NS}contentDescription", f"{ANDROID_NS}hint", f"{ANDROID_NS}title"):
if key in attributes:
return self.resolve_resource_string(attributes.get(key))
return None
def resolve_resource_string(self, value: Optional[str]) -> Optional[str]:
if not value:
return value
if value.startswith("@string/"):
return self.string_table.get(value.split("/", 1)[1], value)
return value
def read_text(self, path: str) -> str:
if path not in self.file_cache:
try:
with open(path, "r", encoding="utf-8", errors="ignore") as handle:
self.file_cache[path] = handle.read()
except FileNotFoundError:
self.file_cache[path] = ""
return self.file_cache[path]
def rel(self, path: Optional[str]) -> Optional[str]:
if not path:
return path
return Path(os.path.relpath(path, self.project_root)).as_posix()
def extract_package_name(text: str) -> str:
match = re.search(r"(?m)^\s*package\s+([A-Za-z0-9_.]+)", text)
return match.group(1) if match else ""
def extract_imports(text: str) -> List[str]:
return re.findall(r"(?m)^\s*import\s+([A-Za-z0-9_.*]+)", text)
def parse_super_types_from_class_suffix(suffix: str) -> List[str]:
suffix = suffix.strip()
if not suffix:
return []
result: List[str] = []
if ":" in suffix:
after_colon = suffix.split(":", 1)[1]
for item in after_colon.split(","):
cleaned = clean_type_name(item)
if cleaned:
result.append(cleaned)
else:
extends_match = re.search(r"extends\s+([A-Za-z0-9_.<>]+)", suffix)
if extends_match:
result.append(clean_type_name(extends_match.group(1)))
implements_match = re.search(r"implements\s+([A-Za-z0-9_., <>]+)", suffix)
if implements_match:
result.extend(clean_type_name(part) for part in implements_match.group(1).split(",") if clean_type_name(part))
return [value for value in result if value]
def parse_fields(header: str, body: str) -> Dict[str, str]:
fields: Dict[str, str] = {}
for match in re.finditer(r"(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z0-9_.,<>? ]+)", header):
fields[match.group(1)] = clean_type_name(match.group(2))
for match in re.finditer(r"(?m)^[ \t]*(?:private|protected|public|internal)?\s*(?:lateinit\s+)?(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z0-9_.,<>? ]+)", body):
fields[match.group(1)] = clean_type_name(match.group(2))
for match in re.finditer(r"(?m)^[ \t]*(?:private|protected|public|internal)?\s*(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([A-Z][A-Za-z0-9_<>]*)\s*\(", body):
fields[match.group(1)] = clean_type_name(match.group(2))
for match in re.finditer(r"(?m)^[ \t]*(?:private|protected|public|internal)?\s*(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s+by\s+[A-Za-z0-9_]+<([A-Za-z_][A-Za-z0-9_]*)>", body):
fields[match.group(1)] = clean_type_name(match.group(2))
for match in re.finditer(r"(?m)^[ \t]*(?:private|protected|public)\s+(?:final\s+)?([A-Z][A-Za-z0-9_<>?,. ]+)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|;)", body):
fields[match.group(2)] = clean_type_name(match.group(1))
return fields
def extract_leading_annotations(text: str, start_index: int) -> List[str]:
lines = text[:start_index].splitlines()
annotations: List[str] = []
for line in reversed(lines[-6:]):
stripped = line.strip()
if not stripped:
if annotations:
break
continue
if stripped.startswith("@"):
annotations.insert(0, stripped)
continue
break
return annotations
def extract_kotlin_callable(text: str, start_index: int, after_name_index: int) -> Tuple[str, str, int, int, int]:
line_start = text.rfind("\n", 0, start_index) + 1
signature_start_line = line_number_for_offset(text, start_index)
search_limit = min(len(text), after_name_index + 3000)
segment = text[after_name_index:search_limit]
body_offset = None
body_mode = None
for relative_index, char in enumerate(segment):
if char == "{":
body_offset = after_name_index + relative_index
body_mode = "block"
break
if char == "=":
body_offset = after_name_index + relative_index
body_mode = "expression"
break
if body_offset is None:
signature_end = text.find("\n", line_start)
signature = text[line_start:signature_end if signature_end != -1 else len(text)].strip()
return signature, "", signature_start_line, signature_start_line, signature_start_line
if body_mode == "block":
end = find_matching_brace(text, body_offset)
if end == -1:
return "", "", 0, 0, 0
signature = text[line_start:body_offset].strip()
body = text[body_offset + 1 : end]
return (
compact_whitespace(signature),
body,
signature_start_line,
line_number_for_offset(text, end),
line_number_for_offset(text, body_offset + 1),
)
line_end = text.find("\n", body_offset)
if line_end == -1:
line_end = len(text)
signature = text[line_start:body_offset].strip()
body = text[body_offset + 1 : line_end].strip()
return compact_whitespace(signature), body, signature_start_line, line_number_for_offset(text, line_end), signature_start_line
def extract_java_callable(text: str, start_index: int) -> Tuple[str, str, int, int, int]:
line_start = text.rfind("\n", 0, start_index) + 1
brace_pos = text.find("{", start_index)
if brace_pos == -1:
return "", "", 0, 0, 0
end = find_matching_brace(text, brace_pos)
if end == -1:
return "", "", 0, 0, 0
signature = text[line_start:brace_pos].strip()
body = text[brace_pos + 1 : end]
start_line = line_number_for_offset(text, start_index)
return compact_whitespace(signature), body, start_line, line_number_for_offset(text, end), line_number_for_offset(text, brace_pos + 1)
def find_enclosing_class(classes: Sequence[ClassInfo], line: int) -> Optional[ClassInfo]:
candidates = [item for item in classes if item.line <= line <= item.end_line]
if not candidates:
return None
return sorted(candidates, key=lambda item: (item.end_line - item.line))[0]
def compact_signature(signature: str, owner_name: str) -> str:
signature = compact_whitespace(signature)
if signature.startswith("fun") or signature.startswith("public") or signature.startswith("private") or signature.startswith("protected"):
return signature
return f"{owner_name}.{signature}"
def compact_whitespace(value: str) -> str:
return re.sub(r"\s+", " ", value).strip()
def line_number_for_offset(text: str, offset: int) -> int:
return text.count("\n", 0, max(offset, 0)) + 1
def count_newlines(text: str) -> int:
return text.count("\n")
def find_matching_brace(text: str, open_index: int) -> int:
depth = 0
in_single = False
in_double = False
in_line_comment = False
in_block_comment = False
escape = False
for index in range(open_index, len(text)):
char = text[index]
nxt = text[index + 1] if index + 1 < len(text) else ""
if in_line_comment:
if char == "\n":
in_line_comment = False
continue
if in_block_comment:
if char == "*" and nxt == "/":
in_block_comment = False
continue
if in_single:
if char == "'" and not escape:
in_single = False
escape = char == "\\" and not escape
continue
if in_double:
if char == '"' and not escape:
in_double = False
escape = char == "\\" and not escape
continue
if char == "/" and nxt == "/":
in_line_comment = True
continue
if char == "/" and nxt == "*":
in_block_comment = True
continue
if char == "'":
in_single = True
continue
if char == '"':
in_double = True
continue
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return index
return -1
def extract_call_sites(body: str, body_line: int) -> List[CallSite]:
calls: List[CallSite] = []
occupied: List[Tuple[int, int]] = []
qualified_pattern = re.compile(r"(?P<qualifier>[A-Za-z_][A-Za-z0-9_?.]*)\s*\.\s*(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*\(")
unqualified_pattern = re.compile(r"(?<![\w.])(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*\(")
for match in qualified_pattern.finditer(body):
name = match.group("name")
if name in SKIP_CALL_NAMES:
continue
calls.append(
CallSite(
name=name,
qualifier=match.group("qualifier"),
line=body_line + count_newlines(body[: match.start()]),
raw=compact_whitespace(match.group(0)),
)
)
occupied.append((match.start(), match.end()))
for match in unqualified_pattern.finditer(body):
name = match.group("name")
if name in SKIP_CALL_NAMES or name[:1].isupper():
continue
if any(start <= match.start() < end for start, end in occupied):
continue
calls.append(
CallSite(
name=name,
qualifier=None,
line=body_line + count_newlines(body[: match.start()]),
raw=compact_whitespace(match.group(0)),
)
)
return calls
def unique_method(methods: Sequence[MethodInfo]) -> Optional[MethodInfo]:
methods = deduplicate_methods(methods)
return methods[0] if len(methods) == 1 else None
def deduplicate_methods(methods: Sequence[MethodInfo]) -> List[MethodInfo]:
result: List[MethodInfo] = []
seen: Set[Tuple[str, int, str, str]] = set()
for method in methods:
key = (method.file_path, method.line, method.owner_name, method.name)
if key in seen:
continue
seen.add(key)
result.append(method)
return result
def deduplicate_dependency_dicts(items: Sequence[Dict[str, object]]) -> List[Dict[str, object]]:
result: List[Dict[str, object]] = []
seen: Set[Tuple[str, str, str, int]] = set()
for item in items:
source = item.get("source") or {}
key = (
str(item.get("api_type")),
str(item.get("declaration")),
str(source.get("file")),
int(source.get("line") or 0),
)
if key in seen:
continue
seen.add(key)
result.append(item)
return result
def find_line_for_text(text: str, needle: Optional[str]) -> Optional[int]:
if not needle:
return None
position = text.find(needle)
if position == -1:
return None
return line_number_for_offset(text, position)
def clean_type_name(value: str) -> str:
value = value.strip().rstrip("?")
if not value:
return value
value = value.split("<", 1)[0]
value = value.split("(", 1)[0]
value = value.split(" ", 1)[0]
return simple_name_of(value)
def simple_name_of(value: str) -> str:
return value.split(".")[-1].replace("$", ".").split(".")[-1]
def qualify_with_package(name: str, package_name: str) -> str:
return f"{package_name}.{name}" if package_name else name
def qualify_android_name(name: str, package_name: Optional[str]) -> str:
if not name:
return name
if name.startswith(".") and package_name:
return f"{package_name}{name}"
if "." not in name and package_name:
return f"{package_name}.{name}"
return name
def clean_resource_id(value: str) -> str:
return value.replace("@+id/", "").replace("@id/", "")
def simple_xml_tag(tag: str) -> str:
if "}" in tag:
return tag.split("}", 1)[1]
return tag.split(".")[-1]
def binding_class_to_layout(binding_name: str) -> str:
return snake_case(binding_name.removesuffix("Binding"))
def snake_case(value: str) -> str:
first = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", value)
second = re.sub("([a-z0-9])([A-Z])", r"\1_\2", first)
return second.replace("__", "_").lower()
def resolve_view_token(token: str, known_ids: Set[str]) -> Optional[str]:
candidates = [token, snake_case(token)]
for candidate in candidates:
if candidate in known_ids:
return candidate
return None
def humanize_entry_name(name: str) -> str:
lowered = name.lower()
if lowered == "oncreate":
return "Initialize the screen and trigger first-load work"
if lowered == "onstart":
return "Refresh data when the screen becomes visible"
if lowered == "onresume":
return "Resume page state and refresh visible data"
if lowered.startswith("setupobserver") or lowered.startswith("observe"):
return "Attach observers that can trigger UI data updates"
if lowered.startswith("init"):
return "Initialize page state required for first render"
if lowered.startswith(("load", "fetch", "query", "refresh", "request")):
return "Load or refresh data for the page"
return f"Execute {name} for page rendering"
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Analyze Android UI navigation and API dependencies.")
parser.add_argument("--project", required=True, help="Android project root path")
parser.add_argument("--output", required=True, help="Output JSON path")
parser.add_argument("--activity", help="Analyze only one Activity by simple or qualified name")
parser.add_argument("--max-depth", type=int, default=4, help="Maximum static call-trace depth (default: 4)")
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
args = parser.parse_args(argv)
analyzer = AndroidUIApiAnalyzer(args.project, max_depth=args.max_depth)
result = analyzer.analyze(target_activity=args.activity)
output_path = os.path.abspath(args.output)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as handle:
json.dump(result, handle, indent=2 if args.pretty else None, ensure_ascii=False)
handle.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())