From 5b1a30dc14e3535ecfc472c9756e78ab9a1bab8a Mon Sep 17 00:00:00 2001 From: "fatih.bulut" Date: Thu, 25 Jun 2026 23:50:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(clone-app):=20extract-nav-graph.py=20?= =?UTF-8?q?=E2=80=94=20navigation=20graph=20from=20nav=20XML=20+=20Compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clone-app/scripts/extract-nav-graph.py | 87 +++++++++++++++++++ .../nav-sample/res/navigation/nav_graph.xml | 16 ++++ .../clone-app/tests/test-extract-nav-graph.py | 29 +++++++ 3 files changed, 132 insertions(+) create mode 100644 plugins/clone-app/skills/clone-app/scripts/extract-nav-graph.py create mode 100644 plugins/clone-app/tests/fixtures/nav-sample/res/navigation/nav_graph.xml create mode 100644 plugins/clone-app/tests/test-extract-nav-graph.py diff --git a/plugins/clone-app/skills/clone-app/scripts/extract-nav-graph.py b/plugins/clone-app/skills/clone-app/scripts/extract-nav-graph.py new file mode 100644 index 0000000..86a9938 --- /dev/null +++ b/plugins/clone-app/skills/clone-app/scripts/extract-nav-graph.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Build a navigation graph from a decompiled APK. + +Primary source: res/**/navigation/*.xml (Jetpack Navigation) — fragment/activity/ +dialog nodes, edges. Secondary: Compose NavHost +composable() routes + navigate() calls grepped from .kt sources. Stdlib-only. +Emits nav-graph.json on stdout (or --out FILE). +""" +import os, re, json, glob, argparse +import xml.etree.ElementTree as ET + +ANDROID = "{http://schemas.android.com/apk/res/android}" +APP = "{http://schemas.android.com/apk/res-auto}" + +def _clean(s): + return (s or "").replace("@+id/", "").replace("@id/", "") + +def _nav_xml(root): + nodes, edges = [], [] + for f in glob.glob(os.path.join(root, "**", "navigation", "*.xml"), recursive=True): + try: + tree = ET.parse(f) + except ET.ParseError: + continue + rel = os.path.relpath(f, root) + for el in tree.getroot().iter(): + tag = el.tag.split("}")[-1] + if tag not in ("fragment", "activity", "dialog"): + continue + nid = _clean(el.get(ANDROID + "id")) + if not nid: + continue + nodes.append({"id": nid, "label": el.get(ANDROID + "name", ""), + "kind": tag, "source": rel}) + for child in list(el): + if child.tag.split("}")[-1] == "action": + dest = _clean(child.get(APP + "destination")) + if dest: + edges.append({"from": nid, "to": dest, + "trigger": _clean(child.get(ANDROID + "id")), + "source": rel}) + return nodes, edges + +def _compose(root): + nodes, edges = [], [] + comp_pat = re.compile(r'composable\(\s*["\']([^"\']+)["\']') + nav_pat = re.compile(r'navigate\(\s*["\']([^"\']+)["\']') + for dp, _, files in os.walk(root): + for fn in files: + if not fn.endswith(".kt"): + continue + path = os.path.join(dp, fn) + try: + text = open(path, encoding="utf-8", errors="replace").read() + except OSError: + continue + if "NavHost" not in text: + continue + rel = os.path.relpath(path, root) + for m in comp_pat.finditer(text): + nodes.append({"id": m.group(1), "label": m.group(1), + "kind": "composable", "source": rel}) + for m in nav_pat.finditer(text): + edges.append({"from": None, "to": m.group(1), + "trigger": "navigate", "source": rel}) + return nodes, edges + +def extract(root): + n1, e1 = _nav_xml(root) + n2, e2 = _compose(root) + fw = "navigation-xml" if n1 else ("compose" if n2 else "unknown") + return {"root": root, "framework": fw, "nodes": n1 + n2, "edges": e1 + e2} + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("root", help="decompile output root") + ap.add_argument("--out") + args = ap.parse_args() + text = json.dumps(extract(args.root), indent=2) + if args.out: + with open(args.out, "w") as f: + f.write(text) + else: + print(text) + +if __name__ == "__main__": + main() diff --git a/plugins/clone-app/tests/fixtures/nav-sample/res/navigation/nav_graph.xml b/plugins/clone-app/tests/fixtures/nav-sample/res/navigation/nav_graph.xml new file mode 100644 index 0000000..d9290fd --- /dev/null +++ b/plugins/clone-app/tests/fixtures/nav-sample/res/navigation/nav_graph.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/plugins/clone-app/tests/test-extract-nav-graph.py b/plugins/clone-app/tests/test-extract-nav-graph.py new file mode 100644 index 0000000..2fb6d8b --- /dev/null +++ b/plugins/clone-app/tests/test-extract-nav-graph.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import json, subprocess, sys, os + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRIPT = os.path.join(HERE, "..", "skills", "clone-app", "scripts", "extract-nav-graph.py") +ROOT = os.path.join(HERE, "fixtures", "nav-sample") + +def run(): + out = subprocess.check_output([sys.executable, SCRIPT, ROOT]) + return json.loads(out) + +def main(): + d = run() + fails = [] + def check(name, cond): + print(f"{'PASS' if cond else 'FAIL'}: {name}") + if not cond: fails.append(name) + ids = {n["id"] for n in d["nodes"]} + check("framework navigation-xml", d["framework"] == "navigation-xml") + check("login node", "loginFragment" in ids) + check("home node", "homeFragment" in ids) + check("node has name label", any(n["label"] == "com.example.LoginFragment" for n in d["nodes"])) + check("edge login->home", any(e["from"] == "loginFragment" and e["to"] == "homeFragment" for e in d["edges"])) + check("edge has trigger", any(e["trigger"] == "action_login_to_home" for e in d["edges"])) + for k in ["root","framework","nodes","edges"]: + check(f"key present: {k}", k in d) + sys.exit(1 if fails else 0) + +main()