Files
2026-10-15 16:35:05 +02:00

159 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Checks every Android / Apache HttpClient method and field the dex references against
published API declarations (NativeScript's android-platform-*.d.ts). Catches stub mistakes
(wrong name, arity, parameter or return kind) that would otherwise surface as
NoSuchMethodError on the phone. usage: check_api.py classes.dex android-platform-17.d.ts"""
import re
import sys
from loguru import logger
logger.remove()
from androguard.core.dex import DEX
dex_path, dts_path = sys.argv[1], sys.argv[2]
# ----------------------------------------------------------- parse the .d.ts
classes = {} # fqcn -> {"supers": [...], "methods": {name: [(params, ret)]}, "fields": set()}
stack = [] # (kind, name, depth_at_open)
depth = 0
cur = None
cls_re = re.compile(r"export (?:abstract )?class (\w+)(?:<[^{]*?>)?(?:\s+extends ([\w.]+)(?:<.*?>)?)?(?:\s+implements (.+?))?\s*\{")
mod_re = re.compile(r"(?:declare|export) module ([\w.]+) \{")
meth_re = re.compile(r"^\s*(?:public |protected )?(?:static )?(\w+)(?:<[^(]*>)?\((.*)\): (.+);\s*$")
field_re = re.compile(r"^\s*public (?:static )?(\w+): (.+);\s*$")
def strip_generics(t):
out, d = "", 0
for ch in t:
if ch == "<":
d += 1
elif ch == ">":
d -= 1
elif d == 0:
out += ch
return out.strip()
for line in open(dts_path, encoding="utf-8"):
m = mod_re.search(line)
c = cls_re.search(line)
if c:
name = ".".join([s[1] for s in stack if s[0] in ("mod", "cls")] + [c.group(1)])
supers = []
if c.group(2):
supers.append(c.group(2))
if c.group(3):
supers += [strip_generics(s) for s in strip_generics(c.group(3)).split(",")]
classes.setdefault(name, {"supers": [], "methods": {}, "fields": set()})["supers"] += supers
stack.append(("cls", c.group(1), depth))
cur = classes[name]
elif m:
for part in m.group(1).split("."):
stack.append(("mod", part, depth))
elif cur is not None and stack and stack[-1][0] == "cls":
mm = meth_re.match(line)
if mm and mm.group(1) not in ("constructor",):
params = [p for p in mm.group(2).split(", ") if p] if mm.group(2).strip() else []
ptypes = [strip_generics(p.split(": ", 1)[1]) if ": " in p else "any" for p in params]
cur["methods"].setdefault(mm.group(1), []).append((ptypes, strip_generics(mm.group(3))))
elif "public constructor(" in line and "implementation" not in line:
inner = line.split("public constructor(", 1)[1].rsplit(");", 1)[0]
params = [p for p in inner.split(", ") if p] if inner.strip() else []
cur["methods"].setdefault("<init>", []).append(
([strip_generics(p.split(": ", 1)[1]) for p in params], "void"))
else:
fm = field_re.match(line)
if fm:
cur["fields"].add(fm.group(1))
depth += line.count("{") - line.count("}")
while stack and depth <= stack[-1][2]:
stack.pop()
cur = classes.get(".".join(s[1] for s in stack if s[0] in ("mod", "cls"))) if stack and stack[-1][0] == "cls" else None
# ------------------------------------------------------------- type mapping
NUM = {"I", "J", "F", "D", "S", "B", "C"}
def ts_of(desc):
if desc in NUM:
return "number"
if desc == "Z":
return "boolean"
if desc == "V":
return "void"
if desc.startswith("["):
return "native.Array<%s>" % ts_of(desc[1:])
n = desc[1:-1].replace("/", ".").replace("$", ".")
if n in ("java.lang.String", "java.lang.CharSequence"):
return "string"
return n
def compatible(java, ts):
ts = ts.replace(" ", "")
if ts in ("any", "any[]"):
return True
if java.startswith("native.Array"):
return ts.startswith("native.Array") or ts.endswith("[]")
if java == "java.lang.Object":
return True
return java == ts or (java == "string" and ts == "string")
def lookup(cls, name, params, ret, seen=None):
seen = seen or set()
if cls in seen or cls not in classes:
return False
seen.add(cls)
for ptypes, r in classes[cls]["methods"].get(name, []):
if len(ptypes) == len(params) and all(compatible(j, t) for j, t in zip(params, ptypes)):
if name == "<init>" or compatible(ret, r) or r == "any":
return True
return any(lookup(s, name, params, ret, seen) for s in classes[cls]["supers"])
def has_field(cls, name, seen=None):
seen = seen or set()
if cls in seen or cls not in classes:
return False
seen.add(cls)
return name in classes[cls]["fields"] or any(has_field(s, name, seen) for s in classes[cls]["supers"])
# ------------------------------------------------------------- check the dex
raw = open(dex_path, "rb").read()
if raw[:2] == b"PK":
import zipfile
raw = zipfile.ZipFile(dex_path).read("classes.dex")
d = DEX(raw)
own = {c.get_name() for c in d.get_classes()}
bad, checked, seen = [], 0, set()
for mid in d.get_methods_id_item().get_obj():
cname = mid.get_class_name()
if not (cname.startswith("Landroid/") or cname.startswith("Lorg/apache/http/")) or cname in own:
continue
desc = mid.get_descriptor().replace(" ", "")
key = (cname, mid.get_name(), desc)
if key in seen:
continue
seen.add(key)
plist = re.findall(r"\[*(?:L[^;]+;|[ZBCSIJFD])", desc[1:desc.index(")")])
ret = desc[desc.index(")") + 1:]
cls = cname[1:-1].replace("/", ".").replace("$", ".")
checked += 1
if not lookup(cls, mid.get_name(), [ts_of(p) for p in plist], ts_of(ret)):
bad.append("%s.%s%s" % (cls, mid.get_name(), desc))
for fid in d.get_fields_id_item().get_obj():
cname = fid.get_class_name()
if not (cname.startswith("Landroid/") or cname.startswith("Lorg/apache/http/")) or cname in own:
continue
cls = cname[1:-1].replace("/", ".").replace("$", ".")
checked += 1
if not has_field(cls, fid.get_name()):
bad.append("field %s.%s" % (cls, fid.get_name()))
print("checked %d framework references against %d declared classes" % (checked, len(classes)))
for b in bad:
print(" NOT FOUND:", b)
sys.exit(1 if bad else 0)