"""Phase 3: Static source code analysis for Activities — extract layout bindings, click handlers,
menus, dialogs, fragments, dynamic views, adapter item layouts, and dialog custom layouts
from Kotlin/Java source files."""
import json
import os
import re
import sys
def analyze_activity_source(source_file):
"""Analyze an Activity source file and extract UI-related information."""
try:
with open(source_file, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except Exception as e:
return {"error": str(e)}
result = {
"source_file": source_file,
"layout_file": None,
"binding_class": None,
"actions": [],
"menus": [],
"dialogs": [],
"fragments": [],
"dynamic_elements": [],
}
m = re.search(r'setContentView\s*\(\s*R\.layout\.(\w+)\s*\)', content)
if m:
result["layout_file"] = m.group(1)
m = re.search(r'(\w+Binding)\.inflate\s*\(', content)
if not m:
m = re.search(r'viewBinding\s*\(\s*(\w+Binding)\s*::\s*inflate\s*\)', content)
if not m:
m = re.search(r'import\s+[\w.]+\.databinding\.(\w+Binding)', content)
if m:
result["binding_class"] = m.group(1)
binding_name = m.group(1)
if binding_name.endswith("Binding"):
layout_name = re.sub(r'(?<!^)(?=[A-Z])', '_', binding_name[:-7]).lower()
if not result["layout_file"]:
result["layout_file"] = layout_name
for m in re.finditer(
r'binding\.(\w+)\s*\.\s*setOnClickListener\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}',
content, re.DOTALL
):
view_name = m.group(1)
body = m.group(2)
view_id = _camel_to_snake(view_name)
action = _analyze_click_body(body, view_id)
result["actions"].append(action)
for m in re.finditer(
r'(\w+)\s*\.\s*setOnClickListener\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}',
content, re.DOTALL
):
var_name = m.group(1)
if var_name == "binding":
continue
body = m.group(2)
action = _analyze_click_body(body, var_name)
if action not in result["actions"]:
result["actions"].append(action)
for m in re.finditer(
r'binding\.(\w+)\s*\.\s*setOnLongClickListener\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)',
content, re.DOTALL
):
view_name = m.group(1)
body = m.group(2)
view_id = _camel_to_snake(view_name)
action = _analyze_click_body(body, view_id, event="onLongClick")
result["actions"].append(action)
seen_menus = set()
menu_patterns = [
r'inflate\s*\(\s*R\.menu\.(\w+)',
r'inflateMenu\s*\(\s*R\.menu\.(\w+)',
r'R\.menu\.(\w+)',
]
for pat in menu_patterns:
for m in re.finditer(pat, content):
menu_file = m.group(1)
if menu_file not in seen_menus:
seen_menus.add(menu_file)
result["menus"].append({"menu_file": menu_file})
menu_handler_pattern = re.compile(
r'R\.id\.(\w+)\s*->\s*\{?([^}]*(?:\{[^}]*\}[^}]*)*)\}?',
re.DOTALL
)
item_selected_match = re.search(
r'onOptionsItemSelected\s*\([^)]*\)\s*[:{]\s*(.*?)(?=\n\s*(?:override\s+fun|fun\s|private\s|protected\s|\}))',
content, re.DOTALL
)
if item_selected_match:
block = item_selected_match.group(1)
for mm in menu_handler_pattern.finditer(block):
item_id = mm.group(1)
body = mm.group(2)
action = _analyze_click_body(body, item_id, event="onMenuItemClick")
result["actions"].append(action)
for m in re.finditer(r'(\w+Dialog)\s*\(', content):
dialog_class = m.group(1)
if dialog_class in ("AlertDialog", "ProgressDialog"):
continue
result["dialogs"].append({
"dialog_class": dialog_class,
"type": "custom",
})
for m in re.finditer(r'AlertDialog\.Builder\s*\(', content):
result["dialogs"].append({"dialog_class": "AlertDialog", "type": "alert"})
for m in re.finditer(r'MaterialAlertDialogBuilder\s*\(', content):
result["dialogs"].append({"dialog_class": "MaterialAlertDialog", "type": "material_alert"})
for m in re.finditer(r'\.replace\s*\(\s*R\.id\.(\w+)\s*,\s*(\w+)\s*[\(,]', content):
container_id = m.group(1)
fragment_class = m.group(2)
result["fragments"].append({
"fragment_class": fragment_class,
"container_id": container_id,
})
for m in re.finditer(r'(\w+Adapter)\s*\(', content):
adapter_class = m.group(1)
if "Pager" in adapter_class or "Fragment" in adapter_class:
result["fragments"].append({
"fragment_class": adapter_class,
"container_id": None,
"type": "pager_adapter",
})
for m in re.finditer(r'addView\s*\(\s*(\w+)', content):
result["dynamic_elements"].append({
"variable": m.group(1),
"description": f"Dynamically added view: {m.group(1)}",
})
for m in re.finditer(r'Intent\s*\(\s*\w+\s*,\s*(\w+)\s*::\s*class\.java\s*\)', content):
target = m.group(1)
result["actions"].append({
"view_id": "_navigation",
"event": "startActivity",
"navigates_to": target,
"description": f"Navigates to {target}",
})
for m in re.finditer(r'launchActivity\s*<\s*(\w+)\s*>', content):
target = m.group(1)
result["actions"].append({
"view_id": "_navigation",
"event": "startActivity",
"navigates_to": target,
"description": f"Navigates to {target}",
})
return result
def _camel_to_snake(name):
"""Convert camelCase to snake_case."""
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
def _analyze_click_body(body, view_id, event="onClick"):
"""Analyze the body of a click handler to determine the action."""
action = {
"view_id": view_id,
"event": event,
"description": "",
"navigates_to": None,
}
m = re.search(r'Intent\s*\(\s*\w+\s*,\s*(\w+)\s*::\s*class\.java', body)
if m:
target = m.group(1)
action["navigates_to"] = target
action["description"] = f"Navigates to {target}"
return action
m = re.search(r'launchActivity\s*<\s*(\w+)\s*>', body)
if m:
target = m.group(1)
action["navigates_to"] = target
action["description"] = f"Navigates to {target}"
return action
if re.search(r'\bfinish\s*\(\s*\)', body):
action["description"] = "Closes this activity"
action["navigates_to"] = "[Back]"
return action
m = re.search(r'(\w+Dialog)\s*\(', body)
if m:
action["description"] = f"Opens {m.group(1)}"
action["navigates_to"] = f"{m.group(1)} (dialog)"
return action
if re.search(r'share|Share', body):
action["description"] = "Shares content"
elif re.search(r'delete|Delete|remove|Remove', body):
action["description"] = "Deletes item"
elif re.search(r'edit|Edit|crop|Crop', body):
action["description"] = "Opens editor"
elif re.search(r'copy|Copy', body):
action["description"] = "Copies item"
elif re.search(r'move|Move', body):
action["description"] = "Moves item"
elif re.search(r'rename|Rename', body):
action["description"] = "Renames item"
elif re.search(r'sort|Sort', body):
action["description"] = "Changes sort order"
elif re.search(r'filter|Filter', body):
action["description"] = "Filters content"
elif re.search(r'refresh|Refresh', body):
action["description"] = "Refreshes content"
elif re.search(r'toggle|Toggle|switch|Switch', body):
action["description"] = "Toggles state"
else:
action["description"] = f"Click handler on {view_id}"
return action
def scan_adapters(source_dirs):
"""Scan adapter source files to extract item layout mappings.
Returns: { adapter_class_name: { "source_file": ..., "item_layouts": [...], "recycler_view_id": ... } }
"""
adapters = {}
binding_pattern = re.compile(r'(\w+Binding)\.inflate\s*\(')
class_pattern = re.compile(r'class\s+(\w+Adapter)')
for source_dir in source_dirs:
if not os.path.isdir(source_dir):
continue
for root, dirs, files in os.walk(source_dir):
for f in files:
if not (f.endswith(".kt") or f.endswith(".java")):
continue
filepath = os.path.join(root, f)
try:
with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
content = fh.read()
except Exception:
continue
class_match = class_pattern.search(content)
if not class_match:
continue
adapter_name = class_match.group(1)
item_layouts = []
for bm in binding_pattern.finditer(content):
binding_class = bm.group(1)
if binding_class.endswith("Binding"):
layout_name = re.sub(r'(?<!^)(?=[A-Z])', '_', binding_class[:-7]).lower()
if layout_name not in item_layouts:
item_layouts.append(layout_name)
if item_layouts:
adapters[adapter_name] = {
"source_file": filepath,
"item_layouts": item_layouts,
}
return adapters
def scan_fragments(source_dirs):
"""Scan fragment source files to extract layout mappings.
Returns: { fragment_class_name: { "source_file": ..., "layout_file": ..., "binding_class": ... } }
"""
fragments = {}
class_pattern = re.compile(r'class\s+(\w+Fragment)\s*[:(]')
binding_pattern = re.compile(r'(\w+Binding)\.inflate\s*\(\s*inflater')
binding_pattern2 = re.compile(r'(\w+Binding)\.inflate\s*\(')
for source_dir in source_dirs:
if not os.path.isdir(source_dir):
continue
for root, dirs, files in os.walk(source_dir):
for f in files:
if not (f.endswith(".kt") or f.endswith(".java")):
continue
if "Fragment" not in f:
continue
filepath = os.path.join(root, f)
try:
with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
content = fh.read()
except Exception:
continue
class_match = class_pattern.search(content)
if not class_match:
continue
fragment_name = class_match.group(1)
if re.search(r'abstract\s+class\s+' + re.escape(fragment_name), content):
continue
binding_match = binding_pattern.search(content) or binding_pattern2.search(content)
layout_file = None
binding_class = None
if binding_match:
binding_class = binding_match.group(1)
if binding_class.endswith("Binding"):
layout_file = re.sub(r'(?<!^)(?=[A-Z])', '_', binding_class[:-7]).lower()
layout_match = re.search(r'inflate\s*\(\s*R\.layout\.(\w+)', content)
if layout_match and not layout_file:
layout_file = layout_match.group(1)
if layout_file:
fragments[fragment_name] = {
"source_file": filepath,
"layout_file": layout_file,
"binding_class": binding_class,
}
return fragments
def scan_dialog_layouts(source_dirs):
"""Scan dialog source files to extract their custom layout mappings.
Returns: { dialog_class_name: { "source_file": ..., "layout_file": ..., "binding_class": ... } }
"""
dialogs = {}
class_pattern = re.compile(r'class\s+(\w+Dialog)')
binding_pattern = re.compile(r'(\w+Binding)\.inflate\s*\(')
layout_pattern = re.compile(r'inflate\s*\(\s*R\.layout\.(\w+)')
for source_dir in source_dirs:
if not os.path.isdir(source_dir):
continue
for root, dirs, files in os.walk(source_dir):
for f in files:
if not (f.endswith(".kt") or f.endswith(".java")):
continue
if "Dialog" not in f:
continue
filepath = os.path.join(root, f)
try:
with open(filepath, "r", encoding="utf-8", errors="ignore") as fh:
content = fh.read()
except Exception:
continue
class_match = class_pattern.search(content)
if not class_match:
continue
dialog_name = class_match.group(1)
layout_file = None
binding_class = None
binding_match = binding_pattern.search(content)
if binding_match:
binding_class = binding_match.group(1)
if binding_class.endswith("Binding"):
layout_file = re.sub(r'(?<!^)(?=[A-Z])', '_', binding_class[:-7]).lower()
layout_match = layout_pattern.search(content)
if layout_match and not layout_file:
layout_file = layout_match.group(1)
if layout_file:
dialogs[dialog_name] = {
"source_file": filepath,
"layout_file": layout_file,
"binding_class": binding_class,
}
return dialogs
def analyze_menu_xml(menu_file, menu_dirs, resolver=None):
"""Parse a menu XML file to extract menu items."""
menu_path = None
for d in menu_dirs:
candidate = os.path.join(d, menu_file + ".xml")
if os.path.isfile(candidate):
menu_path = candidate
break
if not menu_path:
return {"menu_file": menu_file, "items": [], "error": "File not found"}
try:
tree = ET.parse(menu_path)
except ET.ParseError as e:
return {"menu_file": menu_file, "items": [], "error": str(e)}
import xml.etree.ElementTree as ET
ANDROID_NS = "http://schemas.android.com/apk/res/android"
APP_NS = "http://schemas.android.com/apk/res-auto"
root = tree.getroot()
items = _parse_menu_items(root, resolver)
return {"menu_file": menu_file, "items": items}
def _parse_menu_items(parent, resolver=None):
"""Recursively parse menu items."""
ANDROID_NS = "http://schemas.android.com/apk/res/android"
APP_NS = "http://schemas.android.com/apk/res-auto"
items = []
for elem in parent:
tag = elem.tag
if "}" in tag:
tag = tag.split("}", 1)[1]
if tag == "item":
item_id = elem.get(f"{{{ANDROID_NS}}}id", "")
if item_id:
item_id = item_id.replace("@+id/", "").replace("@id/", "")
title = elem.get(f"{{{ANDROID_NS}}}title", "")
if resolver and title.startswith("@string/"):
title = resolver.resolve(title)
icon = elem.get(f"{{{ANDROID_NS}}}icon", "")
show = elem.get(f"{{{APP_NS}}}showAsAction",
elem.get(f"{{{ANDROID_NS}}}showAsAction", ""))
visible = elem.get(f"{{{ANDROID_NS}}}visible", "true")
item = {
"id": item_id,
"title": title,
"icon": icon or None,
"show_as_action": show or None,
"visible": visible != "false",
}
submenu = elem.find("menu")
if submenu is not None:
item["submenu"] = _parse_menu_items(submenu, resolver)
items.append(item)
elif tag == "group":
items.extend(_parse_menu_items(elem, resolver))
elif tag == "menu":
items.extend(_parse_menu_items(elem, resolver))
return items
def main():
if len(sys.argv) < 3:
print("Usage: analyze_source.py <android_project_root> <discovery_json> [output_dir]")
sys.exit(1)
project_root = os.path.abspath(sys.argv[1])
discovery_path = os.path.abspath(sys.argv[2])
output_dir = os.path.abspath(sys.argv[3]) if len(sys.argv) > 3 else os.path.join(project_root, "UI_Analysis")
os.makedirs(output_dir, exist_ok=True)
with open(discovery_path, "r") as f:
discovery = json.load(f)
menu_dirs = []
for base in [os.path.join(project_root, "app", "src", "main", "res"),
os.path.join(project_root, "..", "Simple-Commons", "commons", "src", "main", "res")]:
menu_dir = os.path.join(base, "menu")
if os.path.isdir(os.path.normpath(menu_dir)):
menu_dirs.append(os.path.normpath(menu_dir))
sys.path.insert(0, os.path.dirname(__file__))
from parse_layout_xml import ResourceResolver
resolver = ResourceResolver()
res_dirs = [os.path.join(project_root, "app", "src", "main", "res")]
commons_res = os.path.normpath(os.path.join(project_root, "..", "Simple-Commons", "commons", "src", "main", "res"))
if os.path.isdir(commons_res):
res_dirs.append(commons_res)
resolver.load_resources(res_dirs)
analyses_dir = os.path.join(output_dir, "source_analyses")
os.makedirs(analyses_dir, exist_ok=True)
app_src = os.path.join(project_root, "app", "src")
all_source_dirs = []
for flavor in ["main", "proprietary", "foss", "prepaid"]:
for lang in ["kotlin", "java"]:
d = os.path.join(app_src, flavor, lang)
if os.path.isdir(d):
all_source_dirs.append(d)
commons_src = os.path.normpath(os.path.join(project_root, "..", "Simple-Commons", "commons", "src", "main", "kotlin"))
if os.path.isdir(commons_src):
all_source_dirs.append(commons_src)
print("Scanning adapters...")
adapter_map = scan_adapters(all_source_dirs)
print(f" Found {len(adapter_map)} adapters: {', '.join(adapter_map.keys())}")
print("Scanning fragments...")
fragment_map = scan_fragments(all_source_dirs)
print(f" Found {len(fragment_map)} fragments: {', '.join(fragment_map.keys())}")
print("Scanning dialog layouts...")
dialog_layout_map = scan_dialog_layouts(all_source_dirs)
print(f" Found {len(dialog_layout_map)} dialogs with layouts: {', '.join(dialog_layout_map.keys())}")
global_map_path = os.path.join(output_dir, "component_maps.json")
with open(global_map_path, "w", encoding="utf-8") as f:
json.dump({
"adapters": adapter_map,
"fragments": fragment_map,
"dialog_layouts": dialog_layout_map,
}, f, indent=2, ensure_ascii=False)
print(f"Component maps: {global_map_path}")
for activity in discovery["activities"]:
source_file = activity.get("source_file")
if not source_file:
continue
simple_name = activity["simple_name"]
print(f"Analyzing: {simple_name}")
analysis = analyze_activity_source(source_file)
analysis["activity_class"] = activity["class"]
analysis["simple_name"] = simple_name
for i, menu in enumerate(analysis["menus"]):
menu_detail = analyze_menu_xml(menu["menu_file"], menu_dirs, resolver)
analysis["menus"][i] = menu_detail
out_path = os.path.join(analyses_dir, f"{simple_name}.json")
if os.path.isfile(out_path):
with open(out_path, "r") as f:
existing = json.load(f)
for key in ("adapter_bindings", "fragment_containers"):
if key in existing and existing[key] and key not in analysis:
analysis[key] = existing[key]
elif key in existing and existing[key] and not analysis.get(key):
analysis[key] = existing[key]
with open(out_path, "w", encoding="utf-8") as f:
json.dump(analysis, f, indent=2, ensure_ascii=False)
for extra in discovery.get("extra_source_only", []):
source_file = extra.get("source_file")
if not source_file:
continue
simple_name = extra["class"].rsplit(".", 1)[-1]
print(f"Analyzing (extra): {simple_name}")
analysis = analyze_activity_source(source_file)
analysis["activity_class"] = extra["class"]
analysis["simple_name"] = simple_name
for i, menu in enumerate(analysis["menus"]):
menu_detail = analyze_menu_xml(menu["menu_file"], menu_dirs, resolver)
analysis["menus"][i] = menu_detail
out_path = os.path.join(analyses_dir, f"{simple_name}.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(analysis, f, indent=2, ensure_ascii=False)
print(f"\nSource analyses written to: {analyses_dir}/")
return 0
if __name__ == "__main__":
sys.exit(main())